原则14:减少重复的初始化逻辑

优质
小牛编辑
133浏览
2023-12-01

写构造函数是一个反复的工作。很多开发者总是写了第一个构造器然后复制粘贴代码到另外一个构造器,以满足多个重载函数接口的定义。但愿,你不是其中的一个。如果你是,那么请停止这么做。老练的 C++ 程序员会提取出通用的算法成为一个 private 辅助方法。但是,还是请停止那样做。如果你发现多个构造函数包含相同的逻辑,将这个逻辑提取到一个通用的构造器中。这样做的好处是,你可以避免代码复制,构造器初始化会产生更高效的代码。 C# 编译器将构造函数识别为特殊的语法,进而移除重复的变量初始化和重复基类构造函数的调用。这样使得最后对象执行最少的代码去进行初始化。同时你因把这个任务交个通用构造函数写的代码是最少的。

构造初始化允许一个构造函数调用另一个构造函数,下面的这个用法的例子:

public class MyClass 
{
    // collection of data 
    private List<ImportantData> coll; 
    // Name of the instance: 
    private string name;
    public MyClass() :
        this(0, "") 
    { 
    }
    public MyClass(int initialCount) :
        this(initialCount, string.Empty) 
    { 
    }
    public MyClass(int initialCount, string name) 
    {
        coll = (initialCount > 0) ? 
        new List<ImportantData>(initialCount) : 
        new List<ImportantData>();
        this.name = name; 
    }
}

C# 4.0支持默认参数,你可以将构造函数的代码进一步减少。你可以在一个构造函数中使用默认参数替换几个或者所有的参数的值:

public class MyClass 
    {
    // collection of data 
    private List<ImportantData> coll; 
    // Name of the instance: 
    private string name;
    // Needed to satisfy the new() constraint. 
    public MyClass() :
        this(0, string.Empty) 
    { 
    }
    public MyClass(int initialCount = 0, string name = "") 
    {
        coll = (initialCount > 0) ? 
        new List<ImportantData>(initialCount) : 
        new List<ImportantData>();
        this.name = name; 
    }
}

选择默认参数而不是多个函数重载是一个很好的权衡(查看原则10)。默认参数可以有更多选择。上面的 MyClass 就指定了两个参数的默认值。使用者可以指定每个参数的值。使用重载产生所有排列构造函数需要四个不同的重载构造函数:没有参数的构造函数,需要参数为 initial 的构造函数,需要参数为 name 的构造函数,以及两个参数都需要的构造函数。你的类的成员变量个数增加,重载构造函数的个数就以所有参数的排列数增长。这么复杂使得默认参数是一个非常有效的方法减少潜在需要重载构造函数的数量。

定义所有参数默认参数的构造函数意味着你代码 调用 new MyClass() 是合法的。当你希望这个概念,你需要显示创建一个无参的构造函数,正如上面例子一样。然而大部分的有默认参数,泛型的类使用 new() 约束不会接受使用所有默认参数的构造函数。为了满足 new() 约束,类必须有一个显示无参构造函数。总之,你需要有一个无参的构造函数满足泛型类或泛型方法的 new() 的约束。这也不是说所有的类都需要一个无参构造函数。然后如果你支持这个,你可以保证无参构造函数在所有情况都可以工作,即使是 new() 约束的泛型类的调用。

你该注意到第二个构造函数指定 "" 为 name 参数的默认值,而不是更常见的 string.Empty 。因为 string.Empty 不是编译时期常量。它是 string 类定义的 static 属性。因为他不是编译时期的常量,你不能使用它作为参数的默认值。

然而,使用默认参数代替函数重载使得你的类和所有使用它的客户更加耦合(查看原则10)。特别地,参数的名字和默认值变成了 public 接口的一部分。改变参数的默认值需要重新编译客户的代码才能发现这些改变。这使得重载构造函数在潜在变化的未来有更多弹性。你可以添加新的构造函数,或者改变那些没有指定值而不会破坏客户端代码的构造函数的默认行为。

C# 版本1到3是不支持默认参数,尽管这是这个问题更好的解决方法。你必须使得每个构造函数就像单独的函数。随着构造函数的增多,意味着会有更多的重复代码。使用构造函数链,即让一个构造函数调用另外一个构造函数,而不是创建通用的方法。一些低效就会在提取构造函数通用逻辑的替代方法中体现出来:

public class MyClass 
{
    // collection of data 
    private List<ImportantData> coll; 
    // Name of the instance: 
    private string name;
    public MyClass() 
    {
        commonConstructor(0, ""); 
    }
    public MyClass(int initialCount) 
    {
        commonConstructor(initialCount, ""); 
    }
    public MyClass(int initialCount, string Name) 
    {
        commonConstructor(initialCount, Name); 
    }
    private void commonConstructor(int count, 
    string name)
    {
        coll = (count > 0) ? 
            new List<ImportantData>(count) : 
            new List<ImportantData>();
        this.name = name; 
    }
}

这个版本看起效果一样,但是它产生太多低效的对象代码。编译器会增加几个函数代码在你的构造函数中。它会增加对所有成员变量的进行初始化。它会调用基类的构造函数。如果你写了自己的通用函数,编译器不会提取重复代码。如果你按着下面的方式写, IL 代码和第二个版本是一样的:

public class MyClass 
{
    private List<ImportantData> coll; 
    private string name;
    public MyClass() 
    {
        // Instance Initializers would go here. 
        object(); // Not legal, illustrative only. 
        commonConstructor(0, "");
    }
    public MyClass(int initialCount) 
    {
        // Instance Initializers would go here. 
        object(); // Not legal, illustrative only. 
        commonConstructor(initialCount, "");
    }
    public MyClass(int initialCount, string Name) 
    {
        // Instance Initializers would go here. 
        object(); // Not legal, illustrative only. 
        commonConstructor(initialCount, Name);
    }
    private void commonConstructor(int count, 
    string name)
    {
        coll = (count > 0) ? 
        new List<ImportantData>(count) : 
        new List<ImportantData>();
        this.name = name; 
    }
}

如果你用第一个版本写构造函数,在编译看来,你是这样写的:

// Not legal, illustrates IL generated: 
public class MyClass 
{
    private List<ImportantData> coll; 
    private string name;
    public MyClass() 
    {
        // No variable initializers here. 
        // Call the third constructor, shown below. 
        this(0, ""); // Not legal, illustrative only.
    }
    public MyClass(int initialCount) 
    {
        // No variable initializers here.
        // Call the third constructor, shown below. 
        this(initialCount, "");
    }
    public MyClass(int initialCount, string Name) 
    {
        // Instance Initializers would go here. 
        object(); // Not legal, illustrative only. 
        coll = (initialCount > 0) ?
        new List<ImportantData>(initialCount) : 
        new List<ImportantData>();
        name = Name; 
    }
}

区别在于编译器既不用重复产生基类构造函数,又不用复制实例变量初始化语法到每个构造函数中。实际上基类构造函数只会在最后一个的构造函数中才是有意义的:你不能包含多于一个构造函数的初始化。你可以使用 this() 把这个委托给另一个方法,或者使用 base() 调用基类构造函数。但你不能同时调用这两个。

如果你还不清楚构造函数的初始化语法?你可以考虑下只读常量。在这个例子里,对象的 name 这就是说,你应该设置为只读。这就会导致使用通用函数构造对象产生编译错误:

public class MyClass 
{
    // collection of data 
    private List<ImportantData> coll; 
    // Number for this instance 
    private int counter; 
    // Name of the instance: 
    private readonly string name;
    public MyClass() 
    {
        commonConstructor(0, string.Empty); 
    }
    public MyClass(int initialCount) 
    {
        commonConstructor(initialCount, string.Empty); 
    }
    public MyClass(int initialCount, string Name) 
    {
        commonConstructor(initialCount, Name); 
    }
    private void commonConstructor(int count, 
    string name)
    {
        coll = (count > 0) ? 
        new List<ImportantData>(count) : 
        new List<ImportantData>();
        // ERROR changing the name outside of a constructor. 
        this.name = name;
    } 
}

C++ 程序员会在每个构造函数中初始化 name ,或者在通用函数中扔掉它的常量性质。 C# 的构造函数提供了更好的选择。但在大多数琐碎的类都会包含不止一个构造函数。它们的职责就是初始化对象的所有成员变量。这些函数有相似或者相同的共享的逻辑是很常见的。使用 C# 构造函数初始化语法去提取它们通用的算法,以至于你可以只把它们写一次而且执行一次。

默认参数和重载都各有优劣。一般来说,你应该选择默认参数而不是重载构造函数。毕竟,如果你让客户端指定所有参数的值,你构造函数必须能够处理所有他们指定的值。你的原始默认值应该总是显而易见的并且不会产生异常。因此,即使改变默认参数的值是技术上的破坏,对于客户端代码是不会察觉到的。他们的代码还是用原来的值,而且那些原来的值应该能产生可以接受的行为。所以要最小化使用默认参数的潜在危害。

这是最后一个关于 C# 对象初始化的原则。是时候该回顾一个类型对象构造的一系列的事件的顺序了。你应该尽量让每个成员变量在整个构造过程中只精确的初始化一次。最好的方式是你完成变量初始化越早越好。下面就是一个实例第一次构造对象的操作顺序:

1.static 变量默认存储为0。

2.static 变量初始化执行。

3.基类静态构造函数执行。

4.静态构造函数执行。

5.实例成员变量默认存储为0。

6.实例成员变量初始化执行。

7.恰当的基类实例构造函数执行。

8.实例构造函数执行。

后续同一类型的对象初始化从第5步开始因为类初始化只会执行一次。而且,步骤6和7会被优化构造函数初始化语法会引起编译器移除重复的指令。

C# 编译器保证一个对象被创建是所有东西都被初始化好。至少,在对象创建时,所有对象内存值都被设置为0是确定的。这对静态成员变量和实例成员变量都是正确的。你的目标就是确保你初始化所有变量的代码只执行一次。适用初始化语法去初始化简单的资源。适用构造函数初始化需要复杂逻辑的变量。同时提取调用其他构造函数,以最小化重复。

小结:

这个原则讲的内容还是很重要的,尤其是最后的总结——对象变量初始化的过程。总之,对于复杂静态变量逻辑(特别是会有异常抛出)使用静态构造函数实现。

欢迎各种不爽,各种喷,写这个纯属个人爱好,秉持”分享“之德!