Pull to refresh
176.17
JUG Ru Group
Конференции для Senior-разработчиков

Строители против синтаксиса Java

Reading time 5 min
Views 24K
Original author: Sergei Egorov

Шаблон проектирования «строитель»один из самых популярных в Java.


Он простой, он помогает делать объекты неизменяемыми, и его можно генерировать инструментами вроде @Builder в Project Lombok или Immutables.


Но так ли удобен этот паттерн в Java?


Пример этого шаблона с вызовом методов цепочкой:


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, хоть о шаблонах проектирования!
Tags:
Hubs:
+47
Comments 156
Comments Comments 156

Articles

Information

Website
jugru.org
Registered
Founded
Employees
51–100 employees
Location
Россия
Representative
Алексей Федоров