Безопасный Builder на Scala и Java
Когда в системе возникает сущность с множеством свойств, возникает проблема с ее конструированием. Многословный конструктор или множество setter-ов? Первое выглядит громоздким, второе не безопасно: можно легко упустить вызов метода инициализации важного свойства. Для решения этой проблемы часто прибегают к паттерну Builder.
Паттерн builder решает две задачи: во-первых разделяет алгоритм создания (инициализации) объекта от деталей его (объекта) реализации, во-вторых упрощает сам процесс создания:
UrlBuilder()
.withSchema("http")
.withHost("localhost")
.withFile("/")
.build()
Остается вопрос: как реализовать builder так, чтобы он не допускал не полной инициализации объекта?
Самым простым решением может показаться проверка всех свойств в методе build. Но такой подход не сможет предостеречь нас от проблем до тех пор, пока они не возникнут в процессе выполнения программы.
Следующее что приходит на ум, это StepBuilder — реализация builder, в которой для каждого нового шага описан свой отдельный класс/интерфейс. Недостатком такого решения является крайняя избыточность реализации.
Несколько иной подход практикуют сторонники 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()
Если при использовании такого builder опустить один метод configure () и вызвать метод build (), компилятор выдаст ошибку:
scala> Builder()./*configured()*/.build()
Error:(_, _) Cannot prove that Builder[NotConfigured] =:= Builder[Configured].
Контролем типа в данном примере занимается «оператор» =:=. Запись A =:= B говорит о том, что параметрический (generic) тип A должен быть равен типу B. Мы еще вернемся к данному примеру и разберем магию, с помощью которой компилятор scala отлавливает незавершенное состояние инициализации создаваемого объекта. А пока вернемся в мир более простой и понятной java и вспомним что такое полиморфизм.
В ООП полиморфизм — свойство системы, позволяющее использовать объекты с одинаковым интерфейсом без информации о типе и внутренней структуре объекта. Но то, что мы привыкли называть полиморфизмом в ООП, только частный случай полиморфизма — полиморфизм подтипов. Другим видом полиморфизма является параметрический полиморфизм:
Параметрический полиморфизм позволяет определять функцию или тип данных обобщённо, так что значения обрабатываются идентично вне зависимости от их типа. Параметрически полиморфная функция использует аргументы на основе поведения, а не значения, апеллируя лишь к необходимым ей свойствам аргументов, что делает её применимой в любом контексте, где тип объекта удовлетворяет заданным требованиям поведения.
Примером может служить функция
java> printNumber("123")
Error:(_, _) java: method printNumber ... cannot be applied to given types;
required: N
found: java.lang.String
...
Это может натолкнуть на мысль о функции build, которая определена только для полностью сконфигурированного экземпляра builder. Но остается открытым вопрос: как объяснить это требование компилятору?
Попытка описать функцию по аналогии с printNumber к успеху не приведет, тк параметрический тип придется указывать при вызове функции и никто не помешает указать там все, что душе угодно:
interface NotConfigured {}
interface Configured {}
static class Builder {
static Builder init() {
return new Builder<>();
}
private Builder() {}
public Builder configure() {
return new Builder<>();
}
// первая попытка
public > void build() {
System.out.println("It's work!");
}
public static void main(String[] args) {
Builder.init()
// .configure() // вызов конфигурации опущен,
.>build() // но вызов метода build все еще доступен
}
}
Зайдем с другой стороны: потребуем при вызове метода build доказательство того, что текущий экземпляр полностью сконфигурирован:
public void build(EqualTypes approve)
...
class EqualTypes {}
Теперь чтобы вызвать метод build мы должны передать экземпляр класса EqualTypes такой, в котором тип L равен Configured, а тип R равен типу A, определенному в текущем экземпляре класса Builder.
Пока от такого решения проку мало, достаточно просто опустить тип при создании экземпляра EqualTypes и компилятор позволит нам вызвать функцию build:
public static void main(String[] args) {
Builder.init()
// .configure()
.build(new EqualTypes())
}
Но если объявить параметризированный фабричный метод такой, который принимал бы некоторый тип T и создавал экземпляр класса EqualTypes
static EqualTypes approve() {
return new EqualTypes();
}
и вызывая метода build передавать в него результат работы функции approve, мы получим долгожданный результат: компилятор будет ругаться, если опустить вызов метода 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, параметрический тип A класса Builder имеет значение NotConfigured, тк именно с таким значением создается экземпляр в результате вызова метода init. Компилятор не может подобрать такой тип T для функции approve, чтобы он с одной стороны был равен Configured, как того требует метод build, и с другой стороны NotConfigured как параметрический тип A.
Теперь обратите внимание на метод configure — он возвращает такой экземпляр класса Builder, в котором параметрический тип A определен как Configured. Т.е. при правильной последовательности вызова методов компилятор сможет вывести тип T как Configured и вызов метода build пройдет успешно!
java>Builder.init().configured().build(approve())
It's work!
Осталось добиться того, чтобы единственным способом создать экземпляр класса EqualTypes остался метод approve, но это уже задание на дом.
В качестве типа T может выступать более сложный тип, например Builder. Сигнатура метода build может быть изменена на несколько более громоздкую:
void build(EqualTypes, Builder> approve)
Преимущество такого подхода заключается в том, что если понадобится добавить новый обязательный метод, достаточно будет завести для него новый generic параметр.
interface Defined {}
interface Undefined {}
class UrlBuilder {
private String schema = "";
private String host = "";
private int port = -1;
private String file = "/";
static UrlBuilder 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 withSchema(String schema) {
return new UrlBuilder<>(schema, host, port, file);
}
public UrlBuilder withHost(String host) {
return new UrlBuilder<>(schema, host, port, file);
}
public UrlBuilder withPort(int port) {
return new UrlBuilder<>(schema, host, port, file);
}
public UrlBuilder withFile(String file) {
return new UrlBuilder<>(schema, host, port, file);
}
public URL build(EqualTypes< UrlBuilder, UrlBuilder> 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 и посмотрим на то, как устроен «оператор» =:=. Здесь стоит заметить, что в 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]
}
Разница лишь в том, что вызов функции approve (а точнее ее аналога tpEquals) компилятор скалы подставляет автоматически.
Получается, что привычное оперирование типами в scala (речь идет о применении конструкций =:=, <:<
) вполне применимо в java. Но, тем не менее, механизм implicit предусмотренный в scala делает подобное решение более лаконичным и удобным.
Еще одним преимуществом реализации описанного подхода в scala является аннотация @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: не добавляя специальных конструкций в язык, используя только implicit параметры, разработчикам удалось обогатить языковые конструкции новыми и эффективными решениями, позволяющими гибко оперировать типами.
Что касается Java, развитие языка не стоит на месте, язык меняется в лучшую сторону, вбирая в себя решения и конструкции из других языков. При этом не всегда стоит ждать нововведений от авторов языка, некоторые подходы и решения можно пробовать заимствовать уже сейчас.
Комментарии (4)
6 марта 2017 в 14:37
0↑
↓
У меня 2 вопроса:
1. Стоят ли все эти усилия того, чтобы просто внимательно отработать на get/set методах при создании объекта (при код ревью или самопроверке)?
2. Не является ли такой подход более многословным, нежели JUnit тест мест создания таких объектов?
Спасибо.Я спрашиваю, просто очень много на хабре пишут про билдере в ключе создания объектов, но вот в моей 8 летней практике я ни разу не встречал проблем при работе с такими объектами (get/set).
Тем не менее сам патерн билдер я использовал немного в другом ключе — создание PrepareStatement для разных версий и типов АБС при интеграции данных.6 марта 2017 в 14:48
0↑
↓
Если смотреть с точки зрения использования самого builder-а, то он может казаться избыточным. Тем более при наличии модульных тестов. Но если вы пишете builder с тем, чтобы его использовали другие люди, то Ваши усилия могут окупиться сполна
6 марта 2017 в 15:57
0↑
↓
Все вроде бы хорошо, но как быть, когда вызвать надо не только configure, но и configure2, configure3 и т.д.?6 марта 2017 в 16:58
0↑
↓
Посмотрите пример с UrlBuilder (под спойлером), там как раз несколько обязательных к конфигурирование свойств.