Безопасный Builder на Scala и Java

в 9:38, , рубрики: builder, java, patterns, scala

Статья о реализации паттерна Builder с проверкой на уровне компиляции, реализованного с помощью параметрического полиморфизма. В ней мы поговорим о том, что такое полиморфизм, каким он бывает. Как устроена магия «оператора» =:= в scala, можно ли повторить ее в java и как используя эти знания реализовать Builder, не допускающий неполной инициализации создаваемого объекта.

Когда в системе возникает сущность с множеством свойств, возникает проблема с ее конструированием. Многословный конструктор или множество 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 и вспомним что такое полиморфизм.

В ООП полиморфизм — свойство системы, позволяющее использовать объекты с одинаковым интерфейсом без информации о типе и внутренней структуре объекта. Но то, что мы привыкли называть полиморфизмом в ООП, только частный случай полиморфизма — полиморфизм подтипов. Другим видом полиморфизма является параметрический полиморфизм:

Параметрический полиморфизм позволяет определять функцию или тип данных обобщённо, так что значения обрабатываются идентично вне зависимости от их типа. Параметрически полиморфная функция использует аргументы на основе поведения, а не значения, апеллируя лишь к необходимым ей свойствам аргументов, что делает её применимой в любом контексте, где тип объекта удовлетворяет заданным требованиям поведения.

Примером может служить функция <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
...

Это может натолкнуть на мысль о функции build, которая определена только для полностью сконфигурированного экземпляра builder. Но остается открытым вопрос: как объяснить это требование компилятору?

Попытка описать функцию по аналогии с 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 все еще доступен
    }
}

Зайдем с другой стороны: потребуем при вызове метода build доказательство того, что текущий экземпляр полностью сконфигурирован:

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

Теперь чтобы вызвать метод build мы должны передать экземпляр класса EqualTypes такой, в котором тип L равен Configured, а тип R равен типу A, определенному в текущем экземпляре класса Builder.

Пока от такого решения проку мало, достаточно просто опустить тип при создании экземпляра EqualTypes и компилятор позволит нам вызвать функцию build:

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 передавать в него результат работы функции 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<A>. Сигнатура метода build может быть изменена на несколько более громоздкую:

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

Преимущество такого подхода заключается в том, что если понадобится добавить новый обязательный метод, достаточно будет завести для него новый generic параметр.

Пример 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 и посмотрим на то, как устроен «оператор» =:=. Здесь стоит заметить, что в 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, развитие языка не стоит на месте, язык меняется в лучшую сторону, вбирая в себя решения и конструкции из других языков. При этом не всегда стоит ждать нововведений от авторов языка, некоторые подходы и решения можно пробовать заимствовать уже сейчас.

Автор: dokwork

Источник

* - обязательные к заполнению поля


https://ajax.googleapis.com/ajax/libs/jquery/3.4.1/jquery.min.js