О чем все это?


При реализации chained builder на Java все прекрасно, пока не понадобится добавить наследование. Сразу же возникают две проблемы — как сделать, чтобы методы родительского билдера возвращали объект дочернего билдера и как передавать дочерний билдер в функции, принимающие родительский. Предлагается реализация паттерна, которая позволяет решить обе проблемы. Исходники можно посмотреть здесь на гитхабе.

Upd. Реальная проблема


В приложении есть dto-объекты для отображения результата, которые строятся следующим образом:

1) Создается билдер нужного dto-объекта.
2) Билдер передается в различные классы по цепочке, каждый класс использует билдер для установки нужных ему полей.

В один прекрасный день решили ввести новую версию API, dto-объект был расширен с помощью наследования и тут оказалось, что его билдер не получается засунуть в существующую цепочку классов для достройки.

Постановка задачи


Тут должно быть 100500 слов о важности паттерна билдер, дабы не утомлять читателя этой лабудой, сразу перейдем к делу. Пусть есть 3 класса с понятными именами Gen1, Gen2 и Gen3. Они образуют линейную иерархию Gen3 > Gen2 > Gen1. Каждый их них содержит ровно один очень важный метод с именем setValX (где X цифра из имени класса). Мы хотим получить билдеры Builder1, Builder2, Builder3 каждый из которых содержит соответствующий метод valX, который реализуется только у одного класса (не хотим копипастить).

Так же должны работать цепочки:

Gen1 gen1 = builder1.val1("val1").build();
Gen2 gen2 = builder2.val1("val1").val2("val2").build();
Gen3 gen3 = builder3.val1("val1").val2("val2").val3("val3").build();

И возможность использовать дочерние билдеры вместо родительских:

Gen1 someFunction(Builder1 builder1) {
    return builder1.val1("val1111");
}
...
someFunction1(builder3.val2("val222").val3("val333"));

Что и как получилось


Билдер предполагается сделать по следующей схеме — создать объект в самом начале, потом заполнить его поля, а в функции build() вернуть клиенту. В этом случае нам необходим класс, который будет делать простую вещь — хранить ссылку на достраиваемый объект и хранить ссылку на тот билдер нужного типа, который будут возвращать все методы-установщики значений. Следующий класс решает проблему:

public class BuilderImpl<T, RetBuilder>  {
    protected T nested;
    RetBuilder returnBuilder;

    protected BuilderImpl(T child) {
        nested = child;
    }

    protected T getNested() {
        return nested;
    }

    protected void injectReturnBuilder(RetBuilder builder) {
        returnBuilder = builder;
    }

    protected RetBuilder self() {
        return returnBuilder;
    }

    public T build() {
        return nested;
    }
}

Конечно, от метода injectReturnBuilder лучше было бы избавиться, передавая нужные данные в конструктор, но увы, туда будет передаваться this дочернего билдера, который нельзя использовать до окончания родительского конструктора super(). Метод getNested() на любителя, можно обращаться к полю nested напрямую. Метод self() сделан, чтобы не путать поле со словом this.

Теперь задумаемся вот над какой проблемой. Если у нас есть некий генерик Builder1<> который реализуется все что нам нужно для класса Gen1 (с какими-то параметрами Gen1,Builder1), на нужно будет унаследовать от него генерик Builder2 для Gen2 (с какими-то параметрами Gen1,Builder1), а от того Builder3 для Gen3 то получится, что у Builder3 в предках две реализации исходного Builder1 с разными параметрами, что, увы прямо запрещено Java.

Но выход есть — надо разделить настройку полей объекта и создание объекта на разные классы.
Классы с именами InnerBuilderX отвечают за настройку полей и возврат объекта и допускают наследование. Классы с именами FinalBuilderX наследуются от соответствующих InnerBuilderX, добавляя создание исходного объекта и к дальнейшему наследованию не допускаются.

Отдельную трудность представляет написание InnerBuilderX с правильной комбинацией wildcard. Путем долгих проб и ошибок (читать спецификации не наш путь) был найдет приемлемый вариант. Но пока он был найден — были перепробованы комбинации, на который инспектор Idea помирал или ошибался, что несколько затормозило разработку. И так, вот код для InnerBuilder1 класса Gen1. Параметр T это тип хранящегося объекта, RetBuilder — тип билдера, который возращается из функции установки val1.

public static class InnerBuilder1<T extends Gen1, RetBuilder extends InnerBuilder1<? extends T, ?>> 
extends BuilderImpl<T, RetBuilder> {

        protected InnerBuilder1(T created) {
            super(created);
        }

        public RetBuilder val1(String val) {
            getNested().setVal1(val);
            return self();
        }
    }

Конечно, рекурсивная конструкция class InnerBuilder1<T extends Gen1, RetBuilder extends InnerBuilder1<? extends T, ?>> немного напрягает, но зато реально работает.

Ну а FinalBuilder довольно простой:

private static class FinalBuilder1 extends InnerBuilder1<Gen1, FinalBuilder1> {

        private FinalBuilder1() {
            super(new Gen1()); // сюда нельзя this
            injectReturnBuilder(this);
        }
    }

Осталось добавить статическую функцию по созданию билдера:

public static InnerBuilder1<? extends Gen1, ?> builder() {
        return new FinalBuilder1();
}

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

public static InnerBuilder2<? extends Gen2, ?> builder() {
        return new FinalBuilder2();
    }

    public static class InnerBuilder2<T extends Gen2, RetBuilder extends InnerBuilder2<? extends T,?>> extends InnerBuilder1<T, RetBuilder> {

        protected InnerBuilder2(T created) {
            super(created);
        }

        public RetBuilder val2(String val) {
            getNested().setVal2(val);
            return self();
        }
    }

    private static class FinalBuilder2 extends InnerBuilder2<Gen2, FinalBuilder2> {

        private FinalBuilder2() {
            super(new Gen2());
            injectReturnBuilder(this);
        }
    }

Можно попробовать скомпилировать тестовый код:


Gen2.builder().val1("111").val1("111").val1("111").val1("111").val2("222").build();

Получилось! А что там с полиморфизмом?


 //  принимает билдер передкового Gen1
 Gen1 buildGen1Final(Gen1.InnerBuilder1<? extends Gen1, ?> builder) {
        builder.val1("set value from Gen1 builder");
        return builder.build();
    }
...
// а получает билдер потомка Gen2
 buildGen1Final(
        Gen2.builder().val2("set value from Gen2 builder")
 );

Все тоже работает. Аналогично реализуется билдер для класс Gen3, за подробностями можно обратится в гитхаб
Поделиться с друзьями
-->

Комментарии (35)


  1. xdenser
    13.11.2016 19:26

    Слишком абстрактно и похоже на overengineering. Не помешал бы пример из реальной жизни. Зачем это нужно.


    1. blaze79
      13.11.2016 19:53

      добавил секцию про реальную задачу


  1. SimSonic
    13.11.2016 20:28

    Может быть слишком очевидный и с минусами вариант, сорри.

    class Parent<T extends Parent> {
    	public T fn1(String arg1) {
    		// TO DO SMTHNG W/ arg1 HERE ...
    		return (T)this;
    	}
    }
    class Descendant<T extends Descendant> extends Parent<Descendant> {
    	@Override
    	public T fn1(String arg1) {
    		return (T)super.fn1(arg1);
    	}
    	public T fn2(String arg2) {
    		// TO DO SMTHNG W/ arg2 HERE ...
    		return (T)this;
    	}
    }
    
    	// ... USING:
    	Descendant sc = new Descendant();
    	sc.fn1("1").fn2("2");
    


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


    1. blaze79
      13.11.2016 20:29

      в статье было требование не копипастить :)

      вот без этого паразитного кода

      public T fn1(String arg1) {
      		return (T)super.fn1(arg1);
      	}
      


      1. dougrinch
        13.11.2016 23:12
        +2

        Так там копипаста и не нужна если дженерики чуть аккуратнее написать.


        class B1<T extends B1<T>> {
            public T fn1(String v) {
                return (T) this;
            }
        }
        
        class B2<T extends B2<T>> extends B1<T> {
            public T fn2(String v) {
                return (T) this;
            }
        }
        
        class B3<T extends B3<T>> extends B2<T> {
            public T fn3(String v) {
                return (T) this;
            }
        }
        
        class FB1 extends B1<FB1> {}
        class FB2 extends B2<FB2> {}
        class FB3 extends B3<FB3> {}

        new FB3().fn1("1").fn2("2").fn3("3")


        1. blaze79
          14.11.2016 08:52

          ну собственно у вас код почти такой же, только там где у меня? у вас T ( у меня RetBuilder)
          такая же иерархия классов, только обратное преобразование типов есть, которого лучше избегать по возможности.

          я что-то такое делал, но не уверен что с вашими уайлкардами удастся привести FB3 к B1


  1. fogone
    13.11.2016 20:45
    +3

    Какой практический смысл в наследовании билдеров? Это все очень сильно переусложняет, а копипаста решает эту проблему просто и без особых проблем. Еще хотел заметить, что билдер должен на каждый вызов build отдавать новый объект, иначе страдает инкапсуляция.


    1. blaze79
      13.11.2016 21:23

      смысл в том, чтобы передавать дочерний билдер в функции, которые хотят родительский. скрестите билдер и chain of responsibility и там это потребуется.

      про создание на каждый builder — зависит от ситуации.


      1. semio
        13.11.2016 21:47

        смысл в том, чтобы передавать дочерний билдер в функции, которые хотят родительский


        Можно пример, зачем куда-то передавать сам билдер?
        И почему не спасло <? extends BaseBuilder> в этом месте?


        1. blaze79
          13.11.2016 21:56

          И почему не спасло <? extends BaseBuilder>
          — тогда не получится нормально использовать цепочу. функция установки поля вернет BaseBuilder и на этом все закончится

          ну вот упрощенное построение объекта через прогон билдера через цепочку ответственности:
          class AddressAttacher implements DtoBuilerChain {
            @override 
            void doChainStep(DtoBuilder builder) {
                bulder.fromAddress(someAddressFrom).toAddress(someAddressTo);
            }
          }
          
          DtoBuile builder =  Dto.builder();
          listOfDtoBuilerChain.forEach( chain -> chain.doChainStep(builder));
          DtoObject obj =  builder.build();
          


      1. fogone
        13.11.2016 22:19
        +1

        смысл в том, чтобы передавать дочерний билдер в функции, которые хотят родительский. скрестите билдер и chain of responsibility и там это потребуется.

        Не думаю, что это хорошая идея. Ведь dto это не объекты с логикой, один не может работать там где нужен другой как завещала нам госпожа Лисков. А если у билдера вызывать метод build(), то он, глядишь, создаст не тот объект, который мы ожидаем. В этой связи если использование наследования для dto можно признать допустимым для некоторых случаев, то создание параллельной иерархии билдеров, а потом еще и пытаться использовать эту иерархию в логике — благодатная почва для проблем и ошибок (помимо переусложнения и параллельной иерархии) — старайтесь этого избегать.
        про создание на каждый builder — зависит от ситуации

        Сложно придумать ситуацию, чтобы это не нарушало инкапсуляцию. Честно говоря, не вижу смысла оборачивать dto в еще один объект, если он по сути просто для него обертка с более красивыми методами.


        1. blaze79
          14.11.2016 08:54

          есть объект dto с 40 полями, добавляем новую версию api в которой требуется добавить 1 поле. Можно:
          1) копипастить
          2) наследоваться
          3) инкапсулироваться

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


          1. fogone
            14.11.2016 13:55
            +1

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

            Не очень понял, вы что, используете слово «инкапсулировать» как эвфемизм для кого-то другого слова? Потому что, судя по контексту, третий пункт обычно называется композицией. И я ничего про неё не говорил. Даже написал, что наследовать dto в целом не противозаконно. А когда я говорил про инкапсуляцию, то имел ввиду, что если после вызова метода build вызвать метод билдера, то он поменяет объект, которым кто-то уже возможно пользуется.


            1. blaze79
              14.11.2016 14:52

              ок

              «Честно говоря, не вижу смысла оборачивать dto в еще один объект» если прогонять через цепочку не билдер, а сам объект dto, то получится, что
              1) он не иммутабл
              2) члены цепочки обмениваются недостроенным объектом. что тоже не очень хорошо


              1. fogone
                14.11.2016 15:23

                он не иммутабл

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

                Что намного лучше, чем неявное изменение объекта третьим объектом.


        1. speakingfish
          17.11.2016 21:35

          Параллельная иерархия возникает всегда когда у вас появляются новые представления объекта. И это гораздо лучше чем GodObject, вмещающий в себя все представления, в результате чего при подключении, например, одного DTO появляется зависимость от кучи прикладных библиотек. Конечно, иногда удаются объединения, например, в вариациях mutable/immutable, но и тут не всё может быть гладко. Так что не бойтесь параллельных иерархий — лучше бойтесь GodObject :)


          1. fogone
            17.11.2016 23:24

            GodObject — это несоблюдение принципа single responsibility — когда у объекта слишком много ответственностей. Так вот у dto вообще никакой ответственности быть не должно, иначе это уже не dto. Его ответственностью в какой-то мере можно назвать хранение данных в своей структуре. Но какой интерфейс для своего создания он предоставит, это к ответственности отношения особо не имеет. Билдер это будет или свои методы, а может он сам себе билдер будет — это нюансы. Откуда же взяться куче прикладных библиотек? От билдера что ли? К тому же, я не говорил, что обязательно нужно всё впихнуть в один класс, я только сказал о том, что наследовать билдеры — плохая на мой взгляд идея.


            1. speakingfish
              18.11.2016 11:53

              Я говорю о том что параллельная иерархия возникает часто. Параллельная иерархия может возникать при разделении GodObject. Так что обычно параллельная иерархия не является антипаттерном, подлежащим ликвидации. Также я упомянул, что параллельная иерархия может возникать (хотя мы обычно стремимся избегать этого) при mutable/immutable вариациях объектов, и это как раз относится к обсуждаемой ситуации, так как builder обычно mutable, а объект — нет.


              1. blaze79
                18.11.2016 12:00

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

                если это просто про набор как-то связанных объектов, то вопрос почему «иерархия»


  1. naething
    13.11.2016 21:25
    +4

    В чем смысл билдера, если ваш класс и так содержит сеттеры для полей? Билдер имеет смысл для immutable-классов (т.е. когда все поля final) с большим количеством полей, чтобы не вызывать конструктор с тысячей параметров. Если есть сеттеры, почему бы не использовать сам объект вместо билдера?

    Но, по-моему, корень вашей проблемы в использовании наследования (которое, из моего скромного опыта, является верным решением менее чем в 1% случаев). Может быть, в вашем случае можно применить композицию вместо наследования?

    Касательно приведенного кода: по-моему, вместо хранения ссылки на returnBuilder можно просто использовать (RetBuilder) this, если добавить ораничение RetBuilder extends BuilderImpl и подавить предупреждение об unchecked cast.


    1. blaze79
      13.11.2016 22:00

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

      Но, по-моему, корень в
      ашей проблемы в использовании наследования (которое, из моего скромного опыта, является верным решением менее чем в 1% случаев). Может быть, в вашем случае можно применить композицию вместо наследования?


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

      «по-моему, вместо хранения ссылки на returnBuilder можно просто использовать ». это придется делать в каждом методе, который возвращает билдер — очень некрасиво


      1. naething
        14.11.2016 09:14

        если исходные объекты унаследованы

        Я как раз про них и говорю: может быть, можно обойтись без наследования исходных объектов?

        это придется делать в каждом методе, который возвращает билдер — очень некрасиво

        Можно написать вспомогальный метод:
        @SuppressWarnings("unchecked")
        RetBuilder self() {
             return (RetBuilder) this;
        }
        
        RetBuilder foo(int x) {
             nested.setFoo(x);
             return self();
        }
        


    1. Ghedeon
      14.11.2016 00:13

      Знаю команды, которые используют билдеры в тестах, чтобы повысить читаемость кода. Это хорошо ложится на парадигму Fluent API, которую проповедует AssertJ, например.


      1. naething
        14.11.2016 09:22
        -1

        Мне кажется довольно сомнительной идея содержать код целого билдера только ради того, чтобы можно было писать

        builder.foo(1).bar(2)

        вместо
        object.setFoo(1);
        object.setBar(2);
        

        Да, с билдером не надо писать «object» каждый раз, но сложность кода остается точно такой же. Просто неважная синтаксическая делать.

        (Совсем другое дело — использование неизменяемых (immutable) классов. Здесь билдер часто просто необходим, если в классе больше 3-5 полей)


  1. DigitalSmile
    14.11.2016 12:33

    Я Вам очень советую не заниматься велосипедостроением, а освоить отличную библиотеку, заточенную под это.


  1. Jedi_Knight
    14.11.2016 14:42

    По-хорошему такие конструкции должны быть частью языка. В Groovy/Scala/Kotlin этой хрени уже не надо :)


    1. Saffron
      14.11.2016 23:59

      В scala «эта хрень» всё ещё не поддерживается на уровне языка и реализуется библиотекой. Например, optics от julien-truffaut/Monocle. Но вот языковые свойства скалы, так нелюбимые неосиляторами имплиситы, позволяют реализовывать такую библиотеку действительно лаконично и изящно.


      1. senia
        15.11.2016 00:12
        +1

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


        1. blaze79
          15.11.2016 08:31

          это, очевидно, неверное утверждение.


          1. senia
            15.11.2016 09:33

            Аргументируйте.


            1. Saffron
              15.11.2016 10:49

              Ну вот например, как именованные аргументы помогут в случае наследования?


              1. senia
                15.11.2016 11:05

                Я не знаю что вы подразумеваете под «поможет». Приведите пример с билдером — я вам продемонстрирую как то же самое реализовать лаконичнее с именованными параметрами.


            1. blaze79
              15.11.2016 11:14

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


              1. senia
                15.11.2016 11:22

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


    1. UbuRus
      15.11.2016 03:53

      Все верно Kotlin с функцией apply очень хорошо подходит для создания объектов, а на Java «правильного полиморфмного билдера» не может быть, все что видел — убоги.