如何改进建造者模式?

48

动机

最近我正在寻找一种初始化复杂对象的方式,而不需要在构造函数中传递大量参数。我尝试使用构建器模式,但我不喜欢这样做的事实是我无法在编译时检查是否真正设置了所有必需的值。

传统的构建器模式

当我使用构建器模式来创建我的Complex对象时,创建过程更加“类型安全”,因为更容易看到参数的用途:

new ComplexBuilder()
        .setFirst( "first" )
        .setSecond( "second" )
        .setThird( "third" )
        ...
        .build();

但现在我有一个问题,我很容易会错过一个重要的参数。我可以在build()方法中检查它,但那只能在运行时进行。在编译时,如果我错过了某些内容,没有任何警告可以提醒我。

增强型构建器模式

现在我的想法是创建一个构建器,如果我错过了必需的参数,它可以“提醒”我。我的第一次尝试看起来像这样:

public class Complex {
    private String m_first;
    private String m_second;
    private String m_third;

    private Complex() {}

    public static class ComplexBuilder {
        private Complex m_complex;

        public ComplexBuilder() {
            m_complex = new Complex();
        }

        public Builder2 setFirst( String first ) {
            m_complex.m_first = first;
            return new Builder2();
        }

        public class Builder2 {
            private Builder2() {}
            Builder3 setSecond( String second ) {
                m_complex.m_second = second;
                return new Builder3();
            }
        }

        public class Builder3 {
            private Builder3() {}
            Builder4 setThird( String third ) {
                m_complex.m_third = third;
                return new Builder4();
            }
        }

        public class Builder4 {
            private Builder4() {}
            Complex build() {
                return m_complex;
            }
        }
    }
}

正如您所见,生成器类的每个setter方法都返回一个不同的内部生成器类。每个内部生成器类仅提供一个setter方法,最后一个内部生成器类只提供build()方法。

现在对象的构建看起来像这样:

new ComplexBuilder()
    .setFirst( "first" )
    .setSecond( "second" )
    .setThird( "third" )
    .build();

...但是没有办法忘记必需的参数,编译器会拒绝它。

可选参数

如果我有可选参数,我将使用最后一个内部构建器类Builder4来设置它们,就像“传统”的构建器一样,返回自身。

问题

  • 这是否是一个众所周知的模式?它有特定的名称吗?
  • 您是否看到任何陷阱?
  • 您有任何改善实现的想法-以减少代码行数的意义上?

4
您的“传统构建器模式”更像是我所知道的“流畅接口”。当我听到“构建器模式”时,我想到的是“构建器设计模式”。把流畅接口称作构建器模式是常见的用法吗?还是我漏了什么信息? - Ewan Todd
3
@Ewan Joshua博客将此模式称为“Builder”(但不作为GoF构建器的替代)。请参见http://rwhansen.blogspot.com/2007/07/theres-builder-pattern-that-joshua.html。因此,我不知道是否适合使用相同的名称,也不知道它是否常见,但作为Effective Java的读者,我并不觉得它很笨重。 - Pascal Thivent
4
听起来你正在尝试获得类似于构造函数的编译时检查。很遗憾,Java不支持像Groovy等命名参数,这将解决这个问题,而无需额外的构建器类。 - Peter Lawrey
你可以使用泛型更好地实现这一点:http://michid.wordpress.com/2008/08/13/type-safe-builder-pattern-in-java/ - Chad
这是一个聪明的想法,但你失去了传统构建器模式所具有的很多灵活性。不仅在指定属性的顺序方面,还包括诸如引用正在进行的构建器变量和异步设置构建器属性等方面。由于每次设置属性时构建器的类型都会改变,因此您无法使用您的模式来实现这一点。实际上,您所做的只是创建了一个更冗长的构造函数,其中您必须查找正确的方法顺序,而不是查找每个参数的含义。您的IDE可以帮助您完成任何一项任务。 - brianmearns
10个回答

25

传统的生成器模式已经解决了这个问题:只需在构造函数中使用必要参数。当然,没有任何东西可以阻止调用者传递null,但你的方法也不会。

我认为你的方法存在一个大问题,那就是在具有多个必填参数的类中,可能会出现组合爆炸问题,或者强制用户按特定顺序设置参数,这很麻烦。

此外,这还需要做很多额外的工作。


9
海报特别提到尽量避免在构造函数中传递大量参数,这可能是因为这样做不能很好地进行编译时检查——因为如果您传递5个整数,编译器无法告诉您它们是否按正确顺序排列。 - Peter Recore
2
但是世上没有编译器能告诉我应该输入 foo(5, 6) 而不是 foo(6, 5)。虽然问题中提出的流畅接口使这种可能性(稍微)小了一些,但远远不能消除这种可能性。 - Jeff Sternal
1
昨天我刚读完《Effective Java》中的第二条(关于使用构建器而不是望远镜构造函数),书中还建议创建一个带有必填参数的构造函数,并在正在构建的类实例内进行必要的验证。 - Roman
1
你应该使用领域对象而不是5和6。 - Roger C S Wernersson
3
"强制用户按照特定顺序设置参数" - 非常好的观点。我认为“流畅式构建器”或“Bloch构建器”的优势之一是可以按任意顺序设置参数,而失去这个优势会让你退回到仅具有望远镜式构造函数的阶段。 - sparc_spread

18
public class Complex {
    private final String first;
    private final String second;
    private final String third;

    public static class False {}
    public static class True {}

    public static class Builder<Has1,Has2,Has3> {
        private String first;
        private String second;
        private String third;

        private Builder() {}

        public static Builder<False,False,False> create() {
            return new Builder<>();
        }

        public Builder<True,Has2,Has3> setFirst(String first) {
            this.first = first;
            return (Builder<True,Has2,Has3>)this;
        }

        public Builder<Has1,True,Has3> setSecond(String second) {
            this.second = second;
            return (Builder<Has1,True,Has3>)this;
        }

        public Builder<Has1,Has2,True> setThird(String third) {
            this.third = third;
            return (Builder<Has1,Has2,True>)this;
        }
    }

    public Complex(Builder<True,True,True> builder) {
        first = builder.first;
        second = builder.second;
        third = builder.third;
    }

    public static void test() {
        // Compile Error!
        Complex c1 = new Complex(Complex.Builder.create().setFirst("1").setSecond("2"));

        // Compile Error!
        Complex c2 = new Complex(Complex.Builder.create().setFirst("1").setThird("3"));

        // Works!, all params supplied.
        Complex c3 = new Complex(Complex.Builder.create().setFirst("1").setSecond("2").setThird("3"));
    }
}

2
这是一个很棒的想法!这样你就不必受限于设置参数的特定顺序,但仍然可以确保所有参数都被设置。感谢您这个鼓舞人心的想法! - tangens

17
不,这并不新鲜。实际上,您正在通过扩展标准构建器模式以支持分支来创建一种类似于DSL的东西,这是确保生成器不会将一组冲突设置传递给实际对象的绝佳方法。
就我个人而言,我认为这是构建器模式的一个很好的扩展,您可以使用它做各种有趣的事情,例如在工作中,我们针对某些数据完整性测试使用DSL构建器,允许我们执行像assertMachine().usesElectricity().and().makesGrindingNoises().whenTurnedOn();之类的操作。好吧,也许不是最好的例子,但我想你明白我的意思。

15

为什么不将“所需”参数放在构建器的构造函数中?

public class Complex
{
....
  public static class ComplexBuilder
  {
     // Required parameters
     private final int required;

     // Optional parameters
     private int optional = 0;

     public ComplexBuilder( int required )
     {
        this.required = required;
     } 

     public Builder setOptional(int optional)
     {
        this.optional = optional;
     }
  }
...
}

这个模式在Effective Java中有详细的阐述。


10
因为显然它们都是必需的且数量众多。但如果是这种情况,我会想知道是否有其他方面需要改进的空间。 - Carlos

7

不要使用多个类,只需使用一个类和多个接口。这可以强制执行您的语法,而不需要打很多字符。同时它也允许您将所有相关代码放在一起,从而更容易地理解您的代码在更大层面上发生了什么。


5

我曾经见过/使用过这个:

new ComplexBuilder(requiredvarA, requiedVarB).optional(foo).optional(bar).build();

然后将这些传递给需要它们的对象。


5

在我看来,这似乎过于臃肿。 如果您必须拥有所有参数,请通过构造函数传递它们。


8
建造者模式的重点在于将构造函数中的参数放置其中是有问题的(需要跟踪顺序等)。 - Kathy Van Stone
2
生成器模式不是将顺序与特定步骤分开,以便“生成器”知道如何执行单个步骤,而“导演”类对它们进行排序吗?也就是说,这是模板模式的一个特例。 - Jeff Sternal

2
建造者模式通常用于有许多可选参数的情况。如果您发现需要许多必需参数,请首先考虑以下选项:
您的班级可能做得太多了。请仔细检查它是否违反了单一职责原则。问问自己为什么需要一个有这么多必需实例变量的类。
您的构造函数可能做得太多了。构造函数的工作是构造。(当他们给它命名时并不是非常有创意 ;D)就像类一样,方法也有单一职责原则。如果您的构造函数不仅仅是字段赋值,您需要有充分的理由来证明这一点。您可能会发现需要一个工厂方法而不是一个生成器。
您的参数可能做得太少了。问问自己是否可以将参数分组到一个小结构体中(或在Java的情况下是类似于结构体的对象)。不要害怕制作小类。如果您确实需要制作一个结构体或小类,请不要忘记重构出属于结构体而不是您的较大类的功能链接8:退化

1

如果想了解何时使用建造者模式及其优势,您可以查看我为另一个类似问题撰写的文章这里


0
问题1:关于模式的名称,我喜欢“Step Builder”的名称:

问题2/3:关于陷阱和建议,对于大多数情况来说,这似乎过于复杂。

  • 您正在强制使用构建器的顺序,这在我的经验中是不寻常的。我可以看出在某些情况下这很重要,但我从未需要过。例如,我不认为有必要在此处强制执行顺序:

    Person.builder().firstName("John").lastName("Doe").build() Person.builder().lastName("Doe").firstName("John").build()

  • 然而,许多时候,构建器需要强制执行一些约束条件,以防止构建虚假对象。也许您想确保提供了所有必需的字段,或者组合字段是有效的。我猜这才是您想要引入构建顺序的真正原因。

    在这种情况下,我喜欢Joshua Bloch的建议,在build()方法中进行验证。这有助于跨字段验证,因为此时所有内容都可用。请参见此答案:https://softwareengineering.stackexchange.com/a/241320

总之,我不会因为担心“遗漏”构建器方法的调用而向代码中添加任何复杂性。实际上,这很容易通过测试用例来捕捉。也许可以从一个普通的构建器开始,如果你一直被遗漏的方法调用所困扰,再引入这个方法。

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接