Хабрахабр

[Перевод] Строители против синтаксиса Java

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

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

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

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

public class User 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; }

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

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

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

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

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

public static class Builder<SELF extends Builder<SELF>> { SELF firstName(String value) { this.firstName = value; return (SELF) this; }

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

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

S. Сразу предупрежу: этого решения не существует (по крайней мере, пока что).
Было бы здорово такое получить, но пока я не слышал о существовании JEP об этом.
P. 😉
Кто-нибудь знает, как заводить новые 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-языкам).

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

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

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

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

Теги
Показать больше

Похожие статьи

Добавить комментарий

Ваш e-mail не будет опубликован. Обязательные поля помечены *

Кнопка «Наверх»
Закрыть