Как стать автором
Обновить

Комментарии 156

> Некоторым он не нравится (кстати, напишите тогда в комментариях, почему).

Если родитель реализует Serializable, то будет предупреждение о том, что нет serialVersionUID.
Нужен или @Supress, или сгенерированный/дефалтный serialVersionUID.
Это загромождает и без того многословный код.

UPD: ИМХО 90% кейсов, где нужен Builder, покрыли бы именованные аргументы конструкторов с дефалтными значениями (может быть когда-нибудь добавят?). По крайней мере, как у вас в примере, когда это просто заменяет сеттеры.
Serializable

Валидно, но Я честно говоря не видел Serializable билдеров о_О


А с именованными аргументами есть другая проблема — наследование таких конструкторов.
Python, например, динамический и умеет *kvargs, но в Java такое не прокатит я думаю.

Насчет билдеров вы правы.
Я имел в виду про использование двойных кавычек вообще.
В частности HashMap serializable, а для ее заполнения, в принципе, трюк удобный.

А, да, тут абсолютно согласен.


Я там в конце статьи попытался написать "не стоит использовать везде", но такое стоит повторять — этот трюк не везде применим и может сделать больно в неправильных местах (capturing, serialization, classloading, вот это вот всё), особенно в production коде.


А вот для Testcontainers пока что выглядит очень даже норм :) Но всё ещё думаем...

>и может сделать больно в неправильных местах
и делает. Правда, надо сказать, что я натыкался на такое раза два, и настолько редко, что пожалуй не смогу вспомнить подробности.
В анонимным внутренним классе-наследнике HashMap как раз спотыкался на сериализации. После этого не особо жалую конструкцию {{}}. С загрузкой классов, беда миновала, даже при активном использовании RMI over SSH.

bsideup есть вопрос по testcontainers. Коллеги сталкивались с багом testcontainers+localstack, не только мы одни используем AWS. Какие у них есть варианты по интеграции localstack?

LocalStackContainer наследуется же от GenericContainer, можно любую env variaible указать с помощью .withEnv("FOO", "BAR").

Можно но раз localstack берет на себя вопросы портов, хоста и прочего, имхо это должно делаться в модуле который поддержку localstack в testcontainers и добавляет.
Решение с ENV лишь чуть-чуть элегантнее чем переписывание очереди в полученном queueUrl

мы это конечно же добавим в будущем, просто хотел поделиться быстрым workaround-ом :)

Спасибо за универсальный workaround!
Как раз для HashMap (если религия не позволяет Java 9 или Guava) совершенно несложно написать в своём проекте свой нормальный билдер и использовать его.
ИМХО 90% кейсов, где нужен Builder, покрыли бы именованные аргументы конструкторов с дефалтными значениями


Это решаемо уже сегодня — вторичными конструкторами, делегирующими в this, не? С точки зрения класса это может и громозко, но с точки зрения клиента — никаких отличий от дефолтных значений.

Не совсем решаемо — аргументы конструктора не именованны и читать вызовы конструкторов с 10 параметрами — то ещё развлечение

А, ну да. Именованности нет. Частично проблему чтения IDE решают подсвечиванием.
когда на code-review смотришь такое, то подсвечивания нет
Так это проблема системы code-review, требуйте фича-реквест! Хорошая система могла бы резолвить вызов конструктора и оставлять подсказки.
«Без статической типизации я бы так и не узнал, зачем мне IDE вместо редактора».

Кроме непосредственно чтения вызовов с 10 параметрами есть еще проблемы, которые IDE не решит даже частично.
Например, есть конструктор
C(int a, int b, int c, D d, E e)


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


  • Количество делегирующих конструкторов под все возможные наборы аргументов растет экспоненциально. В данном случае (всего 5 параметров. Очень немного) потребовалось бы 32 конструктора. Для 10 аргументов — уже 1024. "С точки зрения класса" это капец как громоздко. Без всяких "может" :). Да, не всегда нужна возможность задавать любое подмножество параметров, некоторые параметры обязательны и т.д., но тем не менее. Собственно, ни разу и не встречал, чтобы так делали. Если какие-то делегирующие конструкторы и есть, то максимум — убирающие по одному параметру с конца. А если нужно задать нестандартные первый и последний аргументы — будь добр напихать в месте вызова null-ов в середину списка.
  • Для некоторых наборов аргументов создать делегирующие конструкторы невозможно в принципе. Например, для примера выше не выйдет создать 3 конструктора, принимающих параметры, соответственно, (int a, int b), (int b, int c) и (int a, int c). Можно обойти фабричными методами, но по сути — такой же костыль, как и билдеры.
Ок. Резонно.
НЛО прилетело и опубликовало эту надпись здесь

А можно с примерами Java кода?

НЛО прилетело и опубликовало эту надпись здесь

там внизу в комментарии есть problem definition. Ваш "идеальный" код никто не станет использовать, потому что он громоздкий и неудобный (по крайней мере в той библиотеке что я описываю).


Поэтому я и предлагаю переубедить, с примерами кода, иначе ваши комментарии выглядят как "я прочитал книжку, вы всё делаете неправильно, а я — умный".

НЛО прилетело и опубликовало эту надпись здесь

Ух ты как Java изменилась при Собянине


Допустим, я прочитал Ваш код. Но он не отвечает на вопрос! Как сделать удобный API для всего этого, чтобы пользователям было удобно?


Вас сразу выдаёт то, что вы приводите примеры объявления классов, а не пример их использования.
Когда мы у себя проектируем API наших DSL, мы начинаем с "как пользователь, я хочу использовать это вот так", а потом ищем варианты как это реализовать, на грани с возможностями языка, с учетом наших пользователей. А уже потом думаем, чтобы это ещё было поддерживаемо и читаемо.

НЛО прилетело и опубликовало эту надпись здесь
оказывается не про корректную архитектуру приложения

Внезапно то как!


Ему не удобно

Мне удобно было бы вообще не иметь public API. Нет API — нет проблем. Клаааас.

НЛО прилетело и опубликовало эту надпись здесь

как пользователь, мне б это не понравилось, и вот почему:
1) API discoverability — если я хочу настроить порт, то мне надо знать наперёд в каком из билдеров этот метод для настройки порта указан
2) многословность — в вашем примере вы по сути дела устанавливаете 3 параметра, но при этом код нагружен .builder(), .build() и их друзьями
3) результат .build() должен содержать параметры всех "билдеров" (к сожалению в вашем примере это невозможно продемонстрировать), и, если это был билдер BarBuilder, то результат должен быть знать о свойствах Bar, а не только Foo

НЛО прилетело и опубликовало эту надпись здесь

Самое забавное — в моём личном опыте сложней всего для поддержки проекты доставались именно от товарищей, которые пропагандируют "правильные" способы, пишут на Java как на Scala, а ночью под подушкшой читают куски Haskell кода.
Так что думаю тут знаний "мудрейших" не достаточно, и надо уметь их применять там, где оно уместно, и если что-то можно сделать проще — то почему бы и нет?

НЛО прилетело и опубликовало эту надпись здесь
Лисковски — это Barbara Liskov? Или какой-то новый персонаж?

Я готов все свои карма поинты обменять на плюсы к Вашему комментарию, это просто прекрасно!

НЛО прилетело и опубликовало эту надпись здесь

А что не так с уровнем дискуссии? Принцип Лисков код из статьи никак не нарушает. Если он нарушает какой-то принцип какого-то (какой-то) Лисковски, то было бы неплохо пояснить, кто это такой (такая) и что это за принцип, а то я вот тоже ничего о таком персонаже не слышал.

bsideup, справедливости ради, пункт №2 решается, если методы withStrategy, withConfig и прочие принимают аргументы типа Builder.
Да, пущай они сами вызовут .build(), зато в клиентском коде этого мусора не будет. Или я чего-то упускаю?

В простых случаях может быть даже такое: withStrategy(KafkaContainerStrategy::builder);

ну, не решается, скорей просто убирает часть проблемы. Но проблема всё же остаётся, особенно когда на реальных примерах её погонять (прошли через это, был одним из вариантов API)

Правильное решение — перестать хаять наследование по поводу и без, и просто перестать его использовать для создания типов, которые не являются subtypeами по LSP от родителя.
НЛО прилетело и опубликовало эту надпись здесь
LSP вообще просто нарушить, даже если тупо интерфейс имплементировать. И ничто не проверит за девелопера корректность LSP — ни компилятор, ни статический анализатор, ни даже система типов. Единственный реальный недостаток наследования по отношению к остальным инструментам sybtypingа только в том, что с ним нарушить LSP проще всего.
НЛО прилетело и опубликовало эту надпись здесь
Ну например взять какой нибудь интерфейс SortedSet, сымплементировать от него класс, и допустить в имплементации какой нибудь косяк с сортировкой. В итоге, все программы, принимающие на вход SortedSet и справедливо делающие предположение о том что итерация по коллекции будет идти по определенному порядку, перестанут быть корректными после подстановки класса с косяком. Прямое нарушение LSP. Но языкам пофиг — пока синтаксически все верно, они все сбилдят и запустят.
Суть кстати — это устаревший вариант множественного числа слова «есть»
www.slovomania.ru/dnevnik/2007/01/25/sut
НЛО прилетело и опубликовало эту надпись здесь
ИМХО проблема на ровном месте.
Наследоваться вообще надо крайне осторожно, а уж наследоваться от иммутабельного класса выглядит откровенным извращением, поскольку в Java иммутабельность как свойство класса есть, а наследования иммутабельности нет. То есть заложена бомба под принцип L из набора SOLID.

Пример:


Есть проект — Testcontainers. В нём есть базовый класс GenericContainer.
Есть наследники типа KafkaContainer.
Есть даже промежуточные наследования: MySQLContainer -> JDBCContainer -> GenericContainer.


Должна быть возможность их конфигурировать, чтобы удобно, красиво и вот это вот всё.
Как бы вы решили эту проблему на ровном месте? :)

А что делает этот GenericContainer и что в нем наследовать?

Интерфейс с дефолтной реализацией (если она нужна) — Container.
Любые промежуточные интерфейсы (миксины).

Реализации интерфейсов, которые никак друг друга не наследуют.
У нас в проекте такие же билдеры с наследованием как в статье. И они валидны, потому что родитель абстрактный. А в одном месте наслдеование только в билдерах, стоят они один и тот же immutable объект но с разным наполнением.
Для себя проблему наследования решил выделением абстрактного билдера с дженерик аргументами. Выглядит «слегка» монстроуозно, но работает
Код
@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();
        }
    }
}

Это примерно то, что у нас сейчас (и в статье описано в секции про generic версию, только без 2х классов).
Такой подход имеет место быть (так например работает SuperBuilder в Lombok на сколько знаю), но, к сожалению, он очень тяжело даётся контрибьюторам в проект, особенно рекурсивные конструкции вида <SELF extends MyClass>.

Это, кстати, очень интересная тема. Одно дело — умные книжки про как можно и нельзя, а другое — как потом с таким кодом работать, особенно в OSS, где каждый контрибьютор важен.
Разработчики Testcontainers тоже не глупые и тоже разные книжки читали, но, как и во всём — лучшее враг хорошего :)

К сожалению в методе configure Вам пришлось отказаться от чейна, но Вы ведь так за него боролись?

Оно потому и "подойти иначе". Мы долго боролись за chaining, что забыли что есть другие способы, и что без него можно сделать вполне читаемый вариант :)

И как он вам?

в Spring Security норм, но им можно — у них не используется возвращаемый результат их DSL :)


Но даже если и адаптировать его под этот случай — DSL становится сложней читать из-за обилия .and()

Хотелось бы добавить что в lombok добавили аннотацию @SuperBuilder
которая делает все это. Но плагин для idea JetBrains пока не поддерживает но есть issues

Продублирую мой ответ из твиттера:
SuperBuilder работает только если код и сторонние библиотеки к этому коду используют Java и Lombok. В нашем случае это не всегда так.

Жду продолжения темы: "Дальнобольщики против оператора goto" и "Грузщики против синглтонов".

Дальнобольщики — это те, у кого боль от дальних прыжков?

А почему бы не сделать отдельный билдер для каждого юзера? Какую проблему мы тут решаем введением наследования для билдеров?

параметров может быть не 3, а 30.

Может быть и больше. Это будет, конечно, странно, но может. Однако вопрос, какую проблему мы решаем введением наследования для билдеров по прежнему актуален.

А почему бы не оставить User мутабельным, но через отдельный его метод получать иммутабельный интерфейс?


Что-то типа:


RussianUser user = new RussianUser
user.firstName = "Sergei";
user.patronymic ="Valeryevich";
user.lastName = "Egorov";

UserView userView =  RussianUser.view();
userView.lastName = "Egorov"; //error
Так в итоге так и вышло, с той только разницей что RussianUser называется RussianUser.Builder, а UserView называется RussianUser.
Не, в статье билдер описывает поля, сеттеры и передаёт это всё конктруктору, который проставляет это всё юзеру. В моём коде же нет никаких конструкторов и сеттеров, а UserView — не более чем публичный интерфейс.

В вашем коде объект User — изменяемый. А в коде из статьи есть гарантии, что он не изменится.

И как же он может вдруг измениться через иммутабельный интерфейс?

Через интерфейс не изменится, но вообще измениться может, гарантий нет.

А в реализации этого интерфейса конструктор, такой же как и у оригинального User.
Сам User является реализацией интерфейса UserView. Ну или их лучше назвать UserRaw и User, ибо интерфейс чаще используется.
Т.е. метод view будет возвращать this. Тогда иммутабельность будет не настоящая. Состояние можно будет изменить через оригинальный user, а это не то что изначально требовалось.
Ну да, а можно не изменять. Какие проблемы?
Изначально задача стояла так чтобы сделать чтобы было нельзя изменять.
В данном случае решение о том «можно или нельзя менять» принимается там же, где и «менять или не менять», то есть при создании объекта. Так что разницы — никакой.

Это вам разницы никакой, а jvm разницу найдёт. Не увидит final на полях и поймёт, что никакого решения о неизменяемости никто никогда не принимал.

И что она сможет сделать с этой информацией?

С этой — ничего. А вот, если бы у неё была информация о том, что объект неизменяем, то можно было бы применить какие-нибудь оптимизации.

Так какие оптимизации?

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

Как лихо вы утверждаете что возможно, а что нет, не зная сути оптимизаций.

Мне кажется я понял ваш вопрос. Вы сомневаетесь, что существуют оптимизации, которые можно сделать, когда на поле стоит final и нельзя, когда final не стоит и к нему есть сеттер?


Так как final поля никогда не меняются, можно точно знать, что если прочитать final поле из соседнего потока, то его значение будет точно таким же, каким было в момент создания объекта. Про не final поля такого сказать нельзя.


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


Опять же, прогресс не стоит на месте и те оптимизации, которых нет сегодня — появятся завтра. Поэтому, а ещё потому, что final нужен не только jvm, но и программисту, правило правой руки — ставь final везде, где можно. А если получится, то и где нельзя.

Ява разве даёт какие-либо гарантии касательно многопоточности, чтобы было что там оптимизировать?

Кешировать чтение из объекта она имеет полное право, ибо опять же ничего не гарантирует касательно видимости изменений из соседнего потока.

Ок, прогресс не стоит на месте, например компилятор начинает лучше понимать код и сам расставлять final, inline, pure, nogc и прочие атрибуты.
Ява разве даёт какие-либо гарантии касательно многопоточности, чтобы было что там оптимизировать?

Да, эти гарантии описаны в Java Memory Model.


Кешировать чтение из объекта она имеет полное право, ибо опять же ничего не гарантирует касательно видимости изменений из соседнего потока.

Гарантии видимости поля из соседнего потока дать можно, просто для final полей они обойдутся дешевле, посколько достаточно эти поля закешить, и не надо их каждый раз обновлять.


например компилятор начинает лучше понимать код и сам расставлять final, inline, pure, nogc и прочие атрибуты.

Если проставлять final, это не помешает оптимизациям, описанным вами. Но поможет тем оптимизациям, которые опираются на final. Реализовать эти последние оптимизации, кстати, проще, чем первые.

Да, эти гарантии описаны в Java Memory Model.

Там, насколько мне известно, описана семантика synchronized, который ставится программистом вручную на те поля, которые могут измениться в процессе жизни объекта. Такие поля не могут быть final по очевидным причинам. Там же, где synchronized не используется, final ничего и не даст. Аналогично и с volatile.

synchronized ставится не на поля, а на методы.


Там же, где synchronized не используется, final ничего и не даст. Аналогично и с volatile.

То, что вы называете synchronized — нам самом деле volatile. Объявить поле одновременно как final и volatile нельзя. Фактически для поля final даёт те же гарантии, что volatile, но дешевле.

Это замечательно, что вы знаете Яву лучше меня. В более других языках synchronized ставится на классы, а volatile нет вообще. Но давайте по существу. Зачем ставить volatile на поле, которое вы не меняете?
В более других языках synchronized ставится на классы, а volatile нет вообще.

Наверное, там synchronized обладает не той семантикой, которой обладает в джаве. И вообще конкарренси в более других языках — более другая штука. Какие языки вы имеете в виду, кстати? Это не по теме ветки, просто любопытно.


Но давайте по существу. Зачем ставить volatile на поле, которое вы не меняете?

Я видимо, не совсем ясно выразился. Нельзя поставить volatile на поле, которое не меняется, поэтому вопрос "зачем" отпадает сам по себе. volatile нельзя поставить на неизменяемое поле, потому что это бессмысленно, так как неизменяемые поля при чтении уже и так ведут себя как будто volatile на них висит. Но единственный способ сделать поле неизменяемым — повесить на него final.


Если final на поле нет, гарантий, что поле не изменится тоже нет.

Хочу уточнить, что в D synchronized, как и в Java ставится на методы, а synchronized на классе просто добавляет synchronized на все методы. volatile нет, но ключевые слова, которые делают примерно то же самое есть.

Всё это конечно очень важные уточнения. Ну коли пошла такая пьянка, то лишь на все публичные методы.

О каких ключевых словах идёт речь? В D для этого используется прямое указание барьеров памяти через подключаемую библиотеку.
О каких ключевых словах идёт речь?

Прежде всего о shared. Ещё есть const и immutale, я подозреваю, что они дают эффект похожий на final в джаве.

volatile-то тут при чём?

shared, const и immutable — не более чем атрибуты, используемые для проверки типов.
volatile-то тут при чём?

volatile делает так, чтобы изменения, которые произошли с полем в одном потоке были видны в другом. shared делает так, чтобы изменения, сделанные с переменной в одном потоке, впринципе могли быть видны в другом. В общем, похоже на volatile. Правда, в отличии от volatile, если shared нет, то изменения в другом потоке точно не будут видны.


shared, const и immutable — не более чем атрибуты, используемые для проверки типов.

Нет, помимо этого они ещё указывают где хранить переменную. Делать локальную копию для каждого потока или во всех потоках использовать одну и ту же переменную.

Помимо этого они ещё указывают где хранить переменную.

Только shared и только для глобальных переменных.

Ну да, только shared, но вот насчёт только для глобальных переменных я не совсем понимаю, что вы имеете в виду. Посмотрел на сайт по dlang, там в первом же попавшемся примере shared стоит на локальной переменной. Вот тут https://tour.dlang.org/tour/en/multithreading/synchronization-sharing

Как я уже говорил, это не более чем атрибут для тайпчекинга, разрешающий доступ из другого потока. На размещение в памяти он никак не влияет. И с помощью кастования его можно ставить и снимать. dlang.org/spec/const3.html#implicit_qualifier_conversions
dlang.org/spec/expression.html#CastQual
При создании объекта разницы, может быть, и никакой нет. А вот при использовании разница есть.

Либо известно, что объект гарантированно никогда не изменится независимо от того что во внешнем коде нахимичили — либо такой гарантии нет и нужно делать защитную копию.
Ява разве умеет давать гарантию глубокой иммутабельности? Если нет, то защитную копию по любому делать придётся.
Такую гарантию умеет давать программист. Нужно всего лишь объявить все поля как final и выбрать для них иммутабельные типы данных.

И чем принципиально отличается обещание программиста "я этот объект после создания уже не меняю" от "я нигде не забыл проставить final и нигде не использую мутабельные типы данных"?

Давать обещания относительно уже написанного кода проще, чем относительно ненаписанного.
«Написанность» кода ортогональна обсуждаемому вопросу.
Так не принимается же.

RussianUser user = new RussianUser()ж
user.firstName = "Sergei";
user.lastName = "Egorov";

UserView userView =  RussianUser.view();
userView.getLastName(); // возвращает Egorov
user.lastName = "Ivanov";
userView.getLastName(); // перестало возвращть Egorov


Вы конечно можете сказать что не надо переприсваивать, но хотелось бы чтобы компилятор проверял что никто не переприсвоит.

Ссылка на user ему уже передана в объекте userView. Это ведь один и тот же объект. Осталось только скастовать его к User и можно делать всё, что захочется.

Ну если у вас параноя, то можно и в прокси спрятать.

Можно. Но по сложности это примерно то же самое, что использование билдера. Но билдер в нагрузку ещё выдаёт неизменяемый объект.

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

Прокси же в конструкторе имеет одно единственное поле и портянку делегирующих методов, которые и кодогенератор легко сделает.

Я же говорю, всё как в билдере один в один. Только билдер целиком делается генератором, который уже вот сейчас есть в среде разработки в поставке по умолчанию.

НЛО прилетело и опубликовало эту надпись здесь
Разумеется иммутабельно в нём должно быть только то, что не должно меняться.
НЛО прилетело и опубликовало эту надпись здесь
Тем не менее членам класса можно устанавливать разные атрибуты, которые могут быть и объектами.
Мне интересно, как другие люди подходят к этой проблеме и что вы думаете о компромиссах разных подходов!

Целый день думал, как еще можно решить пролему паттерна 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;
        }
...
}


Что думаете???

Думаю что методы builder() и build() тут лишние.

Думали про такой вариант. Неплохой, но отпал т.к.
1) ухудшается API discoverabilitiy — надо знать какой из apply дёрнуть чтобы настроить firstName, вместо .builder().firstName()
2) если наследование глубже 1 класса, то вообще страшно выходит
3) лямбду нельзя на инстанс "забиндить"

Подход с интерфейсным программированием не рассматривали?

Идея — 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);
    }
}

Рассматривали.


С таким очень сложно работать, нет нормального способа хранить состояние (например, коллекции открытых портов, или переменных окружения), ну и не очевидный способ создания через прокси и вот это вот всё (в PR кстати чуть по-другому это решили)

И всё это лишь бы не переходить на Котлин…
sealed class User {
    abstract val firstName: String
    abstract val lastName: String
    
    data class RussianUser(override val firstName: String, override val lastName: String, val patronymic: String): User()

    object SingletonUser: User() {
        override val firstName = "Name"
        override val lastName = "Last name"
    }
}

fun main() {
   val user = User.RussianUser(lastName = "Фамилия", firstName = "Имя", patronymic = "Отчество")
   println(user)
}
data class RussianUser(override val firstName: String, override val lastName: String, val patronymic: String): User()

Даже ваш великий Котлин не спасает от тонны ненужного синтаксиса здесь. Особенно когда таких полей десятки.

1) Какой тонны синтасиса?
2) А этих полей точно нужны десятки?

1) дублирование объявлений полей
2) Да.

Выделите все поля, которые должны быть во всех потомках, в отдельный дата-класс, и храните его. И это не тонна синтаксиса, а максимум килограмм. По сравнению с тем, на что я отвечал.
Выделите все поля, которые должны быть во всех потомках, в отдельный дата-класс

Это уже не "наследование".

И всё-таки, какая проблема решается с помощью наследования билдеров друг от друга?

Значительно упрощенный API благодаря этому.
Юзеру не надо знать о всех существующих билдерах, не надо знать в каком из них настаривается foo, а в каком — bar (типичная проблема композиции).

Судя по коду из статьи, для того, чтобы получить Builder для RussianUser, нужно явным образом написать new RussianUser.Builder(). И только в этом билдере в статье есть pantonymic и об этом пользователю API надо знать.


Если под Юзером вы имели в виду объект User, то точно такого же эффекта, как в статье можно было бы достигнуть, сгенерировав билдер для каждого объекта заново.

Надо знать только этот класс, и на нём будут все методы.


И только в этом билдере в статье есть pantonymic

Не совсем. Юзер просто использует $DesiredUserType.Builder и видит все поля, которые можно "настроить" с помощью билдера.
Т.е. пользователь не ищет "а где же настроить pantonymic", он имеет тип, и видит какие параметры этого типа можно установить.

Т.е. пользователь не ищет "а где же настроить pantonymic", он имеет тип, и видит какие параметры этого типа можно установить.

Да, но для того, чтобы дать пользователю такую возможность, наследование не нужно. Можно просто сгенерировать билдер для каждого объекта.

Если уж хочется странного то что мешает сделать так:

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();
Ваш пример есть же в статье?
Не совсем. Там есть версия у которой по мнению автора есть проблемы. Мой код решает эти проблемы.
я остановился на след подходе:
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");

плюсы:
— значения по умолчанию определяются в одном месте
— удобно менять любое количество полей. главное не забывать что создается новый объект
— не нужен билдер
минусы:
— при изменении одного поля происходит создание объекта с «копированием» всех полей

Такой подход реализуется аннотацией @Wither в Lombok-е, но, к сожалению, не подходит для унаследованных сущностей.

тут с наследованием проблем нет. и в общем синтаксически нет так и сложно. а пользы много. я широко пользуюсь подобной конструкцией
class MyTag extends Tag {}

MyTag tag = new MyTag().setId("yo"); // <-- Error!

Летали. Знаем.

вот так:
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);
	}

}

и никаких new
и никаких new

по-моему в вашем примере кол-во "new" удваивается при добавлении нового наследника ;)


А когда пропертей десятки, то такой код начинает пугать. И ещё, у него есть один важный недостаток — при добавлении метода в базовый класс, он не попадает в наследников пока его вручную везде не добавят.

это на любителя. есть классы которые используются настолько часто что можно потратить время на их написание. один раз написал зато потом 30 раз удобно пользоваться.
действительно при добавлении нового поля нужно N раз скопипастить строку с конструктором (она одинаковая во всех сеттерах):
return new ColoredTag(id, name, color);
MyTag tag = new MyTag().setId(«yo»); // < — Error!

это действительно ошибка ибо id имеет тип int.

как вы рассчитываете создать sub класс методом parent класса???

в java если вы хотите работать с immutable объектами — придется немного попотеть. так как есть только один способ создания таких объектов через конструктор устанавливающий все поля. и если их 10 — значит 10. мой способ позволяет вынести эти конструкторы внутрь класса и не видеть их в основном (сложном) коде.

а еще ваш Builder не умеет устанавливать отдельные поля.

Основной недостаток паттерна Builder — это то, что в отличие от конструктора, он синтаксически никак не учитывает обязательные поля, и позволяет вызвать build() до того, как все необходимые значения будут установлены. При этом даже в рантайме проверка может как делаться, так и не делаться. И для этого нет хорошего решения. Поэтому паттерн Builder полезен лишь когда у нас все поля опциональные.

Ну, на самом деле решение-то возможно, билдер вполне может возвращать разные объекты после каждого вызова. У тех из них, где еще нельзя вызывать build, его просто не будет. Другое дело, что построить такую конструкцию достаточно сложно.
в моем подходе это можно сделать так:
	public static Tag create(int id) {
		return new Tag(id, "");
	}

id становится обязательно устанавливаемым

По-идее так и нужно делать, но уж больно смахивает на обычный конструктор. Когда полей немного проще всех запихать в конструктор (или несколько перегруженных), объявив опциональные значения как @Nullable, и вообще не заморачиваться ни с какими билдерами. Я вообще все поля делаю public final, чтобы еще и геттеры убрать. Вот в Immutables у билдеров есть очень полезная фича — это клонирование объекта с изменениями.

Все-равно необходим какой-то кодогенератор, иначе много бойлерплейта получается, особенно когда у вас сильно больше двух полей. Еще такая сильно неудобная вещь, как мутирование полей у nested-объектов:


user = user.setContact(user.getContact().setAddress(user.getContact().getAddress().setStreet("Lenina"));

когда хотелось бы что-то вроде:


user = set(User.contact.address.street, "Lenina");
насчет nested immutable объектов в качестве полей согласен. без геттеров покрасивее но всеже
user = user.setContact(user.contact.setAddress(user.contact.address.setStreet("Lenina")))

с другой стороны когда нужно изменить у юзера только улицу — не представляю. изменится весь контакт или как минимум весь адрес.
у меня в большом проекте есть несколько центральных классов для представления базы данных в памяти. и мне принципиально важна immutability. с наследованием. с большим количеством полей. но без большой nested immutable глубины. все получилось очень органично. сами классы большие, добавлять поля сложновато да — но использовать их очень удобно.
Я могу ошибаться, но в случае с наследованием от класса User почему бы просто не добавить необходимое поле в сам класс User и непосредственно в билдер, а затем вызывать сеттер только в случае необходимости?

Тогда это уже не называется "наследование"

Все верно, я и не настаивал на наследовании, я просто предложил проблему наследования решить таким способом.
Тогда это уже не называется "наследование"

Я, наверное, покажусь назойливым, но я ещё раз спрошу, какая проблема тут решается наследованием?

Увидел название статьи, заинтересовался, чем каменщикам и штукатурам не угодила Java, зашел почитать, а тут про паттерны оказалось.
Зачем делать сложно, если можно сделать просто?

class Base {
    String foo;

    <T extends Base> T setFoo(String foo) {
        this.foo = foo;
        return (T) this;
    }
}


class Derived extends Base {
    static Derived d = new Derived().setFoo("Bar").setBar(10);
}

Ага, замечательный метод:


static Derived d = new Base().setFoo("Bar").setBar(10); // упс!
Зарегистрируйтесь на Хабре, чтобы оставить комментарий