Шаблон проектирования «строитель» — один из самых популярных в 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();
Что мы тут получаем:
- Класс User — иммутабельный, мы не можем изменить объект после создания.
- У его конструктора видимость в пределах пакета, и для создания экземпляра User надо обращаться к строителю.
- Поля Builder изменяемые, и перед созданием экземпляра User могут меняться неоднократно.
- Сеттеры собираются в цепочки и возвращают 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 могут узнать этот подход ;)
Некоторым он не нравится (кстати, напишите тогда в комментариях, почему). Мне нравится. Я не стал бы использовать его там, где критична производительность, но, скажем, в случае с тестами он выглядит соответствующим всем критериям:
- Может быть использован с любой версией Java со времён царя Гороха.
- Работает с другими JVM-языками.
- Краткий.
- Нативная возможность языка, а не хак.
Заключение
Как мы увидели, хоть Java и не предлагает синтаксис для self typing, мы можем решить проблему с помощью другой возможности Java (и не портя всю малину другим JVM-языкам).
Хотя некоторые разработчики считают инициализацию с двойными фигурными скобками антипаттерном, она выглядит ценной для определённых сценариев. В конце концов, это просто синтаксический сахар для определения конструктора внутри анонимного класса.
Мне интересно, как другие люди подходят к этой проблеме и что вы думаете о компромиссах разных подходов!
P.S. Большое спасибо Ричарду Норсу и Кевину Виттеку за проверку текста.
Минутка рекламы. С прошлого года я работаю в Pivotal над Project Reactor, и на JPoint (5-6 апреля) выступлю с докладом о нём — а в дискуссионной зоне после этого можно будет зарубиться хоть о Reactor, хоть о шаблонах проектирования!
Комментарии (140)
fzn7
06.02.2019 14:15Простое решение — отказаться от наследования и использовать композицию
bsideup Автор
06.02.2019 14:19А можно с примерами Java кода?
fzn7
06.02.2019 14:36Код вам ничего не даст, вы нарушаете принцип Лисковски (ниже написали уже). Необходимо убрать все поля, относящиеся к имени пользователя в отдельный класс Credentials и работать с ним, дополняя при необходимости. Если у вас появляется необходимость в локализации, то user должен держать у себя список всех Credentials для всех поддерживаемых локалей и доставаться по ключу при необходимости.
bsideup Автор
06.02.2019 14:42там внизу в комментарии есть problem definition. Ваш "идеальный" код никто не станет использовать, потому что он громоздкий и неудобный (по крайней мере в той библиотеке что я описываю).
Поэтому я и предлагаю переубедить, с примерами кода, иначе ваши комментарии выглядят как "я прочитал книжку, вы всё делаете неправильно, а я — умный".
fzn7
06.02.2019 14:51Ок, для вашего примера предлагаю подобную структуру
JDBCContainer extends AbstractJDBCContainer implements IJDBCContainer -> config<T extends BaseJDBCConfig> -> containerStrategy<T extends JDBCContainerStrategy> ConcreteJDBCContainerStrategy<T extends BaseJDBCConfig> config extends AbstractJDBCContainerStrategy implements JDBCContainerStrategy ConcreteJDBCContainerStrategy.setConfig(T config)
Хочу отметить, что абстрактные классы служат лишь возможности добавления хуков вокруг методов интерфейса и не реализуют никакой конкретной логикиbsideup Автор
06.02.2019 15:48Ух ты как Java изменилась при Собянине
Допустим, я прочитал Ваш код. Но он не отвечает на вопрос! Как сделать удобный API для всего этого, чтобы пользователям было удобно?
Вас сразу выдаёт то, что вы приводите примеры объявления классов, а не пример их использования.
Когда мы у себя проектируем API наших DSL, мы начинаем с "как пользователь, я хочу использовать это вот так", а потом ищем варианты как это реализовать, на грани с возможностями языка, с учетом наших пользователей. А уже потом думаем, чтобы это ещё было поддерживаемо и читаемо.fzn7
06.02.2019 15:58О, началось. Ему не удобно. И почему я не удивлен данным развитием событий? Пост оказывается не про корректную архитектуру приложения с попыткой найти оптимум, а что-бы вам вместо alt+insert надо было только точку жмакать.
bsideup Автор
06.02.2019 16:00оказывается не про корректную архитектуру приложения
Внезапно то как!
Ему не удобно
Мне удобно было бы вообще не иметь public API. Нет API — нет проблем. Клаааас.
fzn7
06.02.2019 16:06Так что мешает под код выше написать (сгенерировать?) fluent api?
MysqlDBContainer .builder() .withConfig( MysqlConfig.builder() .withLogin("login") .withPassword("password") .build()) .withStrategy( KafcaContainerStrategy.builder() .build()) .build();
Норм, не?bsideup Автор
06.02.2019 16:12как пользователь, мне б это не понравилось, и вот почему:
1) API discoverability — если я хочу настроить порт, то мне надо знать наперёд в каком из билдеров этот метод для настройки порта указан
2) многословность — в вашем примере вы по сути дела устанавливаете 3 параметра, но при этом код нагружен.builder()
,.build()
и их друзьями
3) результат.build()
должен содержать параметры всех "билдеров" (к сожалению в вашем примере это невозможно продемонстрировать), и, если это был билдер BarBuilder, то результат должен быть знать о свойствах Bar, а не только Foofzn7
06.02.2019 16:24А потому-что писать правильно это не бесплатно. В данном случае плата это +4 слова build() и builder() в коде. Печально конечно. Но еще более печально забирать на поддержку очередной написанный с нарушением Лисковски проект, переживший уже три бригады бракоделов. Приходится с грустным лицом умножать смету на 3
bsideup Автор
06.02.2019 16:32Самое забавное — в моём личном опыте сложней всего для поддержки проекты доставались именно от товарищей, которые пропагандируют "правильные" способы, пишут на Java как на Scala, а ночью под подушкшой читают куски Haskell кода.
Так что думаю тут знаний "мудрейших" не достаточно, и надо уметь их применять там, где оно уместно, и если что-то можно сделать проще — то почему бы и нет?
IvanVakhrushev
07.02.2019 08:29+1Лисковски — это Barbara Liskov? Или какой-то новый персонаж?
bsideup Автор
07.02.2019 12:48+1Я готов все свои карма поинты обменять на плюсы к Вашему комментарию, это просто прекрасно!
fzn7
07.02.2019 22:20-1Ок, уровень дискуссии понятен
Dim0v
08.02.2019 12:01А что не так с уровнем дискуссии? Принцип Лисков код из статьи никак не нарушает. Если он нарушает какой-то принцип какого-то (какой-то) Лисковски, то было бы неплохо пояснить, кто это такой (такая) и что это за принцип, а то я вот тоже ничего о таком персонаже не слышал.
vladimirsitnikov
07.02.2019 13:32bsideup, справедливости ради, пункт №2 решается, если методы withStrategy, withConfig и прочие принимают аргументы типа Builder.
Да, пущай они сами вызовут .build(), зато в клиентском коде этого мусора не будет. Или я чего-то упускаю?
В простых случаях может быть даже такое: withStrategy(KafkaContainerStrategy::builder);bsideup Автор
07.02.2019 13:37ну, не решается, скорей просто убирает часть проблемы. Но проблема всё же остаётся, особенно когда на реальных примерах её погонять (прошли через это, был одним из вариантов API)
skapral
06.02.2019 14:55Правильное решение — перестать хаять наследование по поводу и без, и просто перестать его использовать для создания типов, которые не являются subtypeами по LSP от родителя.
fzn7
06.02.2019 15:00Согласен, я поправил себя чуть ниже и уточнил про Лисковски. Его легко нарушить, что выливается в «проще вообще запретить наследоваться»
skapral
06.02.2019 15:16+1LSP вообще просто нарушить, даже если тупо интерфейс имплементировать. И ничто не проверит за девелопера корректность LSP — ни компилятор, ни статический анализатор, ни даже система типов. Единственный реальный недостаток наследования по отношению к остальным инструментам sybtypingа только в том, что с ним нарушить LSP проще всего.
fzn7
06.02.2019 15:29Можете привести пример как можно нарушить lsp имплементацией интерфейса? (без дефолтных методов, которые есть суть ересь)
skapral
06.02.2019 15:46Ну например взять какой нибудь интерфейс SortedSet, сымплементировать от него класс, и допустить в имплементации какой нибудь косяк с сортировкой. В итоге, все программы, принимающие на вход SortedSet и справедливо делающие предположение о том что итерация по коллекции будет идти по определенному порядку, перестанут быть корректными после подстановки класса с косяком. Прямое нарушение LSP. Но языкам пофиг — пока синтаксически все верно, они все сбилдят и запустят.
DukeKan
06.02.2019 19:48Суть кстати — это устаревший вариант множественного числа слова «есть»
www.slovomania.ru/dnevnik/2007/01/25/sut
nicholas_k
06.02.2019 14:25ИМХО проблема на ровном месте.
Наследоваться вообще надо крайне осторожно, а уж наследоваться от иммутабельного класса выглядит откровенным извращением, поскольку в Java иммутабельность как свойство класса есть, а наследования иммутабельности нет. То есть заложена бомба под принцип L из набора SOLID.bsideup Автор
06.02.2019 14:32Пример:
Есть проект — Testcontainers. В нём есть базовый класс GenericContainer.
Есть наследники типа KafkaContainer.
Есть даже промежуточные наследования: MySQLContainer -> JDBCContainer -> GenericContainer.
Должна быть возможность их конфигурировать, чтобы удобно, красиво и вот это вот всё.
Как бы вы решили эту проблему на ровном месте? :)Kelsink
06.02.2019 16:37А что делает этот GenericContainer и что в нем наследовать?
Интерфейс с дефолтной реализацией (если она нужна) — Container.
Любые промежуточные интерфейсы (миксины).
Реализации интерфейсов, которые никак друг друга не наследуют.
gnefedev
06.02.2019 14:39У нас в проекте такие же билдеры с наследованием как в статье. И они валидны, потому что родитель абстрактный. А в одном месте наслдеование только в билдерах, стоят они один и тот же immutable объект но с разным наполнением.
Evgenij_Popovich
06.02.2019 14:53Для себя проблему наследования решил выделением абстрактного билдера с дженерик аргументами. Выглядит «слегка» монстроуозно, но работает
Код@Test public void testBuilders(){ User user = new User.Builder() .firstName("Sergei") .lastName("Egorov") .build(); assertEquals("Sergei", user.firstName); assertEquals("Egorov", user.lastName); User userCopy = new User.Builder(user) .build(); assertEquals("Sergei", userCopy.firstName); assertEquals("Egorov", userCopy.lastName); RussianUser russianUser = new RussianUser.Builder() .firstName("Sergei") .patronymic("Valeryevich") .lastName("Egorov") .build(); assertEquals("Sergei", russianUser.firstName); assertEquals("Valeryevich", russianUser.patronymic); assertEquals("Egorov", russianUser.lastName); RussianUser russianUserCopy = new RussianUser.Builder(russianUser) .build(); assertEquals("Sergei", russianUserCopy.firstName); assertEquals("Valeryevich", russianUserCopy.patronymic); assertEquals("Egorov", russianUserCopy.lastName); } public static class User { public final String firstName; public final String lastName; User(AbstractBuilder builder) { firstName = builder.firstName; lastName = builder.lastName; } public static class Builder extends AbstractBuilder<Builder, User> { public Builder() { } public Builder(User item) { super(item); } @Override public User build() { return new User(this); } } public static abstract class AbstractBuilder< BUILDER extends AbstractBuilder, RETURN extends User> { String firstName; String lastName; public AbstractBuilder() { } public AbstractBuilder(RETURN item) { firstName(item.firstName); lastName(item.lastName); } BUILDER firstName(String value) { this.firstName = value; return getBuilder(); } BUILDER lastName(String value) { this.lastName = value; return getBuilder(); } public BUILDER getBuilder() { return (BUILDER) this; } public abstract RETURN build(); } } public static class RussianUser extends User { final String patronymic; RussianUser(AbstractBuilder builder) { super(builder); patronymic = builder.patronymic; } public static class Builder extends AbstractBuilder<Builder, RussianUser> { public Builder() { } public Builder(RussianUser item) { super(item); } @Override public RussianUser build() { return new RussianUser(this); } } public static abstract class AbstractBuilder< BUILDER extends AbstractBuilder, RETURN extends RussianUser> extends User.AbstractBuilder<BUILDER, RETURN> { String patronymic; public AbstractBuilder() { } public AbstractBuilder(RETURN item) { super(item); patronymic(item.patronymic); } BUILDER patronymic(String value) { this.patronymic = value; return getBuilder(); } } }
bsideup Автор
06.02.2019 15:41+1Это примерно то, что у нас сейчас (и в статье описано в секции про generic версию, только без 2х классов).
Такой подход имеет место быть (так например работает SuperBuilder в Lombok на сколько знаю), но, к сожалению, он очень тяжело даётся контрибьюторам в проект, особенно рекурсивные конструкции вида <SELF extends MyClass>.
Это, кстати, очень интересная тема. Одно дело — умные книжки про как можно и нельзя, а другое — как потом с таким кодом работать, особенно в OSS, где каждый контрибьютор важен.
Разработчики Testcontainers тоже не глупые и тоже разные книжки читали, но, как и во всём — лучшее враг хорошего :)
reforms
06.02.2019 15:41К сожалению в методе configure Вам пришлось отказаться от чейна, но Вы ведь так за него боролись?
bsideup Автор
06.02.2019 15:42Оно потому и "подойти иначе". Мы долго боролись за chaining, что забыли что есть другие способы, и что без него можно сделать вполне читаемый вариант :)
bsideup Автор
06.02.2019 15:48Я до сих пор удивлён что никто не упомянул трюк с
.and()
как это делает Spring Security:
https://docs.spring.io/spring-security/site/docs/current/reference/html/jc.html#jc-httpsecurity
olegnyr
06.02.2019 15:49Хотелось бы добавить что в lombok добавили аннотацию @SuperBuilder
которая делает все это. Но плагин для idea JetBrains пока не поддерживает но есть issuesbsideup Автор
06.02.2019 15:52Продублирую мой ответ из твиттера:
SuperBuilder работает только если код и сторонние библиотеки к этому коду используют Java и Lombok. В нашем случае это не всегда так.
speakingfish
06.02.2019 19:14+1Уже разбирал эту тему с незаслуженно забытым double brace initialization: habr.com/ru/post/261163
И тут: stackoverflow.com/questions/1958636/what-is-double-brace-initialization-in-java/32404313#32404313
alexeiz
06.02.2019 20:33Жду продолжения темы: "Дальнобольщики против оператора goto" и "Грузщики против синглтонов".
poxvuibr
06.02.2019 22:12А почему бы не сделать отдельный билдер для каждого юзера? Какую проблему мы тут решаем введением наследования для билдеров?
vintage
07.02.2019 07:44А почему бы не оставить User мутабельным, но через отдельный его метод получать иммутабельный интерфейс?
Что-то типа:
RussianUser user = new RussianUser user.firstName = "Sergei"; user.patronymic ="Valeryevich"; user.lastName = "Egorov"; UserView userView = RussianUser.view(); userView.lastName = "Egorov"; //error
mayorovp
07.02.2019 09:04Так в итоге так и вышло, с той только разницей что RussianUser называется RussianUser.Builder, а UserView называется RussianUser.
vintage
07.02.2019 09:51Не, в статье билдер описывает поля, сеттеры и передаёт это всё конктруктору, который проставляет это всё юзеру. В моём коде же нет никаких конструкторов и сеттеров, а UserView — не более чем публичный интерфейс.
poxvuibr
07.02.2019 10:14В вашем коде объект User — изменяемый. А в коде из статьи есть гарантии, что он не изменится.
a_e_tsvetkov
07.02.2019 10:18А в реализации этого интерфейса конструктор, такой же как и у оригинального User.
vintage
07.02.2019 20:51Сам User является реализацией интерфейса UserView. Ну или их лучше назвать UserRaw и User, ибо интерфейс чаще используется.
a_e_tsvetkov
08.02.2019 06:48Т.е. метод view будет возвращать this. Тогда иммутабельность будет не настоящая. Состояние можно будет изменить через оригинальный user, а это не то что изначально требовалось.
vintage
08.02.2019 06:52Ну да, а можно не изменять. Какие проблемы?
a_e_tsvetkov
08.02.2019 06:59+1Изначально задача стояла так чтобы сделать чтобы было нельзя изменять.
vintage
08.02.2019 08:36-1В данном случае решение о том «можно или нельзя менять» принимается там же, где и «менять или не менять», то есть при создании объекта. Так что разницы — никакой.
poxvuibr
08.02.2019 09:15Это вам разницы никакой, а jvm разницу найдёт. Не увидит final на полях и поймёт, что никакого решения о неизменяемости никто никогда не принимал.
vintage
08.02.2019 09:21И что она сможет сделать с этой информацией?
poxvuibr
08.02.2019 11:52С этой — ничего. А вот, если бы у неё была информация о том, что объект неизменяем, то можно было бы применить какие-нибудь оптимизации.
vintage
08.02.2019 14:10Так какие оптимизации?
poxvuibr
08.02.2019 14:20В контексте вопроса о разнице между неизменяемым объектом и неизменяемым интерфейсом к изменяемому объекту не важно о каких конкретно оптимизациях идёт речь. Важно, что в одном случае они возможны, а в другом нет.
vintage
08.02.2019 14:27Как лихо вы утверждаете что возможно, а что нет, не зная сути оптимизаций.
poxvuibr
08.02.2019 14:51Мне кажется я понял ваш вопрос. Вы сомневаетесь, что существуют оптимизации, которые можно сделать, когда на поле стоит final и нельзя, когда final не стоит и к нему есть сеттер?
Так как final поля никогда не меняются, можно точно знать, что если прочитать final поле из соседнего потока, то его значение будет точно таким же, каким было в момент создания объекта. Про не final поля такого сказать нельзя.
Конкретные оптимизации, которые jvm применяет для таких полей зависят от того, о какой jvm идёт речь. Насколько я представляю себе вопрос — в первую очередь речь идёт о кешировании. Ещё вроде некотороые сборщики мусора могут использовать эту информацию.
Опять же, прогресс не стоит на месте и те оптимизации, которых нет сегодня — появятся завтра. Поэтому, а ещё потому, что final нужен не только jvm, но и программисту, правило правой руки — ставь final везде, где можно. А если получится, то и где нельзя.
vintage
08.02.2019 16:11Ява разве даёт какие-либо гарантии касательно многопоточности, чтобы было что там оптимизировать?
Кешировать чтение из объекта она имеет полное право, ибо опять же ничего не гарантирует касательно видимости изменений из соседнего потока.
Ок, прогресс не стоит на месте, например компилятор начинает лучше понимать код и сам расставлять final, inline, pure, nogc и прочие атрибуты.poxvuibr
08.02.2019 16:58Ява разве даёт какие-либо гарантии касательно многопоточности, чтобы было что там оптимизировать?
Да, эти гарантии описаны в Java Memory Model.
Кешировать чтение из объекта она имеет полное право, ибо опять же ничего не гарантирует касательно видимости изменений из соседнего потока.
Гарантии видимости поля из соседнего потока дать можно, просто для final полей они обойдутся дешевле, посколько достаточно эти поля закешить, и не надо их каждый раз обновлять.
например компилятор начинает лучше понимать код и сам расставлять final, inline, pure, nogc и прочие атрибуты.
Если проставлять final, это не помешает оптимизациям, описанным вами. Но поможет тем оптимизациям, которые опираются на final. Реализовать эти последние оптимизации, кстати, проще, чем первые.
vintage
08.02.2019 17:23Да, эти гарантии описаны в Java Memory Model.
Там, насколько мне известно, описана семантика synchronized, который ставится программистом вручную на те поля, которые могут измениться в процессе жизни объекта. Такие поля не могут быть final по очевидным причинам. Там же, где synchronized не используется, final ничего и не даст. Аналогично и с volatile.
poxvuibr
08.02.2019 18:06+1synchronized ставится не на поля, а на методы.
Там же, где synchronized не используется, final ничего и не даст. Аналогично и с volatile.
То, что вы называете synchronized — нам самом деле volatile. Объявить поле одновременно как final и volatile нельзя. Фактически для поля final даёт те же гарантии, что volatile, но дешевле.
vintage
08.02.2019 19:06-1Это замечательно, что вы знаете Яву лучше меня. В более других языках synchronized ставится на классы, а volatile нет вообще. Но давайте по существу. Зачем ставить volatile на поле, которое вы не меняете?
poxvuibr
08.02.2019 22:13В более других языках synchronized ставится на классы, а volatile нет вообще.
Наверное, там synchronized обладает не той семантикой, которой обладает в джаве. И вообще конкарренси в более других языках — более другая штука. Какие языки вы имеете в виду, кстати? Это не по теме ветки, просто любопытно.
Но давайте по существу. Зачем ставить volatile на поле, которое вы не меняете?
Я видимо, не совсем ясно выразился. Нельзя поставить volatile на поле, которое не меняется, поэтому вопрос "зачем" отпадает сам по себе. volatile нельзя поставить на неизменяемое поле, потому что это бессмысленно, так как неизменяемые поля при чтении уже и так ведут себя как будто volatile на них висит. Но единственный способ сделать поле неизменяемым — повесить на него final.
Если final на поле нет, гарантий, что поле не изменится тоже нет.
vintage
08.02.2019 22:52dlang.org/spec/class.html#synchronized-classes
По остальному не буду повторяться.poxvuibr
09.02.2019 13:42+1Хочу уточнить, что в D synchronized, как и в Java ставится на методы, а synchronized на классе просто добавляет synchronized на все методы. volatile нет, но ключевые слова, которые делают примерно то же самое есть.
vintage
09.02.2019 14:20Всё это конечно очень важные уточнения. Ну коли пошла такая пьянка, то лишь на все публичные методы.
О каких ключевых словах идёт речь? В D для этого используется прямое указание барьеров памяти через подключаемую библиотеку.poxvuibr
09.02.2019 14:36О каких ключевых словах идёт речь?
Прежде всего о shared. Ещё есть const и immutale, я подозреваю, что они дают эффект похожий на final в джаве.
vintage
09.02.2019 15:07volatile-то тут при чём?
shared, const и immutable — не более чем атрибуты, используемые для проверки типов.poxvuibr
09.02.2019 15:42volatile-то тут при чём?
volatile делает так, чтобы изменения, которые произошли с полем в одном потоке были видны в другом. shared делает так, чтобы изменения, сделанные с переменной в одном потоке, впринципе могли быть видны в другом. В общем, похоже на volatile. Правда, в отличии от volatile, если shared нет, то изменения в другом потоке точно не будут видны.
shared, const и immutable — не более чем атрибуты, используемые для проверки типов.
Нет, помимо этого они ещё указывают где хранить переменную. Делать локальную копию для каждого потока или во всех потоках использовать одну и ту же переменную.
vintage
09.02.2019 17:29Помимо этого они ещё указывают где хранить переменную.
Только shared и только для глобальных переменных.
mayorovp
08.02.2019 12:54При создании объекта разницы, может быть, и никакой нет. А вот при использовании разница есть.
Либо известно, что объект гарантированно никогда не изменится независимо от того что во внешнем коде нахимичили — либо такой гарантии нет и нужно делать защитную копию.vintage
08.02.2019 14:13Ява разве умеет давать гарантию глубокой иммутабельности? Если нет, то защитную копию по любому делать придётся.
mayorovp
08.02.2019 14:19Такую гарантию умеет давать программист. Нужно всего лишь объявить все поля как final и выбрать для них иммутабельные типы данных.
vintage
08.02.2019 14:30И чем принципиально отличается обещание программиста "я этот объект после создания уже не меняю" от "я нигде не забыл проставить final и нигде не использую мутабельные типы данных"?
a_e_tsvetkov
08.02.2019 13:20Так не принимается же.
RussianUser user = new RussianUser()ж user.firstName = "Sergei"; user.lastName = "Egorov"; UserView userView = RussianUser.view(); userView.getLastName(); // возвращает Egorov user.lastName = "Ivanov"; userView.getLastName(); // перестало возвращть Egorov
Вы конечно можете сказать что не надо переприсваивать, но хотелось бы чтобы компилятор проверял что никто не переприсвоит.vintage
08.02.2019 14:16Ссылку на user этот кто-то как получит, если вы передадите ему userView?
poxvuibr
08.02.2019 14:54Ссылка на user ему уже передана в объекте userView. Это ведь один и тот же объект. Осталось только скастовать его к User и можно делать всё, что захочется.
vintage
08.02.2019 16:14Ну если у вас параноя, то можно и в прокси спрятать.
poxvuibr
08.02.2019 17:07Можно. Но по сложности это примерно то же самое, что использование билдера. Но билдер в нагрузку ещё выдаёт неизменяемый объект.
vintage
08.02.2019 17:27Ну конечно. В билдере надо объявить все те же свойства, прокинуть их потом в конструктор, в конструкторе присвоить полям. Прокси же в конструкторе имеет одно единственное поле и портянку делегирующих методов, которые и кодогенератор легко сделает.
poxvuibr
08.02.2019 18:07Прокси же в конструкторе имеет одно единственное поле и портянку делегирующих методов, которые и кодогенератор легко сделает.
Я же говорю, всё как в билдере один в один. Только билдер целиком делается генератором, который уже вот сейчас есть в среде разработки в поставке по умолчанию.
babylon
07.02.2019 15:39Необязательно иммутабельный, но в пропсах интерфейса логично предусмотреть обязательный мод, задающий иммутабельность текущего контента. Что-то из контента ведь может быть и мутабельным. Зачем закрывать такую возможность.
vintage
07.02.2019 20:53Разумеется иммутабельно в нём должно быть только то, что не должно меняться.
babylon
08.02.2019 16:47Вопрос можно поставить шире — как одному и тому же контексту технично задавать разные свойства. Это может и пермишн, и скрытость и мутабельность Типы ключей к удивлению не могут быть объектами.
vintage
08.02.2019 16:54Тем не менее членам класса можно устанавливать разные атрибуты, которые могут быть и объектами.
reforms
07.02.2019 09:59+1Мне интересно, как другие люди подходят к этой проблеме и что вы думаете о компромиссах разных подходов!
Целый день думал, как еще можно решить пролему паттерна builder с сохранением цепочки вызовов. Получилость так:
RussianUser user = RussianUser.builder().apply(userBuilder -> userBuilder .firstName("Sergey") .lastName("Egorov")) .patronymic("Valeryevich") .build(); // где apply это public static class Builder extends User.Builder { ... // можно прокинуть логику заполнения базовой сущности public Builder apply(Consumer<User.Builder> baseConfigure) { baseConfigure.accept(this); return this; } ... }
Что думаете???
bsideup Автор
07.02.2019 12:56Думали про такой вариант. Неплохой, но отпал т.к.
1) ухудшается API discoverabilitiy — надо знать какой из apply дёрнуть чтобы настроить firstName, вместо .builder().firstName()
2) если наследование глубже 1 класса, то вообще страшно выходит
3) лямбду нельзя на инстанс "забиндить"reforms
07.02.2019 13:42Подход с интерфейсным программированием не рассматривали?
Идея — pojo объекты и билдеры к ним — чистые контракты, а реализация достигается за счет кодогенерации.
В примере ниже рабочий эскиз, правда вместо кодогенерации реализация через интерфейсное программирование
package code_gen; import java.lang.invoke.MethodHandles.Lookup; import java.lang.reflect.Field; import java.lang.reflect.InvocationHandler; import java.lang.reflect.Method; import java.lang.reflect.Proxy; import java.util.HashMap; import java.util.Map; public class JustForFun { public static void main(String[] args) { // Нюансы с конфигурацией, лямбдами не рассматриваются IRussianUserBuilder builder = ProxyBuilder.getBuilder(IRussianUserBuilder.class); builder.firstName("Sergey"); builder.lastName("Egorov"); builder.patronymic("Valeryevich"); IRussianUser user = builder.build(); System.out.println(user); // >> {firstName=Sergey, lastName=Egorov, patronymic=Valeryevich} System.out.println(user.fullName()); // >> Sergey Egorov Valeryevich } } //----------------- ИДЕЯ - интерфейсное программирование --------------------------------- // Контракт. Реализация или кодогенерацией или java.lang.reflect.InvocationHandler (академически/для тестов) interface IUser { String firstName(); String lastName(); /** Бизнес логика все еще возможна, но без состояния :) */ default String fullName() { return new StringBuilder().append(firstName()).append(" ").append(lastName()).toString(); } } //Контракт. Реализация или кодогенерацией или java.lang.reflect.InvocationHandler (академически/для тестов) interface IRussianUser extends IUser { String patronymic(); /** Бизнес логика все еще возможна, но без состояния :) */ @Override default String fullName() { return new StringBuilder().append(firstName()).append(" ").append(lastName()).append(" ").append(patronymic()).toString(); } } //Контракт. Реализация или кодогенерацией или java.lang.reflect.InvocationHandler (академически/для тестов) interface IUserBuilder { IUserBuilder firstName(String firstName); IUserBuilder lastName(String secondName); IUser build(); } //Контракт. Реализация или кодогенерацией или java.lang.reflect.InvocationHandler (академически/для тестов) interface IRussianUserBuilder extends IUserBuilder { IRussianUserBuilder patronymic(String patronymic); @Override IRussianUser build(); } //---------------- java.lang.reflect.InvocationHandler (для Академических целей/для тестов) --------------------------------- class ProxyBuilder implements InvocationHandler { private Map<String, Object> data = new HashMap<>(); @Override public Object invoke(Object proxy, Method method, Object[] args) throws Throwable { // TODO: Обработка методов equals/hashcode и прочее if ("toString".equals(method.getName())) { return data.toString(); } if ("build".equals(method.getName()) && (args == null || args.length == 0)) { return ProxyPojo.getPojo(method.getReturnType(), data); } // TODO: можно добавить любые методы.. // Реализация максимум упрощена return data.put(method.getName(), args[0]); } @SuppressWarnings("unchecked") public static <T> T getBuilder(Class<T> builderType) { return (T) Proxy.newProxyInstance(builderType.getClassLoader(), new Class[] {builderType}, new ProxyBuilder()); } } class ProxyPojo implements InvocationHandler { private final Map<String, Object> data; ProxyPojo(Map<String, Object> data) { this.data = data; } @Override public Object invoke(Object proxy, Method method, Object[] args) throws Throwable { // TODO: Обработка методов equals/hashcode и прочее if (method.isDefault()) { return invokeDefaultMethod(proxy, method, args); } if ("toString".equals(method.getName())) { return data.toString(); } // Реализация максимум упрощена return data.get(method.getName()); } @SuppressWarnings("unchecked") public static <T> T getPojo(Class<T> pojoType, Map<String, Object> data) { return (T) Proxy.newProxyInstance(pojoType.getClassLoader(), new Class[] {pojoType}, new ProxyPojo(data)); } //--------- поддержка default -------------------------------- private static final Lookup TRUSTED_LOOKUP = getLookupField(); private static Lookup getLookupField() { try { Field lookupField = Lookup.class.getDeclaredField("IMPL_LOOKUP"); lookupField.setAccessible(true); return (Lookup) lookupField.get(null); } catch (Exception ex) { throw new RuntimeException(ex); } } private Object invokeDefaultMethod(Object proxy, Method method, Object[] args) throws Throwable { return TRUSTED_LOOKUP .in(method.getDeclaringClass()) .unreflectSpecial(method, method.getDeclaringClass()) .bindTo(proxy) .invokeWithArguments(args); } }
bsideup Автор
07.02.2019 13:50
С таким очень сложно работать, нет нормального способа хранить состояние (например, коллекции открытых портов, или переменных окружения), ну и не очевидный способ создания через прокси и вот это вот всё (в PR кстати чуть по-другому это решили)
a_e_tsvetkov
07.02.2019 10:07Если уж хочется странного то что мешает сделать так:
class Base { private final int field1; Base(int field1) { this.field1 = field1; } public static class BaseBuilder extends BaseBuilderEx<BaseBuilder> { public Base build() { return new Base(field1); } } protected static class BaseBuilderEx<T> { protected int field1; public T field1(int field1) { this.field1 = field1; return (T) this; } } } class Derived extends Base { private final int field2; Derived(int field1, int field2) { super(field1); this.field2 = field2; } static class DerivedBuilder extends DerivedBuilderEx<DerivedBuilder> { public Derived build() { return new Derived(field1, field2); } } protected static class DerivedBuilderEx<T> extends Base.BaseBuilderEx<T> { protected int field2; public T field2(int field2) { this.field2 = field2; return (T) this; } } }
И создавай себе инстансы
new Derived.DerivedBuilder() .field1(1) .field2(1) .build(); new Base.BaseBuilder() field1(1) .build();
reforms
07.02.2019 10:14+1Ваш пример есть же в статье?
a_e_tsvetkov
07.02.2019 18:07Не совсем. Там есть версия у которой по мнению автора есть проблемы. Мой код решает эти проблемы.
ovsale
07.02.2019 10:16я остановился на след подходе:
public class Tag { public final int id; public final String name; private Tag(int id, String name) { this.id = id; this.name = name; } public static Tag create() { return new Tag(-1, ""); } public Tag setId(int id) { return new Tag(id, name); } public Tag setName(String name) { return new Tag(id, name); } } Tag tag1 = Tag.create().setId(5); tag1 = tag1.setName("Test");
плюсы:
— значения по умолчанию определяются в одном месте
— удобно менять любое количество полей. главное не забывать что создается новый объект
— не нужен билдер
минусы:
— при изменении одного поля происходит создание объекта с «копированием» всех полей
bsideup Автор
07.02.2019 12:58Такой подход реализуется аннотацией
@Wither
в Lombok-е, но, к сожалению, не подходит для унаследованных сущностей.ovsale
07.02.2019 13:23тут с наследованием проблем нет. и в общем синтаксически нет так и сложно. а пользы много. я широко пользуюсь подобной конструкцией
bsideup Автор
07.02.2019 13:33+1class MyTag extends Tag {} MyTag tag = new MyTag().setId("yo"); // <-- Error!
Летали. Знаем.
ovsale
07.02.2019 16:09-2вот так:
public class ColoredTag extends Tag { public final String color; private ColoredTag(int id, String name, String color) { super(id, name); this.color = color; } public static ColoredTag create() { return new ColoredTag(-1, "", null); } public ColoredTag setId(int id) { return new ColoredTag(id, name, color); } public ColoredTag setName(String name) { return new ColoredTag(id, name, color); } public ColoredTag setColor(String color) { return new ColoredTag(id, name, color); } }
и никаких newbsideup Автор
07.02.2019 16:15+1и никаких new
по-моему в вашем примере кол-во "new" удваивается при добавлении нового наследника ;)
А когда пропертей десятки, то такой код начинает пугать. И ещё, у него есть один важный недостаток — при добавлении метода в базовый класс, он не попадает в наследников пока его вручную везде не добавят.
ovsale
07.02.2019 17:05это на любителя. есть классы которые используются настолько часто что можно потратить время на их написание. один раз написал зато потом 30 раз удобно пользоваться.
действительно при добавлении нового поля нужно N раз скопипастить строку с конструктором (она одинаковая во всех сеттерах):
return new ColoredTag(id, name, color);
ovsale
08.02.2019 11:06-1MyTag tag = new MyTag().setId(«yo»); // < — Error!
это действительно ошибка ибо id имеет тип int.
как вы рассчитываете создать sub класс методом parent класса???
в java если вы хотите работать с immutable объектами — придется немного попотеть. так как есть только один способ создания таких объектов через конструктор устанавливающий все поля. и если их 10 — значит 10. мой способ позволяет вынести эти конструкторы внутрь класса и не видеть их в основном (сложном) коде.
а еще ваш Builder не умеет устанавливать отдельные поля.
Throwable
07.02.2019 13:41Основной недостаток паттерна Builder — это то, что в отличие от конструктора, он синтаксически никак не учитывает обязательные поля, и позволяет вызвать build() до того, как все необходимые значения будут установлены. При этом даже в рантайме проверка может как делаться, так и не делаться. И для этого нет хорошего решения. Поэтому паттерн Builder полезен лишь когда у нас все поля опциональные.
sshikov
07.02.2019 21:04+1Ну, на самом деле решение-то возможно, билдер вполне может возвращать разные объекты после каждого вызова. У тех из них, где еще нельзя вызывать build, его просто не будет. Другое дело, что построить такую конструкцию достаточно сложно.
ovsale
08.02.2019 11:10в моем подходе это можно сделать так:
public static Tag create(int id) { return new Tag(id, ""); }
id становится обязательно устанавливаемымThrowable
08.02.2019 23:06По-идее так и нужно делать, но уж больно смахивает на обычный конструктор. Когда полей немного проще всех запихать в конструктор (или несколько перегруженных), объявив опциональные значения как @Nullable, и вообще не заморачиваться ни с какими билдерами. Я вообще все поля делаю public final, чтобы еще и геттеры убрать. Вот в Immutables у билдеров есть очень полезная фича — это клонирование объекта с изменениями.
ovsale
09.02.2019 11:43Throwable
09.02.2019 13:09Все-равно необходим какой-то кодогенератор, иначе много бойлерплейта получается, особенно когда у вас сильно больше двух полей. Еще такая сильно неудобная вещь, как мутирование полей у nested-объектов:
user = user.setContact(user.getContact().setAddress(user.getContact().getAddress().setStreet("Lenina"));
когда хотелось бы что-то вроде:
user = set(User.contact.address.street, "Lenina");
ovsale
09.02.2019 13:38насчет nested immutable объектов в качестве полей согласен. без геттеров покрасивее но всеже
user = user.setContact(user.contact.setAddress(user.contact.address.setStreet("Lenina")))
с другой стороны когда нужно изменить у юзера только улицу — не представляю. изменится весь контакт или как минимум весь адрес.
у меня в большом проекте есть несколько центральных классов для представления базы данных в памяти. и мне принципиально важна immutability. с наследованием. с большим количеством полей. но без большой nested immutable глубины. все получилось очень органично. сами классы большие, добавлять поля сложновато да — но использовать их очень удобно.
busuzima
07.02.2019 13:59Я могу ошибаться, но в случае с наследованием от класса User почему бы просто не добавить необходимое поле в сам класс User и непосредственно в билдер, а затем вызывать сеттер только в случае необходимости?
bsideup Автор
07.02.2019 14:00Тогда это уже не называется "наследование"
busuzima
07.02.2019 14:40Все верно, я и не настаивал на наследовании, я просто предложил проблему наследования решить таким способом.
poxvuibr
07.02.2019 15:33-2Тогда это уже не называется "наследование"
Я, наверное, покажусь назойливым, но я ещё раз спрошу, какая проблема тут решается наследованием?
justboris
07.02.2019 23:20Увидел название статьи, заинтересовался, чем каменщикам и штукатурам не угодила Java, зашел почитать, а тут про паттерны оказалось.
j_wayne
> Некоторым он не нравится (кстати, напишите тогда в комментариях, почему).
Если родитель реализует Serializable, то будет предупреждение о том, что нет serialVersionUID.
Нужен или @Supress, или сгенерированный/дефалтный serialVersionUID.
Это загромождает и без того многословный код.
UPD: ИМХО 90% кейсов, где нужен Builder, покрыли бы именованные аргументы конструкторов с дефалтными значениями (может быть когда-нибудь добавят?). По крайней мере, как у вас в примере, когда это просто заменяет сеттеры.
bsideup Автор
Валидно, но Я честно говоря не видел Serializable билдеров о_О
А с именованными аргументами есть другая проблема — наследование таких конструкторов.
Python, например, динамический и умеет *kvargs, но в Java такое не прокатит я думаю.
j_wayne
Насчет билдеров вы правы.
Я имел в виду про использование двойных кавычек вообще.
В частности HashMap serializable, а для ее заполнения, в принципе, трюк удобный.
bsideup Автор
А, да, тут абсолютно согласен.
Я там в конце статьи попытался написать "не стоит использовать везде", но такое стоит повторять — этот трюк не везде применим и может сделать больно в неправильных местах (capturing, serialization, classloading, вот это вот всё), особенно в production коде.
А вот для Testcontainers пока что выглядит очень даже норм :) Но всё ещё думаем...
sshikov
>и может сделать больно в неправильных местах
и делает. Правда, надо сказать, что я натыкался на такое раза два, и настолько редко, что пожалуй не смогу вспомнить подробности.
igor_suhorukov
В анонимным внутренним классе-наследнике HashMap как раз спотыкался на сериализации. После этого не особо жалую конструкцию {{}}. С загрузкой классов, беда миновала, даже при активном использовании RMI over SSH.
bsideup есть вопрос по testcontainers. Коллеги сталкивались с багом testcontainers+localstack, не только мы одни используем AWS. Какие у них есть варианты по интеграции localstack?
bsideup Автор
LocalStackContainer
наследуется же отGenericContainer
, можно любую env variaible указать с помощью.withEnv("FOO", "BAR")
.zeldigas
Можно но раз localstack берет на себя вопросы портов, хоста и прочего, имхо это должно делаться в модуле который поддержку localstack в testcontainers и добавляет.
Решение с ENV лишь чуть-чуть элегантнее чем переписывание очереди в полученном queueUrl
bsideup Автор
мы это конечно же добавим в будущем, просто хотел поделиться быстрым workaround-ом :)
lany
Как раз для HashMap (если религия не позволяет Java 9 или Guava) совершенно несложно написать в своём проекте свой нормальный билдер и использовать его.
skapral
Это решаемо уже сегодня — вторичными конструкторами, делегирующими в this, не? С точки зрения класса это может и громозко, но с точки зрения клиента — никаких отличий от дефолтных значений.
bsideup Автор
Не совсем решаемо — аргументы конструктора не именованны и читать вызовы конструкторов с 10 параметрами — то ещё развлечение
skapral
А, ну да. Именованности нет. Частично проблему чтения IDE решают подсвечиванием.
tbl
когда на code-review смотришь такое, то подсвечивания нет
lany
Так это проблема системы code-review, требуйте фича-реквест! Хорошая система могла бы резолвить вызов конструктора и оставлять подсказки.
tbl
«Без статической типизации я бы так и не узнал, зачем мне IDE вместо редактора».
Dim0v
Кроме непосредственно чтения вызовов с 10 параметрами есть еще проблемы, которые IDE не решит даже частично.
Например, есть конструктор
C(int a, int b, int c, D d, E e)
Будь у нас именованные параметры и дефолтные значения — мы могли бы его вызывать, передавая только недефолтные параметры (собственно, получив поведение билдеров, только удобнее и без билдеров).
Без них же проблемы следующие:
skapral
Ок. Резонно.