ScalaとJavaのSafe Builder

パラメトリック多相性を使用して実装されたコンパイルレベル検証を使用したBuilderパターンの実装に関する記事。 その中で、ポリモーフィズムが何であるかについて、それが起こったときに話します。 Scalaに配置された「演算子」の魔法はどのようになっていますか。Javaでそれを繰り返すことはできますか。



多くのプロパティを持つエンティティがシステムで発生すると、その構築に問題が発生します。 冗長なコンストラクターまたは多くのセッター? 最初は面倒に見えますが、2番目は安全ではありません。重要なプロパティの初期化メソッドの呼び出しを見逃す可能性があります。 この問題を解決するために、彼らはしばしばBuilderパターンに頼ります。



ビルダーパターンは、2つの問題を解決します。1つ目は、オブジェクトの作成(初期化)アルゴリズムを(オブジェクト)実装の詳細から分離し、2つ目は、作成プロセスを簡素化します。



UrlBuilder() .withSchema("http") .withHost("localhost") .withFile("/") .build()
      
      





問題は残っています:オブジェクトの不完全な初期化を許可しないようにビルダーを実装する方法ですか?



最も簡単な解決策は、ビルドメソッドのすべてのプロパティをチェックするように見えるかもしれません。 しかし、このようなアプローチでは、プログラムの実行中に問題が発生するまで問題を警告することはできません。



次に頭に浮かぶのは、新しいステップごとに個別のクラス/インターフェースを記述するビルダー実装であるStepBuilderです。 このソリューションの欠点は、実装の極端な冗長性です。



Scalaサポーターは少し異なるアプローチを採用しています。 scalaでオブジェクト構成の完全性を確認するには、パラメトリック多相性が使用されます。



 trait NotConfigured trait Configured class Builder[A] private() { def configure(): Builder[Configured] = new Builder[Configured] def build()(implicit ev: Builder[A] =:= Builder[Configured]) = { println("It's work!") } } object Builder { def apply(): Builder[NotConfigured] = { new Builder[NotConfigured]() } } Builder() .configure() //      ! .build()
      
      





そのようなビルダーを使用するときに、1つのconfigure()メソッドを省略してbuild()メソッドを呼び出すと、コンパイラーはエラーを生成します。



scala> Builder()./*configured()*/.build()

Error:(_, _) Cannot prove that Builder[NotConfigured] =:= Builder[Configured].






この例では、「演算子」=:=は型制御に関与しています。 表記A =:= Bは、ジェネリック型Aが型Bと等しくなければならないことを示します。この例に戻り、作成されたオブジェクトの不完全な初期化状態をscalaコンパイラがキャッチするマジックを分析します。 それまでの間、より単純で理解しやすいJavaの世界に戻り、ポリモーフィズムとは何かを思い出しましょう。



OOPでは、ポリモーフィズムはシステムのプロパティであり、オブジェクトのタイプと内部構造に関する情報なしで、同じインターフェースを持つオブジェクトを使用できます。 しかし、OOPでポリモーフィズムと呼ばれていたのは、ポリモーフィズムの特殊なケース、つまりサブタイプポリモーフィズムにすぎません。 別のタイプのポリモーフィズムは、パラメトリックポリモーフィズムです。

パラメトリック多相性により、関数またはデータ型を一般的な方法で定義できるため、値は型に関係なく同じように処理されます。 パラメトリック多相関数は、値ではなく動作に基づいて引数を使用し、必要な引数のプロパティのみに訴えます。これにより、オブジェクトのタイプが動作の指定要件を満たすあらゆるコンテキストに適用できます。


例は、関数<N extends Number> printNumber(N n)です。 この関数は、Numberの派生クラスの引数に対してのみ実行されます。 コンパイラーは、渡された引数の型とパラメーター化された関数のすべての期待値との対応を確認し、無効な引数で関数が呼び出された場合に例外をスローできることに注意してください。



java> printNumber("123")

Error:(_, _) java: method printNumber ... cannot be applied to given types;

required: N

found: java.lang.String

...






これは、完全に構成されたビルダーのインスタンスに対してのみ定義されるビルド関数のアイデアにつながる可能性があります。 しかし、疑問は未解決のままです。この要件をコンパイラーに説明する方法は?



関数を呼び出すときにパラメトリックタイプを指定する必要があるため、printNumberとの類推によって関数を記述しようとしても成功しません。



 interface NotConfigured {} interface Configured {} static class Builder<A> { static Builder<NotConfigured> init() { return new Builder<>(); } private Builder() {} public Builder<Configured> configure() { return new Builder<>(); } //   public <T extends Builder<Configured>> void build() { System.out.println("It's work!"); } public static void main(String[] args) { Builder.init() // .configure() //   , .<Builder<Configured>>build() //    build    } }
      
      





一方、ビルドメソッドを呼び出すとき、現在のインスタンスが完全に構成されていることの証明が必要になります。



 public void build(EqualTypes<Configured, A> approve) ... class EqualTypes<L, R> {}
      
      





ここで、buildメソッドを呼び出すには、LがConfiguredと等しく、RがBuilderクラスの現在のインスタンスで定義されたAと等しくなるように、EqualTypesクラスのインスタンスを渡す必要があります。



このようなソリューションはほとんど使用されませんが、EqualTypesインスタンスを作成するときに型を単に省略すれば十分であり、コンパイラーはビルド関数を呼び出すことができます。



 public static void main(String[] args) { Builder.init() // .configure() .build(new EqualTypes()) }
      
      





ただし、パラメーター化されたファクトリーメソッドを宣言して、T型を受け取り、クラスEqualTypes <T、T>のインスタンスを作成する場合:



 static <T> EqualTypes<T, T> approve() { return new EqualTypes<T, T>(); }
      
      





承認メソッドの結果を渡すためにbuildメソッドを呼び出すと、待望の結果が得られます。configureメソッドの呼び出しを省略すると、コンパイラは誓います。



java>Builder.init()./*configured()*/.build(approve())

Error:(_, _) java: incompatible types: inferred type does not conform to equality constraint(s)

inferred: NotConfigured

equality constraints(s): NotConfigured,Configured






実際、buildメソッドが呼び出されるまでに、BuilderクラスのパラメータータイプAはNotConfiguredになります。これは、initメソッドの呼び出しの結果としてインスタンスが作成されるのはこの値であるためです。 コンパイラーは、承認関数のタイプTを選択できません。そのため、一方ではビルドメソッドの必要に応じてConfiguredに等しくなり、他方ではパラメータータイプAとしてNotConfiguredになります。



次に、configureメソッドに注意してください。このメソッドは、パラメトリックタイプAがConfiguredとして定義されているBuilderクラスのインスタンスを返します。 つまり メソッド呼び出しの正しいシーケンスを使用すると、コンパイラーはConfiguredとしてタイプTを推測でき、buildメソッドの呼び出しは成功します!



java>Builder.init().configured().build(approve())

It's work!






EqualTypesクラスのインスタンスを作成する唯一の方法がapproveメソッドであることを確認するために残りますが、これはすでにホームタスクです。



タイプTは、より複雑なタイプ、たとえばBuilder <A>である場合があります。 ビルドメソッドのシグネチャは、やや面倒になるように変更できます。



 void build(EqualTypes<Builder<Configured>, Builder<A>> approve)
      
      





このアプローチの利点は、新しい必須メソッドを追加する必要がある場合、そのメソッドの新しい汎用パラメーターを取得するだけで十分であることです。



UrlBuilderの例
 interface Defined {} interface Undefined {} class UrlBuilder<HasSchema, HasHost, HasFile> { private String schema = ""; private String host = ""; private int port = -1; private String file = "/"; static UrlBuilder<Undefined, Undefined, Undefined> init() { return new UrlBuilder<>(); } private UrlBuilder() {} private UrlBuilder(String schema, String host, int port, String file) { this.schema = schema; this.host = host; this.port = port; this.file = file; } public UrlBuilder<Defined, HasHost, HasFile> withSchema(String schema) { return new UrlBuilder<>(schema, host, port, file); } public UrlBuilder<HasSchema, Defined, HasFile> withHost(String host) { return new UrlBuilder<>(schema, host, port, file); } public UrlBuilder<HasSchema, HasHost, HasFile> withPort(int port) { return new UrlBuilder<>(schema, host, port, file); } public UrlBuilder<HasSchema, HasHost, Defined> withFile(String file) { return new UrlBuilder<>(schema, host, port, file); } public URL build(EqualTypes< UrlBuilder<Defined, Defined, Defined>, UrlBuilder<HasSchema, HasHost, HasFile>> approve) throws MalformedURLException { return new URL(schema, host, file); } public static void main(String[] args) throws MalformedURLException { UrlBuilder .init() .withSchema("http") //   .withHost("localhost") //    .withFile("/") //     ! .build(EqualTypes.approve()); } }
      
      







scalaの例に戻って、「operator」=:=の仕組みを見てみましょう。 scalaでは、 型パラメーターを記述する中置形式が許可されているため、== = [A、B]をA =:= Bとして記述することができます。はい、はい! 実際には=:=-演算子はありません。これはscala.Predefで宣言された抽象クラスで、EqualTypesと非常によく似ています!



 @implicitNotFound(msg = "Cannot prove that ${From} =:= ${To}.") sealed abstract class =:=[From, To] extends (From => To) with Serializable private[this] final val singleton_=:= = new =:=[Any,Any] { def apply(x: Any): Any = x } object =:= { implicit def tpEquals[A]: A =:= A = singleton_=:=.asInstanceOf[A =:= A] }
      
      





唯一の違いは、承認関数(またはそのアナログtpEquals)の呼び出しがロックコンパイラを自動的にコンパイルすることです。



scalaでの通常の型処理( =:=, <:<



構文の使用について話している
)は、javaで非常に適切であることがわかります 。 しかし、それにもかかわらず、scalaで提供される暗黙のメカニズムにより、このようなソリューションはより簡潔で便利になります。



説明したアプローチをscalaで実装するもう1つの利点は、 @implicitNotFound



アノテーションです。これにより、コンパイル中に例外の内容を制御できます。 この注釈は、コンパイラによる暗黙的な置換のためにインスタンスが見つからないクラスに適用されます。



悪いニュースは、構成===のエラーテキストを変更できないことですが、良いものは-必要なメッセージを使用して独自のアナログを簡単に作成できることです!



 object ScalaExample extends App { import ScalaExample.Builder.is import scala.annotation.implicitNotFound trait NotConfigured trait Configured class Builder[A] private() { def configure(): Builder[Configured] = new Builder[Configured] def build()(implicit ev: Builder[A] is Builder[Configured]) = { println("It's work!") } } object Builder { @implicitNotFound("Builder is not configured!") sealed abstract class is[A, B] private val singleIs = new is[AnyRef, AnyRef] {} implicit def verifyTypes[A]: A is A = singleIs.asInstanceOf[is[A, A]] def apply(): Builder[NotConfigured] = { new Builder[NotConfigured]() } } Builder() .configure() //      : .build() // Builder is not configured! }
      
      





要約すると、私はscala言語の作者に敬意を払うしかありません:暗黙的なパラメーターのみを使用して言語に特別な構造を追加することなく、開発者は柔軟な型処理を可能にする新しい効果的なソリューションで言語構造を充実させることができました



Javaに関しては、言語の開発はまだ止まっておらず、他の言語のソリューションと構成を取り入れて、言語はより良く変化しています。 ただし、言語の作者からの革新を常に待つ価値はありません。いくつかのアプローチと解決策を今すぐ借りることができます。



All Articles