Зачем нам нужен класс Builder при реализации шаблона Builder?

31

Я видел много реализаций шаблона Builder (в основном на Java). Все они имеют класс сущности (скажем, Personкласс) и класс строителя PersonBuilder. Конструктор "складывает" различные поля и возвращает a new Personс переданными аргументами. Зачем нам явно нужен класс построителя вместо того, чтобы помещать все методы построителя в сам Personкласс?

Например:

class Person {

  private String name;
  private Integer age;

  public Person() {
  }

  Person withName(String name) {
    this.name = name;
    return this;
  }

  Person withAge(int age) {
    this.age = age;
    return this;
  }
}

Я могу просто сказать Person john = new Person().withName("John");

Зачем нужен PersonBuilderкласс?

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

Боян Кушлев
источник
4
Примечание: нормальное название для этого паттерна chainable setters: D
Mooing Duck
4
Принцип единой ответственности. Если вы поместите логику в класс Person, тогда у него будет несколько заданий
reggaeguitar
1
Ваша выгода может быть получена в любом случае: я могу withNameвернуть копию Человека только с измененным полем имени. Другими словами, Person john = new Person().withName("John");может работать, даже если Personявляется неизменным (и это распространенная модель в функциональном программировании).
Брайан Маккатон,
9
@MooingDuck: еще один распространенный термин для этого - свободный интерфейс .
Mac
2
@Mac Я думаю, что свободный интерфейс немного более общий - вы избегаете использования voidметодов. Так, например, если Personесть метод, который печатает их имя, вы все равно можете связать его с помощью интерфейса Fluent person.setName("Alice").sayName().setName("Bob").sayName(). Кстати, я аннотирую их в JavaDoc именно вашим предложением @return Fluent interface- оно достаточно общее и понятное, когда оно применяется к любому методу, который делает return thisв конце своего выполнения, и это довольно ясно. Таким образом, Builder также будет свободно работать с интерфейсом.
ВЛАЗ

Ответы:

27

Это значит, что вы можете быть неизменными И моделировать именованные параметры одновременно.

Person p = personBuilder
    .name("Arthur Dent")
    .age(42)
    .build()
;

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

Похоже, вы говорите о Josh Blochs Builder Pattern . Это не должно быть перепутано с Образцом Банды Четырех Строителей . Это разные звери. Они оба решают строительные проблемы, но по-разному.

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

Неизменяемый пример, не имеет имен для параметров

Person p = new Person("Arthur Dent", 42);

Здесь вы строите все с помощью одного простого конструктора. Это позволит вам оставаться неизменным, но вы потеряете симуляцию именованных параметров. Это становится трудно читать со многими параметрами. Компьютерам все равно, но это тяжело для людей.

Пример имитированного именованного параметра с традиционными установщиками. Не неизменен.

Person p = new Person();
p.name("Arthur Dent");
p.age(42);

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

Итак, добавив класс, вы можете сделать и то, и другое.

Проверка может быть выполнена в том build()случае, если вам достаточно ошибки времени выполнения для отсутствующего поля возраста. Вы можете обновить его и применить к нему age()вызов ошибки компилятора. Только не с шаблоном строителя Джоша Блоха.

Для этого вам нужен внутренний домен-специфический язык (iDSL).

Это позволяет вам требовать, чтобы они звонили age()и name()до звонка build(). Но вы не можете сделать это, просто возвращаясь thisкаждый раз. Каждая вещь, которая возвращается, возвращает другую вещь, которая заставляет вас называть следующую вещь.

Использование может выглядеть так:

Person p = personBuilder
    .name("Arthur Dent")
    .age(42)
    .build()
;

Но это:

Person p = personBuilder
    .age(42)
    .build()
;

вызывает ошибку компилятора, потому что age()допустимо вызывать только тип, возвращенный name().

Эти iDSL являются чрезвычайно мощными (например, JOOQ или Java8 Streams ) и очень удобны в использовании, особенно если вы используете IDE с дополнением кода, но их довольно сложно настроить. Я бы порекомендовал сохранить их для вещей, для которых будет написано немало исходного кода.

candied_orange
источник
3
А потом кто-то спрашивает, почему вы должны предоставить имя до достижения возраста, и единственный ответ - «потому что комбинаторный взрыв». Я вполне уверен, что этого можно избежать в исходном коде, если у вас есть подходящие шаблоны, такие как C ++, или если вы используете другой тип для всего.
Дедупликатор
1
Негерметичных абстракция требует знаний нижележащего сложности , чтобы иметь возможность знать , как использовать его. Вот что я вижу здесь. Любой класс, не знающий, как создать себя, обязательно связан с Builder, который имеет дополнительные зависимости (такова цель и характер концепции Builder). Однажды (не в полосовом лагере) простая необходимость создания экземпляра объекта потребовала 3 месяца, чтобы отменить только такой кластерный взлом. Я не шучу.
радаробоб
Одно из преимуществ классов, которые не знают ни одного из своих правил построения, состоит в том, что когда эти правила изменяются, им все равно. Я могу просто добавить свой AnonymousPersonBuilderсобственный набор правил.
candied_orange
Класс / система дизайна редко показывают глубокое применение «максимизировать сцепление и минимизировать сцепление». Легионы дизайна и времени выполнения являются расширяемыми, и недостатки могут быть исправлены только в том случае, если принципы и рекомендации ОО применяются с чувством фрактальности или «это черепахи, все время вниз».
радаробоб
1
@radarbob Класс, который создается, все еще знает, как создать сам себя. Его конструктор отвечает за обеспечение целостности объекта. Класс builder - это просто помощник для конструктора, потому что конструктор часто принимает большое количество параметров, что не создает очень приятного API.
Восстановить
57

Зачем использовать / предоставлять класс строителя:

  • Делать неизменные объекты - вы уже определили пользу. Полезно, если строительство занимает несколько шагов. Кстати, неизменность следует рассматривать в качестве важного инструмента в нашем стремлении писать поддерживаемые и не содержащие ошибок программы.
  • Если представление во время выполнения конечного (возможно, неизменяемого) объекта оптимизировано для чтения и / или использования пространства, но не для обновления. String и StringBuilder являются хорошими примерами здесь. Многократное объединение строк не очень эффективно, поэтому StringBuilder использует другое внутреннее представление, которое подходит для добавления, но не так хорошо для использования пространства и не так хорошо для чтения и использования, как обычный класс String.
  • Четко отделить построенные объекты от строящихся объектов. Этот подход требует четкого перехода от незавершенного строительства к построенному. Для потребителя нет способа спутать объект незавершенного конструирования с созданным объектом: система типов будет обеспечивать это. Это означает, что иногда мы можем использовать этот подход, чтобы как бы «попасть в пропасть успеха», и при создании абстракции для использования другими (или нами) (например, API или уровнем) это может быть очень хорошим вещь.
Эрик Эйдт
источник
4
По поводу последней точки пули. Таким образом, идея в том, что a PersonBuilderне имеет получателей, и единственный способ проверить текущие значения - это вызвать .Build()return Person. Таким образом .Build может проверить правильно построенный объект, правильно? Т.е. этот механизм предназначен для предотвращения использования «строящегося» объекта?
AaronLS
@AaronLS, да, конечно; комплексная проверка может быть введена в Buildоперацию, чтобы предотвратить плохие и / или недостаточно построенные объекты.
Эрик Эйдт
2
Вы также можете проверить свой объект в его конструкторе, предпочтение может быть персональным или зависеть от сложности. Что бы вы ни выбрали, суть в том, что когда у вас есть неизменный объект, вы знаете, что он правильно построен и не имеет неожиданной ценности. Неизменный объект с неправильным состоянием не должен возникать.
Вальфрат
2
@AaronLS у строителя могут быть геттеры; не в этом дело. В этом нет необходимости, но если у сборщика есть геттеры, вы должны знать, что вызов getAgeстроителя может вернуться, nullесли это свойство еще не указано. Напротив, Personкласс может применять инвариант, которым никогда не может быть возраст null, что невозможно при смешивании строителя и фактического объекта, как в new Person() .withName("John")примере с OP , который успешно создает Personбез возраста. Это относится даже к изменяемым классам, так как сеттеры могут применять инварианты, но не могут применять начальные значения.
Хольгер
1
@ Хольгер, ваши очки верны, но в основном поддерживают аргумент, что Строитель должен избегать получателей.
user949300
21

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

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

Вы можете поместить это все в свой класс Person, особенно если логика очень мала (например, просто убедитесь, что возраст неотрицателен), но по мере роста логики имеет смысл разделить ее.

дьякон
источник
7
В примере, приведенном в вопросе, приведено простое нарушение правила: возраст не указанjohn
Caleth
6
+1 И очень трудно сделать правила для двух полей одновременно без класса построителя (поля X + Y не могут быть больше 10).
Реджинальд Блю
7
@ReginaldBlue еще сложнее, если они связаны "x + y is even". Наше начальное условие с (0,0) в порядке, состояние (1,1) тоже в порядке, но нет последовательности обновлений отдельных переменных, которая бы поддерживала состояние как действительное и приводила нас от (0,0) к (1). , 1).
Майкл Андерсон
Я считаю, что это самое большое преимущество. Все остальные добрые.
Джакомо Альзетта
5

Немного другой взгляд на это от того, что я вижу в других ответах.

withFooПодход здесь является проблематичным , так как они ведут себя как сеттера , но определены таким образом , чтобы создать впечатление опоры класса неизменности. В классах Java, если метод изменяет свойство, обычно запускают метод с помощью 'set'. Я никогда не любил это как стандарт, но если ты сделаешь что-то еще, это удивит людей, и это не хорошо. Есть еще один способ, которым вы можете поддерживать неизменяемость с помощью базового API, который у вас есть. Например:

class Person {
  private final String name;
  private final Integer age;

  private Person(String name, String age) {
    this.name = name;
    this.age = age;
  }  

  public Person() {
    this.name = null;
    this.age = null;
  }

  Person withName(String name) {
    return new Person(name, this.age);
  }

  Person withAge(int age) {
    return new Person(this.name, age);
  }
}

Это не дает возможности предотвратить неправильную печать частично построенных объектов, но предотвращает изменения любых существующих объектов. Это, вероятно, глупо для такого рода вещей (как и JB Builder). Да, вы создадите больше объектов, но это не так дорого, как вы думаете.

В основном вы увидите такой подход, используемый с параллельными структурами данных, такими как CopyOnWriteArrayList . И это намекает на то, почему неизменность важна. Если вы хотите сделать ваш код безопасным для потоков, почти всегда следует учитывать неизменность. В Java каждому потоку разрешено хранить локальный кэш переменного состояния. Чтобы один поток мог видеть изменения, сделанные в других потоках, необходимо использовать синхронизированный блок или другую функцию параллелизма. Любой из них добавит некоторые накладные расходы в код. Но если ваши переменные являются окончательными, ничего не поделаешь. Значение всегда будет тем, к чему оно было инициализировано, и поэтому все потоки видят одно и то же, несмотря ни на что.

JimmyJames
источник
2

Другая причина, которая здесь явно не упоминалась, заключается в том, что build()метод может проверить, что все поля являются 'полями, содержащими допустимые значения (заданные напрямую или полученные из других значений других полей), что, вероятно, является наиболее вероятным режимом сбоя что иначе произошло бы.

Другое преимущество заключается в том, что ваш Personобъект будет иметь более простое время жизни и простой набор инвариантов. Вы знаете, что если у вас есть Person p, у вас есть p.nameи действительный p.age. Ни один из ваших методов не должен быть рассчитан на такие ситуации, как «ну, если задан возраст, но не имя, или что, если задано имя, но не возраст?» Это уменьшает общую сложность класса.

Александр - Восстановить Монику
источник
«[...] может проверить, что все поля установлены [...]» Смысл паттерна Builder в том, что вам не нужно устанавливать все поля самостоятельно, и вы можете вернуться к разумным значениям по умолчанию. Если вам все равно нужно установить все поля, возможно, вам не следует использовать этот шаблон!
Винсент Савард
@ VincentSavard Действительно, но, по моей оценке, это наиболее распространенное использование. Чтобы проверить, что установлен некоторый «базовый» набор значений, и сделайте некоторую причудливую обработку некоторых необязательных значений (установка значений по умолчанию, получение их значений из других значений и т. Д.).
Александр - Восстановить Монику
Вместо того чтобы сказать «убедитесь, что все поля установлены», я бы изменил это на «проверка того, что все поля содержат допустимые значения [иногда зависящие от значений других полей]» (т. Е. Поле может разрешать только определенные значения в зависимости от значения другого поля). Это может быть сделано в каждом установщике, но для кого-то легче настроить объект без исключения, пока объект не будет завершен и готов к сборке.
Ицих
Конструктор создаваемого класса в конечном итоге отвечает за достоверность его аргументов. Валидация в компоновщике в лучшем случае избыточна и может легко нарушаться в соответствии с требованиями конструктора.
Восстановить
@TKK Действительно. Но они подтверждают разные вещи. Во многих случаях, в которых я использовал Builder, задачей конструктора было создание входных данных, которые в конечном итоге передаются конструктору. Например, конструктор настроен с использованием либо a URI, a File, либо a FileInputStreamи использует все, что было предоставлено, чтобы получить a, FileInputStreamкоторый в конечном итоге переходит в вызов конструктора как arg
Alexander - Reinstate Monica
2

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

Эд Марти
источник
2

Шаблон Builder используется для поэтапного построения / создания объекта путем установки свойств, а когда все обязательные поля установлены, возвращают конечный объект с помощью метода сборки. Вновь созданный объект является неизменным. Главное, на что следует обратить внимание, это то, что объект возвращается только при вызове окончательного метода сборки. Это гарантирует, что все свойства установлены для объекта и, таким образом, объект не находится в несогласованном состоянии, когда он возвращается классом компоновщика.

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

Таким образом, используя класс построителя (т.е. некоторую внешнюю сущность, отличную от самого класса Person), мы гарантируем, что объект никогда не будет в противоречивом состоянии.

Шубхам Бондарде
источник
@ ДавидО, ты понял мою точку зрения. Основной акцент, который я пытаюсь здесь сделать, - это противоречивое состояние. И это одно из главных преимуществ использования шаблона Builder.
Шубхам Бондард
2

Повторно использовать объект строителя

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

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

yitzih
источник
1

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

Это работает, только если есть способ получить начальный (действительный / полезный) объект (например, из конструктора, который устанавливает все обязательные поля, или из метода фабрики, который устанавливает значения по умолчанию), а дополнительные методы компоновщика затем возвращают модифицированные объекты на основе на существующий. Эти методы конструктора должны гарантировать, что вы не получите недопустимые / противоречивые объекты в пути.

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

Я использовал это в тестовом коде для создания совпадений Hamcrest для одного из моих бизнес-объектов. Я не помню точный код, но он выглядел примерно так (упрощенно):

public class CustomerMatcher extends TypeSafeMatcher<Customer> {
    private final Matcher<? super String> nameMatcher;
    private final Matcher<? super LocalDate> birthdayMatcher;

    @Override
    protected boolean matchesSafely(Customer c) {
        return nameMatcher.matches(c.getName()) &&
               birthdayMatcher.matches(c.getBirthday());
    }

    private CustomerMatcher(Matcher<? super String> nameMatcher,
                            Matcher<? super LocalDate> birthdayMatcher) {
        this.nameMatcher = nameMatcher;
        this.birthdayMatcher = birthdayMatcher;
    }

    // builder methods from here on

    public static CustomerMatcher isCustomer() {
        // I could return a static instance here instead
        return new CustomerMatcher(Matchers.anything(), Matchers.anything());
    }

    public CustomerMatcher withBirthday(Matcher<? super LocalDate> birthdayMatcher) {
        return new CustomerMatcher(this.nameMatcher, birthdayMatcher);
    }

    public CustomerMatcher withName(Matcher<? super String> nameMatcher) {
        return new CustomerMatcher(nameMatcher, this.birthdayMatcher);
    }
}

Затем я бы использовал это в своих модульных тестах (с подходящим статическим импортом):

assertThat(result, is(customer().withName(startsWith("Paŭlo"))));
Пауло Эберманн
источник
1
Это правда - и я думаю, что это очень важный момент, но он не решает последнюю проблему - он не дает вам возможности проверить, что объект находится в согласованном состоянии при его создании. После создания вам понадобится некоторая хитрость, например, вызов валидатора перед любым из ваших бизнес-методов, чтобы убедиться, что вызывающая сторона установила все методы (что было бы ужасной практикой). Я думаю, что хорошо бы рассуждать об этом, чтобы понять, почему мы используем шаблон Builder так, как мы это делаем.
Билл К
@BillK true - объект с, по сути, неизменяемыми установщиками (которые каждый раз возвращают новый объект) означает, что каждый возвращаемый объект должен быть действительным. Если вы возвращаете недопустимые объекты (например, человек с, nameно нет age), то концепция не работает. Ну, на самом деле вы могли бы возвращать что-то вроде этого, PartiallyBuiltPersonпока оно недействительно, но это похоже на хак, чтобы замаскировать Строителя.
ВЛАЗ
@BillK это то, что я имел в виду под «если есть способ получить начальный (действительный / полезный) объект» - я предполагаю, что как исходный объект, так и объект, возвращаемый из каждой функции построителя, являются действительными / согласованными. Ваша задача как создателя такого класса состоит в том, чтобы предоставлять только те методы построения, которые вносят эти согласованные изменения.
Paŭlo Ebermann