关于参数:有多少构造函数参数太多?

How many constructor arguments is too many?

假设您有一个名为customer的类,其中包含以下字段:

  • 用户名
  • 电子邮件
  • 名字

我们还要说,根据您的业务逻辑,所有客户对象都必须定义这四个属性。

现在,通过强制构造函数指定这些属性中的每一个,我们可以很容易地做到这一点。但是,当您被迫向客户对象添加更多的必需字段时,很容易看到这是如何失控的。

我见过将20多个参数引入其构造函数的类,使用它们只是一种痛苦。但是,或者,如果您不需要这些字段,那么如果您依赖调用代码来指定这些属性,则可能会遇到具有未定义信息的风险,或者更糟的是,对象引用错误。

是否有其他方法可以替代,或者您只是需要决定x数量的构造函数参数是否太多而无法使用?


需要考虑的两种设计方法

本质模式

Fluent接口模式

这两种方法在意图上都很相似,因为我们缓慢地构建一个中间对象,然后在一个步骤中创建目标对象。

Fluent接口的一个示例是:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
public class CustomerBuilder {
    String surname;
    String firstName;
    String ssn;
    public static CustomerBuilder customer() {
        return new CustomerBuilder();
    }
    public CustomerBuilder withSurname(String surname) {
        this.surname = surname;
        return this;
    }
    public CustomerBuilder withFirstName(String firstName) {
        this.firstName = firstName;
        return this;
    }
    public CustomerBuilder withSsn(String ssn) {
        this.ssn = ssn;
        return this;
    }
    // client doesn't get to instantiate Customer directly
    public Customer build() {
        return new Customer(this);            
    }
}

public class Customer {
    private final String firstName;
    private final String surname;
    private final String ssn;

    Customer(CustomerBuilder builder) {
        if (builder.firstName == null) throw new NullPointerException("firstName");
        if (builder.surname == null) throw new NullPointerException("surname");
        if (builder.ssn == null) throw new NullPointerException("ssn");
        this.firstName = builder.firstName;
        this.surname = builder.surname;
        this.ssn = builder.ssn;
    }

    public String getFirstName() { return firstName;  }
    public String getSurname() { return surname; }
    public String getSsn() { return ssn; }    
}
1
2
3
4
5
6
7
8
9
10
11
import static com.acme.CustomerBuilder.customer;

public class Client {
    public void doSomething() {
        Customer customer = customer()
            .withSurname("Smith")
            .withFirstName("Fred")
            .withSsn("123XS1")
            .build();
    }
}


我看到有些人建议把7作为上限。很明显,人们一次能把七件东西放在头上是不正确的;他们只能记住四件(苏珊·温申克,100件每个设计师都需要知道的关于人的事情,48件)。即使如此,我认为四个是一个高地球轨道。但那是因为鲍勃·马丁改变了我的想法。

在干净的代码中,Bob叔叔主张将3作为参数个数的一般上限。他提出了激进的主张(40):

The ideal number of arguments for a function is zero (niladic). Next comes one (monadic) followed closely by two (dyadic). Three arguments (triadic) should be avoided where possible. More than three (polyadic) requires very special justification—and then shouldn't be used anyway.

他说这是因为可读性;也因为可测试性:

Imagine the difficulty of writing all the test cases to ensure that all various combinations of arguments work properly.

我鼓励你找到一本他的书,并阅读他关于函数论的全部讨论(40-43)。

我同意那些提到单一责任原则的人。对于我来说,很难相信一个需要两个或三个以上的值/对象而没有合理的默认值的类实际上只有一个责任,而且提取另一个类也不会更好。

现在,如果您是通过构造函数注入依赖项的,那么Bob Martin关于调用构造函数有多容易的论点就不那么适用了(因为通常情况下,应用程序中只有一个点可以连接它,或者您甚至有一个为您实现它的框架)。然而,单一责任原则仍然是相关的:一旦一个类有了四个依赖项,我认为它正在做大量的工作。

然而,正如计算机科学中的所有事情一样,拥有大量的构造器参数无疑是有效的。不要为了避免使用大量参数而扭曲代码;但是如果确实使用大量参数,请停止并考虑一下,因为这可能意味着代码已经被扭曲了。


在您的例子中,坚持使用构造函数。信息属于客户,4个字段都可以。

在您有许多必需和可选字段的情况下,构造函数不是最佳解决方案。正如@boojiboy所说,很难阅读,也很难编写客户端代码。

@传染建议对可选属性使用默认模式和设置器。这要求字段是可变的,但这是一个小问题。

Joshua Block在有效Java 2上说,在这种情况下,你应该考虑一个建设者。本书中的一个例子:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
 public class NutritionFacts {  
   private final int servingSize;  
   private final int servings;  
   private final int calories;  
   private final int fat;  
   private final int sodium;  
   private final int carbohydrate;  

   public static class Builder {  
     // required parameters  
     private final int servingSize;  
     private final int servings;  

     // optional parameters  
     private int calories         = 0;  
     private int fat              = 0;  
     private int carbohydrate     = 0;  
     private int sodium           = 0;  

     public Builder(int servingSize, int servings) {  
      this.servingSize = servingSize;  
       this.servings = servings;  
    }  

     public Builder calories(int val)  
       { calories = val;       return this; }  
     public Builder fat(int val)  
       { fat = val;            return this; }  
     public Builder carbohydrate(int val)  
       { carbohydrate = val;   return this; }  
     public Builder sodium(int val)  
       { sodium = val;         return this; }  

     public NutritionFacts build() {  
       return new NutritionFacts(this);  
     }  
   }  

   private NutritionFacts(Builder builder) {  
     servingSize       = builder.servingSize;  
     servings          = builder.servings;  
     calories          = builder.calories;  
     fat               = builder.fat;  
     soduim            = builder.sodium;  
     carbohydrate      = builder.carbohydrate;  
   }  
}

然后这样使用:

1
2
NutritionFacts cocaCola = new NutritionFacts.Builder(240, 8).
      calories(100).sodium(35).carbohydrate(27).build();

上面的例子取自有效的Java 2。

这不仅适用于构造函数。在实现模式中引用Kent Beck:

1
2
setOuterBounds(x, y, width, height);
setInnerBounds(x + 2, y + 2, width - 4, height - 4);

将矩形显式化为对象可以更好地解释代码:

1
2
setOuterBounds(bounds);
setInnerBounds(bounds.expand(-2));


我认为您的问题更多的是关于类的设计,而不是关于构造函数中参数的数量。如果我需要20块数据(参数)来成功初始化一个对象,我可能会考虑分解类。


我认为"纯OOP"的答案是,如果在某些成员未初始化时类上的操作无效,那么这些成员必须由构造函数设置。通常情况下可以使用默认值,但我假设我们不考虑这种情况。当API被修复时,这是一个很好的方法,因为在API公开后更改单个允许的构造函数对于您和您的代码的所有用户来说都是一场噩梦。

在C中,我对设计指南的理解是,这不一定是处理这种情况的唯一方法。特别是对于wpf对象,您会发现.NET类倾向于使用无参数构造函数,并且如果在调用方法之前数据未初始化到所需的状态,则会抛出异常。不过,这可能主要是针对基于组件的设计的;我不能想出一个具体的.NET类的例子,它的行为就是这样的。在您的例子中,它肯定会增加测试的负担,以确保类不会保存到数据存储中,除非属性已经过验证。老实说,由于这个原因,如果您的API设置为stone或非public,那么我更喜欢"构造函数设置所需的属性"方法。

我敢肯定的一点是,可能有无数种方法可以解决这个问题,而且每一种方法都引入了自己的一组问题。最好的做法是尽可能多地学习模式,并为工作选择最好的模式。(这难道不是逃避答案的借口吗?)


史蒂夫·麦康奈尔在《代码全集》中写道,人们一次在头脑中记住7件事情都有困难,所以这就是我试图记住的数字。


我认为最简单的方法是为每个值找到一个可接受的默认值。在这种情况下,每个字段看起来都需要构造,因此可能会重载函数调用,以便在调用中未定义某些内容时将其设置为默认值。

然后,为每个属性生成getter和setter函数,以便可以更改默认值。

Java实现:

1
2
3
4
5
6
7
public static void setEmail(String newEmail){
    this.email = newEmail;
}

public static String getEmail(){
    return this.email;
}

这也是保持全局变量安全的良好实践。


如果您有许多不令人满意的参数,那么只需将它们打包成structs/pod类,最好声明为正在构造的类的内部类。这样,您仍然可以在使调用构造函数的代码合理可读的同时需要字段。


我认为这完全取决于形势。对于类似于您的示例,一个客户类,我不会冒险在需要时不定义数据。另一方面,传递一个结构将清除参数列表,但在结构中仍有许多要定义的内容。


样式非常重要,在我看来,如果有一个带有20多个参数的构造函数,那么应该修改设计。提供合理的违约。


我用自己的构造/验证逻辑将类似字段封装到自己的对象中。

比如说,如果你

  • 商界
  • 营业地址
  • 家庭电话
  • 家庭住址

我会创建一个类来存储电话和地址,以及一个标签来指定它是"家"还是"公司"电话/地址。然后将4个字段缩小为一个数组。

1
2
3
4
5
6
ContactInfo cinfos = new ContactInfo[] {
    new ContactInfo("home","+123456789","123 ABC Avenue"),
    new ContactInfo("biz","+987654321","789 ZYX Avenue")
};

Customer c = new Customer("john","doe", cinfos);

那会使它看起来不像意大利面。

当然,如果您有很多字段,那么一定有一些模式可以提取出来,使其成为自己的一个很好的功能单元。并使代码更可读。

以下也是可能的解决方案:

  • 展开验证逻辑,而不是将其存储在单个类中。当用户输入它们时进行验证,然后在数据库层等处再次验证…
  • 制作一个CustomerFactory类,可以帮助我构造Customers
  • @马西奥的解决方案也很有趣…


我同意Boojiboy提到的7项限制。除此之外,还需要查看匿名(或专门)类型、IDictionary或通过另一个数据源的主键进行间接寻址。


只使用默认参数。在支持默认方法参数(例如php)的语言中,您可以在方法签名中这样做:

public function doSomethingWith($this = val1, $this = val2, $this = val3)

还有其他创建默认值的方法,例如在支持方法重载的语言中。

当然,您也可以在声明字段时设置默认值,如果您认为这样做是合适的话。

实际上,这取决于您是否适合设置这些默认值,或者您的对象是否应该在构建时一直被指定出来。这真是一个只有你才能做出的决定。


除非它不止一个参数,否则我总是使用数组或对象作为构造函数参数,并依赖错误检查来确保所需的参数存在。