📜 ⬆️ ⬇️

Builders against Java syntax

The builder design pattern is one of the most popular in Java.


It is simple, it helps to make objects immutable, and it can be generated with tools like @Builder in Project Lombok or Immutables .


But is this pattern so convenient in Java?


An example of this pattern with chaining methods:


public class User {

  private final String firstName;

  private final String lastName;

  User(String firstName, String lastName) {
    this.firstName = firstName;
    this.lastName = lastName;
  }

  public static Builder builder() {
      return new Builder();
  }

  public static class Builder {

    String firstName;
    String lastName;

    Builder firstName(String value) {
        this.firstName = value;
        return this;
    }

    Builder lastName(String value) {
        this.lastName = value;
        return this;
    }

    public User build() {
        return new User(firstName, lastName);
    }
  }
}

User.Builder builder = User.builder().firstName("Sergey").lastName("Egorov");

if (newRules) {
    builder.firstName("Sergei");
}

User user = builder.build();

Что мы тут получаем:


  1. Класс User — иммутабельный, мы не можем изменить объект после создания.
  2. У его конструктора видимость в пределах пакета, и для создания экземпляра User надо обращаться к строителю.
  3. Поля Builder изменяемые, и перед созданием экземпляра User могут меняться неоднократно.
  4. Сеттеры собираются в цепочки и возвращают this (типа Builder).

Так… и в чём тут проблема?


Проблема с наследованием


Представим, что мы захотели унаследовать класс User:


public class RussianUser extends User {
    final String patronymic;

    RussianUser(String firstName, String lastName, String patronymic) {
        super(firstName, lastName);
        this.patronymic = patronymic;
    }

    public static RussianUser.Builder builder() {
        return new RussianUser.Builder();
    }

    public static class Builder extends User.Builder {

        String patronymic;

        public Builder patronymic(String patronymic) {
            this.patronymic = patronymic;
            return this;
        }

        public RussianUser build() {
            return new RussianUser(firstName, lastName, patronymic);
        }
    }
}

RussianUser me = RussianUser.builder()
    .firstName("Sergei") // возвращает User.Builder :(
    .patronymic("Valeryevich") // Метод не вызвать!
    .lastName("Egorov")
    .build();

Проблема возникает в связи с тем, что метод firstName определён так:


   User.Builder firstName(String value) {
        this.value = value;
        return this;
    }

И у Java-компилятора нет никакой возможности определить, что в данном случае this означает RussianUser.Builder, а не просто User.Builder!


Даже изменение порядка не поможет:


RussianUser me = RussianUser.builder()
    .patronymic("Valeryevich")
    .firstName("Sergei")
    .lastName("Egorov")
    .build() // ошибка компиляции! User нельзя присвоить RussianUser
    ;

Возможное решение: self typing


Один из способов решения проблемы — добавить к User.Builder дженерик, указывающий, какой тип надо вернуть:


 public static class Builder<SELF extends Builder<SELF>> {

    SELF firstName(String value) {
        this.firstName = value;
        return (SELF) this;
    }

И установить там RussianUser.Builder:


   public static class Builder extends User.Builder<RussianUser.Builder> {

Теперь это работает:


RussianUser.builder()
    .firstName("Sergei") // возвращает RussianUser.Builder :)
    .patronymic("Valeryevich") // RussianUser.Builder
    .lastName("Egorov") // RussianUser.Builder
    .build(); // RussianUser

И с несколькими уровнями наследования тоже работает:


class A<SELF extends A<SELF>> {

    SELF self() {
        return (SELF) this;
    }
}

class B<SELF extends B<SELF>> extends A<SELF> {}

class C extends B<C> {}

Так что, проблема решена? Не совсем… Теперь невозможно получить объект базового типа!
Поскольку мы используем рекурсивное определение с дженериками, у нас появилась проблема с рекурсией!


new A<A<A<A<A<A<A<...>>>>>>>()


В принципе, это можно решить (если вы не используете Kotlin):


A a = new A<>();

Тут мы используем «сырые типы» (raw types) и diamond operator из Java. Но, как упомянуто выше, это не работает с другими языками, да и вообще в целом это хак.


Идеальное решение: Self typing в Java


Сразу предупрежу: этого решения не существует (по крайней мере, пока что).
Было бы здорово такое получить, но пока я не слышал о существовании JEP об этом.
P.S. Кто-нибудь знает, как заводить новые JEP? ;)

Self typing существует как языковая фича в языках вроде Swift.
Представьте следующий выдуманный Java-пример:


class A {

    @Self
    void withSomething() {
        System.out.println("something");
    }
}

class B extends A {
    @Self
    void withSomethingElse() {
        System.out.println("something else");
    }
}

new B()
    .withSomething() // использует получателя вместо void
    .withSomethingElse();

Как видите, проблема может быть решена на уровне компилятора.
Для этого существуют даже плагины к javac вроде аннотации Self в Manifold.


Реальное решение: подойти иначе


Но что, если вместо попыток решить проблему возвращаемого типа, мы… уберём тип вообще?


public class User {

  // ...

    public static class Builder {

        String firstName;
        String lastName;

        void firstName(String value) {
            this.firstName = value;
        }

        void lastName(String value) {
            this.lastName = value;
        }

        public User build() {
            return new User(firstName, lastName);
        }
    }
}
public class RussianUser extends User {

    // ...

    public static class Builder extends User.Builder {

        String patronymic;

        public void patronymic(String patronymic) {
            this.patronymic = patronymic;
        }

        public RussianUser build() {
            return new RussianUser(firstName, lastName, patronymic);
        }
    }
}

RussianUser.Builder b = RussianUser.builder();
b.firstName("Sergei");
b.patronymic("Valeryevich");
b.lastName("Egorov");
RussianUser user = b.build(); // RussianUser

«Это неудобно и многословно, по крайней мере, в Java» — скажете вы.
И я соглашусь, но… является ли это проблемой самого паттерна Строитель?
Помните, как я сказал, что он может быть изменяемым? Давайте тогда этим воспользуемся!
Добавим это к нашему исходному строителю:


public class User {

  // ...

    public static class Builder {
        public Builder() {
            this.configure();
        }

        protected void configure() {}

И используем его как анонимный объект:


RussianUser user = new RussianUser.Builder() {
    @Override
    protected void configure() {
        firstName("Sergei"); // из User.Builder
        patronymic("Valeryevich"); // из RussianUser.Builder
        lastName("Egorov"); // из User.Builder
    }
}.build();

Наследование перестало быть проблемой, но многословность осталась.
Тут пригодится другая «фича» Java: инициализация с двойными фигурными скобками.


RussianUser user = new RussianUser.Builder() {{
    firstName("Sergei");
    patronymic("Valeryevich");
    lastName("Egorov");
}}.build();

Тут мы используем блок инициализации, чтобы задать все поля. Любители Swing/Vaadin могут узнать этот подход ;)


Некоторым он не нравится (кстати, напишите тогда в комментариях, почему). Мне нравится. Я не стал бы использовать его там, где критична производительность, но, скажем, в случае с тестами он выглядит соответствующим всем критериям:


  1. Может быть использован с любой версией Java со времён царя Гороха.
  2. Работает с другими JVM-языками.
  3. Краткий.
  4. Нативная возможность языка, а не хак.

Заключение


Как мы увидели, хоть Java и не предлагает синтаксис для self typing, мы можем решить проблему с помощью другой возможности Java (и не портя всю малину другим JVM-языкам).


Хотя некоторые разработчики считают инициализацию с двойными фигурными скобками антипаттерном, она выглядит ценной для определённых сценариев. В конце концов, это просто синтаксический сахар для определения конструктора внутри анонимного класса.


Мне интересно, как другие люди подходят к этой проблеме и что вы думаете о компромиссах разных подходов!


P.S. Большое спасибо Ричарду Норсу и Кевину Виттеку за проверку текста.


Минутка рекламы. С прошлого года я работаю в Pivotal над Project Reactor, и на JPoint (5-6 апреля) выступлю с докладом о нём — а в дискуссионной зоне после этого можно будет зарубиться хоть о Reactor, хоть о шаблонах проектирования!


Source: https://habr.com/ru/post/438866/