An article about the implementation of the Builder pattern with a compile-check, implemented using parametric polymorphism. In it we will talk about what polymorphism is, how it happens. How the magic of the “operator” =: = in scala is arranged, whether it is possible to repeat it in java and how using this knowledge to implement Builder, which does not allow incomplete initialization of the created object.
When an entity with a set of properties arises in the system, there is a problem with its construction. Verbose constructor or set setters? The first looks cumbersome, the second is not safe: you can easily miss the call to the initialization method of an important property. To solve this problem, the Builder pattern is often resorted to.
The builder pattern solves two tasks: first, it separates the algorithm for creating (initializing) an object from the details of its (object) implementation, and secondly, it simplifies the process of creation:
UrlBuilder() .withSchema("http") .withHost("localhost") .withFile("/") .build()
The question remains: how to implement the builder so that it does not allow incomplete initialization of the object?
')
The simplest solution may seem to be checking all the properties in the build method. But such an approach will not be able to warn us against problems until they arise in the course of the program.
The next thing that comes to mind is
StepBuilder - the implementation of the builder, in which for each new step a separate class / interface is described. The disadvantage of this solution is the extreme redundancy of the implementation.
Scala advocates practice a slightly different approach. To check the completeness of the configuration of an object in scala, parametric polymorphism is used:
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()
If, when using such a builder, omit one configure () method and call the build () method, the compiler will generate an error:
scala> Builder()./*configured()*/.build()
Error:(_, _) Cannot prove that Builder[NotConfigured] =:= Builder[Configured].
Type control in this example is occupied by the “operator” =: =. The A =: = B entry says that the parametric (generic) type A should be equal to type B. We will return to this example and analyze the magic with which the scala compiler catches the incomplete initialization state of the object being created. For now, let us return to the world of a simpler and more understandable java and recall what polymorphism is.
In OOP, polymorphism is a property of the system that allows using objects with the same interface without information about the type and internal structure of the object. But what we used to call polymorphism in OOP, only a special case of polymorphism is polymorphism of subtypes. Another type of polymorphism is parametric polymorphism:
Parametric polymorphism allows you to define a function or data type generically, so values ​​are processed identically regardless of their type. Parametrically, a polymorphic function uses arguments based on behavior, not values, appealing only to the necessary properties of the arguments, which makes it applicable in any context where the type of object satisfies the specified behavior requirements.
An example would be the function <N extends Number> printNumber (N n). This function is performed only for the arguments of the class inherited from Number. Please note that the compiler is able to check the match of the type of the passed argument with all the expectations of the parameterized function and throw an exception if the function is called with an incorrect argument:
java> printNumber("123")
Error:(_, _) java: method printNumber ... cannot be applied to given types;
required: N
found: java.lang.String
...
This may suggest the build function, which is defined only for a fully configured instance of builder. But the question remains: how to explain this requirement to the compiler?
An attempt to describe a function by analogy with printNumber will not lead to success, since the parametric type will have to be specified when calling the function, and no one will interfere with indicating there everything your heart desires:
interface NotConfigured {} interface Configured {} static class Builder<A> { static Builder<NotConfigured> init() { return new Builder<>(); } private Builder() {} public Builder<Configured> configure() { return new Builder<>(); }
Let's go on the other hand: when we call the build method, we need proof that the current instance is fully configured:
public void build(EqualTypes<Configured, A> approve) ... class EqualTypes<L, R> {}
Now, to call the build method, we must pass an instance of the EqualTypes class such that type L is Configured and type R is type A defined in the current instance of Builder class.
So far there is little use for such a solution, simply omitting the type when creating an instance of EqualTypes and the compiler will allow us to call the build function:
public static void main(String[] args) { Builder.init()
But if we declare a parameterized factory method such that one would accept some type T and create an instance of the class EqualTypes <T, T>:
static <T> EqualTypes<T, T> approve() { return new EqualTypes<T, T>(); }
and calling the build method to pass the result of the approve function to it, we get the long-awaited result: the compiler will swear if you omit the call to the configure method:
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
The fact is that by the time the build method is called, the parametric type A of the Builder class has the value NotConfigured, and it is with this value that an instance is created as a result of calling the init method. The compiler cannot choose such a type T for the approve function, so that on the one hand it is equal to Configured, as required by the build method, and on the other hand NotConfigured as a parametric type A.
Now pay attention to the configure method — it returns an instance of the Builder class, in which the parametric type A is defined as Configured. Those. if the method call sequence is correct, the compiler will be able to output type T as Configured and the build method call will succeed!
java>Builder.init().configured().build(approve())
It's work!
It remains to ensure that the only way to create an instance of the EqualTypes class is the approve method, but this is a home task.
The type T can be a more complex type, for example
Builder <A> . The signature of the build method can be changed to a bit more cumbersome:
void build(EqualTypes<Builder<Configured>, Builder<A>> approve)
The advantage of this approach is that if you need to add a new mandatory method, it will be enough to start a new generic parameter for it.
UrlBuilder Example 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")
Let's go back to the scala example and look at how the “operator” =: = works. Here it is worth noting that the
infix form of writing type parameters is valid in scala, which allows you to write a structure like =: = [A, B] as A =: = B. Yes, yes! Actually =: = - no operator, this is an abstract class declared in scala.Predef, very similar to our 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] }
The only difference is that the function call approve (or rather its analogue tpEquals) compiler rocks automatically substitutes.
It turns out that the usual type manipulation in scala (
we are talking about the use of constructions =:=, <:<
) is quite applicable in java. But, nevertheless, the implicit mechanism provided for in scala makes such a decision more concise and convenient.
Another advantage of the implementation of the described approach in scala is the
@implicitNotFound
annotation, which allows you to control the content of the exception when compiling. This annotation applies to a class whose instance cannot be found for implicit substitution by the compiler.
The bad news is that you cannot change the error text for the = = = construct, but the good news is that you can now easily create your own analog with the message you need!
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()
Summing up, I can not fail to pay tribute to the authors of the scala language: without adding special constructions to the language, using only implicit parameters, the developers managed to enrich the language constructs with new and effective solutions that allow them to flexibly operate with types.
As for Java, the development of the language does not stand still, the language is changing for the better, incorporating solutions and constructions from other languages. At the same time, it is not always worth waiting for innovations from the authors of the language, some approaches and solutions can be borrowed now.