为什么在实现Builder模式时我们需要一个Builder类?


31

我已经看到了Builder模式的许多实现(主要是Java)。它们都有一个实体类(假设是一个Person类)和一个构建器类PersonBuilder。构建器“堆叠”各种字段,并返回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
单一责任原则。如果您将逻辑放在“人”类中,那么它有很多工作要做
reggaeguitar

1
您可以以任何一种方式获得好处:我可以withName返回“人”的副本,但只更改“名称”字段。换句话说,Person john = new Person().withName("John");即使Person是不可变的也可以工作(这是函数编程中的常见模式)。
布赖恩·麦卡顿

9
@MooingDuck:另一个通用术语是流畅的界面
Mac

2
@Mac我认为流畅的界面更通用-避免使用void方法。因此,例如,如果Person有一个打印其名称的方法,您仍然可以使用Fluent Interface链接它person.setName("Alice").sayName().setName("Bob").sayName()。顺便说一句,我确实用您的建议注释了JavaDoc中的那些注释@return Fluent interface-当它适用于return this在其执行结束时执行的任何方法时,它是通用且足够清晰的,并且非常清楚。因此,Builder也将做一个流畅的界面。
VLAZ

Answers:


27

这样一来,您可以保持不变并同时模拟命名参数

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

这样可以使您的手套不被打扰,直到状态设置好为止;一旦设置好状态,就不允许您更改它了,但是每个字段都被清楚地标记了。您不能只使用Java中的一个类就可以做到这一点。

似乎您在谈论Josh Blochs Builder模式。这不应与“ 四人帮”模式混淆。这些是不同的野兽。他们都解决了建筑问题,但是方式却大不相同。

当然,您可以在不使用其他类的情况下构造对象。但是然后您必须选择。您将失去用没有名称的语言模拟命名参数的能力(例如Java),或者失去了在对象生命周期中保持不变的能力。

不可变的示例,没有参数名称

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

在这里,您将使用一个简单的构造函数来构建所有内容。这将使您保持不变,但会失去对命名参数的模拟。使用许多参数很难理解。电脑不在乎,但对人类来说却很难。

使用传统设置器模拟命名参数示例。不是一成不变的。

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

在这里,您将使用setter建立所有内容,并模拟命名参数,但是您不再是不变的。每次使用setter都会更改对象状态。

因此,通过添加类而获得的是您可以同时进行这两种操作。

build()如果缺少年龄字段的运行时错误足以满足您的要求,则可以执行验证。您可以对其进行升级并强制执行age()编译器错误。只是不适合Josh Bloch构建器模式。

为此,您需要内部特定于域的语言(iDSL)。

这样一来,您就可以要求他们致电,age()并且name()在致电之前build()。但是您不能仅通过this每次返回来做到这一点。返回的每个事物都会返回一个不同的事物,迫使您调用下一个事物。

使用可能看起来像这样:

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

但是这个:

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

导致编译器错误,因为age()仅对所返回的类型有效name()

这些iDSLs是(非常强大JOOQJava8流为例),是非常不错的使用,特别是如果你使用的代码完成的IDE,但他们工作的公平位设置。我建议将它们保存起来,以免有很多源代码针对它们编写。


3
然后有人问您为什么必须在年龄之前提供名字,唯一的答案是“因为组合爆炸”。我敢肯定,如果有人拥有适当的模板(例如C ++),或者对所有内容继续使用其他类型,则可以避免在源代码中出现这种情况。
重复数据删除器

1
一个漏水的抽象需要底层的复杂性知识,能够知道如何使用它。这就是我在这里看到的。任何不知道如何构建自身的类都必须与具有进一步依赖性(例如,构建器概念的目的和性质)的构建器耦合。一次(不是在乐队训练营中),简单地实例化一个对象需要3个月的时间才能消除这种簇裂。我不骗你
Radarbob

不知道其构造规则的类的优点之一是,当这些规则更改时,它们将不在乎。我可以添加一个AnonymousPersonBuilder具有自己的规则集的规则。
candied_orange

类/系统设计很少显示“最大化内聚和最小化耦合”的深入应用。大量的设计和运行时是可扩展的,并且只有在应用面向对象的格言和准则时,它才具有可扩展性,而且这些缺陷是可以修复的,甚至“完全是乌龟”。
Radarbob

1
@radarbob正在构建的类仍然知道如何构建自身。它的构造函数负责确保对象的完整性。builder类仅是构造函数的帮助器,因为构造函数通常会使用大量参数,而这并不能构成一个非常好的API。
U U

57

为什么使用/提供一个构建器类:

  • 创建不可变的对象-您已经确定的好处。如果构造需要多个步骤,则很有用。FWIW,不变性应被视为我们寻求编写可维护且无错误程序的重要工具。
  • 如果最终对象(可能是不可变的)的运行时表示针对读取和/或空间使用进行了优化,但对于更新则没有进行优化。String和StringBuilder是很好的例子。重复连接字符串不是很有效,因此StringBuilder使用了一个不同的内部表示形式,该表示形式很适合追加-但在空间使用方面不那么出色,并且在读取和使用方面不如常规String类。
  • 清楚地将构造对象与正在构造的对象分开。这种方法需要从施工阶段到施工阶段的明确过渡。对于消费者而言,无法将底层构造的对象与构造的对象混淆:类型系统将强制执行此操作。这意味着有时候我们可以使用这种方法来“成功”,当抽象给他人(或我们自己)使用(例如API或层)时,这可能是一个很好的选择事情。

4
关于最后一点。因此,想法是a PersonBuilder没有吸气剂,并且检查当前值的唯一方法是调用.Build()返回a Person。这样,.build可以验证正确构造的对象,对吗?这是旨在防止使用“在建”对象的机制吗?
AaronLS '18 -10-22

@AaronLS,是的,当然可以;可以在Build操作中进行复杂的验证,以防止不良和/或构造欠佳的对象。
埃里克·艾德

2
您也可以在其构造函数中验证您的对象,首选项可以是个人的,也可以取决于复杂性。无论选择什么,主要的一点是,一旦有了不可变的对象,便知道它已正确构建且没有意外价值。状态不正确的不可变对象不应该发生。
Walfrat

2
@AaronLS建造者可以有吸气剂;那不是重点。不需要,但是如果构建器具有getter,则必须注意,当尚未指定此属性时,getAge可能会返回对构建器的调用null。相比之下,Person该类可能会强制执行年龄永远都不会变的不变量null,这在构建器和实际对象混合使用时是不可能的,就像OP的new Person() .withName("John")示例那样,它很高兴地创建了一个Person没有年龄的变量。这甚至适用于可变的类,因为设置器可能会强制执行不变式,但不能强制执行初始值。
Holger

1
@Holger您的观点是正确的,但主要支持Builder应该避免使用getter的观点。
user949300 '18 -10-23

21

原因之一是要确保所有传入的数据都遵循业务规则。

您的示例没有考虑到这一点,但是假设某人传入了空字符串或包含特殊字符的字符串。您可能需要基于某种逻辑来确保其名称实际上是有效名称(这实际上是一项非常困难的任务)。

您可以将所有内容都放在Person类中,特别是在逻辑很小的情况下(例如,确保年龄不为负数),但是随着逻辑的增长,将其分开是有意义的。


7
问题中的示例提供了一个简单的违反规则的方法:没有给出年龄john
Caleth

6
+1如果没有构建器类,很难同时为两个字段制定规则(字段X + Y不能大于10)。
雷金纳德·蓝

7
@ReginaldBlue,如果它们被“ x + y是偶数”约束,则更加困难。我们的初始条件为(0,0)可以,状态(1,1)也可以,但是没有单变量更新序列既可以保持状态有效又可以使我们从(0,0)变为(1 ,1)。
迈克尔·安德森

我相信这是最大的优势。所有其他人都很有福。
Giacomo Alzetta

5

这与我在其他答案中看到的有所不同。

withFoo这里的方法是有问题的,因为它们的行为类似于setter,但是以某种方式定义,使得类似乎支持不可变性。在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中,允许每个线程保留变量状态的本地缓存。为了使一个线程看到在其他线程中所做的更改,需要使用同步块或其他并发功能。这些中的任何一个都会给代码增加一些开销。但是,如果您的变量是最终变量,则无需执行任何操作。该值将始终是初始化时的值,因此无论什么情况,所有线程都会看到相同的内容。


2

此处尚未明确提及的另一个原因是,该build()方法可以验证所有字段是否为“包含有效值的字段(直接设置或从其他字段的其他值派生),这可能是最有可能的故障模式否则会发生。

另一个好处是,您的Person对象最终将具有更简单的生存期和一组简单的不变式。您知道,一旦拥有Person p,便拥有p.namep.age。您的方法都不必设计为处理诸如“如果设置了年龄但未设置名称,或者设置了名称但未设置年龄,该怎么办?”之类的情况。这降低了类的整体复杂性。


“ [...可以验证是否设置了所有字段。” Builder模式的要点是,您不必自己设置所有字段,并且可以使用明智的默认设置。如果仍然需要设置所有字段,则也许不应该使用该模式!
Vincent Savard '18

@VincentSavard确实,但是据我估计,这是最常见的用法。为了验证是否设置了一些“核心”值,并对一些可选值进行了花哨的处理(设置默认值,从其他值派生其值,等等)。
亚历山大–恢复莫妮卡

与其说“验证是否已设置所有字段”,不如说是“验证所有字段是否包含有效值(有时取决于其他字段的值)”(即,一个字段可能仅允许某些值取决于值)另一个字段)。可以在每个设置器中完成此操作,但对于某人来说,设置对象更容易,而不会引发异常,直到对象完成并准备好构建为止。
yitzih

所构建类的构造函数最终负责其参数的有效性。构建器中的验证充其量是多余的,并且很容易与构造器的要求不同步。
No U

@TKK确实。但是他们验证了不同的事情。在我使用过Builder的许多情况下,Builder的工作是构造最终提供给构造函数的输入。例如,构建器配置有或者是URI,一个File或一个FileInputStream,不管是提供用来获得FileInputStream最终进入构造函数调用作为ARG
恢复莫妮卡-亚历山大

2

还可以将构建器定义为返回接口或抽象类。您可以使用构建器定义对象,然后构建器可以根据设置的属性或设置的属性来确定要返回的具体子类。


2

构建器模式用于通过设置属性来逐步构建/创建对象并且在设置了所有必填字段后,使用构建方法返回最终对象。新创建的对象是不可变的。这里要注意的要点是,仅当调用最终的build方法时才返回该对象。这样可以确保将所有属性都设置为对象,从而在构建器类返回该对象时,该对象不会处于不一致的状态

如果我们不使用构建器类,而是直接将所有构建器类方法放到Person类本身,那么我们必须首先创建对象,然后在创建的对象上调用setter方法,这将导致创建之间对象的状态不一致对象并设置属性。

因此,通过使用构建器类(即,除Person类本身以外的某些外部实体),我们可以确保对象永远不会处于不一致状态。


@DawidO你明白我的意思。我要在这里重点强调的是不一致的状态。这是使用Builder模式的主要优势之一。
Shubham Bondarde '18 -10-24

2

重用构建器对象

正如其他人提到的那样,不变性和验证所有字段的业务逻辑以验证对象是创建单独的构建器对象的主要原因。

但是,可重用性是另一个好处。如果要实例化许多非常相似的对象,则可以对构建器对象进行少量更改,然后继续实例化。无需重新创建构建器对象。这种重用允许构建器充当创建许多不可变对象的模板。这是一个小好处,但可能是有用的。


1

实际上,您可以在类本身上使用builder方法,并且仍然具有不变性。这只是意味着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模式是很好的。
Bill K

@BillK true-本质上具有不变的setter的对象(每次都会返回一个新对象)意味着每个返回的对象都是有效的。如果您要返回无效的对象(例如,具有nameno的人age),那么该概念将不起作用。好吧,实际上,您可能会返回诸如PartiallyBuiltPerson无效之类的信息,但看起来好像是在掩盖Builder。
VLAZ

@BillK这就是我的意思,“如果有一种获取初始(有效/有用)对象的方法” –我假设初始对象和从每个构建器函数返回的对象都是有效/一致的。作为此类的创建者,您的任务是仅提供进行那些一致更改的构建器方法。
圣保罗Ebermann
By using our site, you acknowledge that you have read and understand our Cookie Policy and Privacy Policy.
Licensed under cc by-sa 3.0 with attribution required.