大量参数的构造函数与构造器模式


21

众所周知,如果您的类的构造函数带有许多参数,例如超过4个,则很可能是代码气味。您需要重新考虑该类是否满足SRP要求

但是,如果我们构建并反对依赖10个或更多参数的对象,并最终通过Builder模式设置所有这些参数,该怎么办?想象一下,您Person用其个人信息,工作信息,朋友信息,兴趣信息,教育信息等构建了一个类型对象。这已经很好了,但是您可以通过多个4来设置相同的参数,对吗?为什么这两种情况不一样?

Answers:


25

构建器模式无法解决许多参数的“问题”。但是,为什么许多论点有问题?

  • 它们表明您的课程可能做得太多。但是,有许多类型合法包含许多无法合理分组的成员。
  • 测试和理解具有许多输入的函数会变得更加复杂-字面上就是!
  • 如果语言不提供命名参数,则函数调用不会自行记录。读取带有许多参数的函数调用非常困难,因为您不知道第7个参数应该做什么。您甚至不会注意到第5个和第6个参数是否被意外交换,特别是如果您使用的是动态类型的语言,或者一切都碰巧是字符串,或者最后一个参数true出于某种原因时。

伪造命名参数

Builder模式只涉及这些问题,即有许多参数的函数调用的可维护性问题之一*。所以像

MyClass o = new MyClass(a, b, c, d, e, f, g);

可能成为

MyClass o = MyClass.builder()
  .a(a).b(b).c(c).d(d).e(e).f(f).g(g)
  .build();

∗ Builder模式最初旨在作为一种与表示形式无关的方法来组装复合对象,这比仅使用参数命名参数要大得多。特别是,构建器模式不需要流畅的界面。

这提供了一些额外的安全性,因为如果您调用不存在的builder方法,该方法将被炸开,但否则不会为您带来构造函数调用中不会包含的注释。另外,手动创建构建器需要代码,并且更多的代码始终可以包含更多的错误。

在容易定义新值类型的语言中,我发现最好使用微分型/小类型来模拟命名参数。之所以这样命名,是因为类型很小,但是最终却要键入很多;-)

MyClass o = new MyClass(
  new MyClass.A(a), new MyClass.B(b), new MyClass.C(c),
  new MyClass.D(d), new MyClass.E(e), new MyClass.F(f),
  new MyClass.G(g));

显然,类型名称ABC,...应该是说明了该参数,往往是相同名字的含义自文档名称,你想给的参数变量。与生成器的命名参数惯用法相比,所需的实现要简单得多,因此包含错误的可能性也较小。例如(使用Java-ish语法):

class MyClass {
  ...
  public static class A {
    public final int value;
    public A(int a) { value = a; }
  }
  ...
}

编译器可以帮助您确保提供了所有参数。使用Builder时,您必须手动检查是否缺少参数,或者将状态机编码为宿主语言类型系统-两者都可能包含错误。

还有另一种模拟命名参数的常用方法:使用内联类语法初始化所有字段的单个抽象参数对象。在Java中:

MyClass o = new MyClass(new MyClass.Arguments(){{ argA = a; argB = b; argC = c; ... }});

class MyClass {
  ...
  public static abstract class Arguments {
    public int argA;
    public String ArgB;
    ...
  }
}

但是,可以忘记字段,这是一种非常特定于语言的解决方案(我见过JavaScript,C#和C中的用法)。

幸运的是,构造函数仍可以验证所有参数,而在部分构造状态下创建对象时,情况并非如此,并且要求用户通过setter或init()方法提供进一步的参数-所需的编码工作最少,但是编写正确的程序更加困难。

因此,尽管有许多方法可以解决“许多未命名的参数使代码难以维护的问题”,但仍然存在其他问题。

解决根本问题

例如可测试性问题。在编写单元测试时,我需要能够注入测试数据,并提供测试实现以模拟出具有外部副作用的依赖关系和操作。当您实例化构造函数中的任何类时,我无法做到这一点。除非您的类的职责是创建其他对象,否则它不应实例化任何不重要的类。这与单一责任问题并驾齐驱。班级的责任越集中,测试就越容易(通常更容易使用)。

对于构造函数而言,最简单且通常最好的方法是将完全构造的依赖项作为参数,尽管这将管理调用者的依赖项的责任推卸给了调用者(也不理想),除非依赖项是域模型中的独立实体。

有时使用(抽象的)工厂或完全依赖项注入框架,尽管在大多数用例中这些方法可能会过大。特别是,如果其中许多参数是准全局对象或在对象实例化之间不改变的配置值,则这些参数只会减少参数的数量。例如,如果参数ad是全局性的,我们将得到

Dependencies deps = new Dependencies(a, d);
...
MyClass o = deps.newMyClass(b, c, e, f, g);

class MyClass {
  MyClass(Dependencies deps, B b, C c, E e, F f, G g) {
    this.depA = deps.newDepA(b, c);
    this.depB = deps.newDepB(e, f);
    this.g = g;
  }
  ...
}

class Dependencies {
  private A a;
  private D d;
  public Dependencies(A a, D d) { this.a = a; this.d = d; }
  public DepA newDepA(B b, C c) { return new DepA(a, b, c); }
  public DepB newDepB(E e, F f) { return new DepB(d, e, f); }
  public MyClass newMyClass(B b, C c, E e, F f, G g) {
    return new MyClass(deps, b, c, e, f, g);
  }
}

根据应用程序的不同,这可能是一个改变游戏规则的方法,工厂方法最终几乎没有参数,因为所有参数都可以由依赖项管理器提供,或者它可能是大量代码,使实例化变得复杂,没有明显的好处。这样的工厂对于将接口映射到具体类型比对参数进行管理更有用。但是,这种方法试图解决过多参数的根本问题,而不是仅仅使用一个流畅的界面将其隐藏。


我真的反对自记录部分。如果您有一个不错的IDE +不错的注释,那么大多数时候,构造函数和参数定义就一键之遥。
JavierIEH

在Android Studio 3.0中,构造函数中的参数名称显示在构造函数调用中传递的值旁边。例如:new A(operand1:34,operationnd2:56); 操作数1和操作数2是构造函数中的参数名称。IDE会显示它们,以使代码更具可读性。因此,无需去定义即可确定参数是什么。
石榴石

9

构建器模式无法为您解决任何问题,也无法解决设计故障。

如果您有一个需要构造10个参数的类,那么构造一个构造器来构造它就不会突然使您的设计更好。您应该选择重构相关的类。

另一方面,如果您有一个类(也许是一个简单的DTO),其中类的某些属性是可选的,则生成器模式可以简化所述对象的构造。


1

每个部分,例如个人信息,工作信息(每个“职位”),每个朋友等,都应转换为自己的对象。

考虑如何实现更新功能。用户想要添加新的工作历史记录。用户不想更改信息的任何其他部分,也不想更改以前的工作历史记录,只需保持不变即可。

当信息很多时,不可避免地一次构建一条信息。您可以根据人类的直觉(使程序员更容易使用)或基于使用方式(通常同时更新哪些信息)在逻辑上对这些片段进行分组。

构建器模式只是捕获类型化参数列表(有序或无序)的一种方法,然后可以将其传递到实际的构造函数中(或构造函数可以读取生成器捕获的参数)。

准则4只是一个经验法则。在某些情况下,需要四个以上的参数,并且没有理智或逻辑的方式将它们分组。在这些情况下,创建一个struct可以直接填充或使用属性设置器填充的对象可能是有意义的。请注意,struct在这种情况下,和构建器的用途非常相似。

但是,在您的示例中,您已经描述了将它们分组的合理方法。如果您面临的情况并非如此,也许您可​​以举一个不同的例子来说明这一点。

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.