导航

条款7:将值类型尽可能实现为具有常量性和原子性的类型

具有常量性的类型很简单,它们自创建后便保持不变。如果在构造的时候就验证了参数的有效性,我们就可以确保从此之后它都处于有效的状态。因为我们不可能再更改其内部状态。通过禁止在构建对象之后更改对象状态,我们实际上可以省却许多必要的错误检查。具有常量性的类型同时也是线程安全的:多个reader可以访问同样的内容。因为如果内部状态不可能改变,那么不同线程也就没有机会获得同一数据的不同值。具有常量性的类型也可以安全地暴露给外界,因为调用者不可能改变对象的内部状态。具有常量性的类型在基于散列(hash)的集合中也表现得很好,因为由Object.GetHashCode()方法返回的值必须是一个不变量(参见条款10),而具有常量性的类型显然可以保证这一点。

然而,并非所有类型都可以为常量类型。如果那样的话,我们将需要克隆对象来改变程序的状态。这也就是为什么本条款同时针对具有常量性和原子性的值类型。我们应该将我们的类型分解为各种可以自然形成单个实体的结构。比如,Address类型就是这样的例子。一个Address对象是由多个相关字段组成的单一实体。其中一个字段的更改可能意味着需要更改其他字段。而Customer类型就不具有原子性。一个Customer类型可能包含许多信息:地址(address)、名称(name)以及一个或者多个电话号码(phone number)。这些独立信息中的任何一个都可能更改。一个Customer对象可能要更改它的电话号码,但并不需要更改地址;也可能更改它的地址,而仍然保留同样的电话号码;也可能更改它的名称,但保留同样的电话号码和地址。因此,Customer对象并不具有原子性。但它由各个不同的原子类型组成:一个地址、一个名称或者一组电话号码/类型对[13]。具有原子性的类型都是单一的实体:我们通常会直接替换一个原子类型的整个内容。但有时候也有例外,比如更改构成它的几个字段。

下面是一个典型的可变类型Address的实现:

// 可变结构Address。

public struct Address

{

  private string  _line1;

  private string _line2;

  private string  _city;

  private string _state;

  private int    _zipCode;

  // 依赖系统产生的默认构造器。

  public string Line1

  {

    get { return _line1; }

    set { _line1 = value; }

  }

  public string Line2

  {

    get { return _line2; }

    set { _line2 = value; }

  }

  public string City

  {

    get { return _city; }

    set { _city= value; }

  }

  public string State

  {

    get { return _state; }

    set

    {

      ValidateState(value);

      _state = value;

    }

  }

  public int ZipCode

  {

    get { return _zipCode; }

    set

    {

      ValidateZip( value );

      _zipCode = value;

    }

  }

  // 忽略其他细节。

}

// 应用示例:

Address a1 = new Address( );

a1.Line1 = "111 S. Main";

a1.City = "Anytown";

a1.State = "IL";

a1.ZipCode = 61111 ;

// 更改:

a1.City = "Ann Arbor"; // ZipCode、State 现在无效。

a1.ZipCode = 48103; // State 现在仍然无效。

a1.State = "MI"; // 现在整个对象正常。

内部状态的改变意味着有可能违反对象的不变式(invariant)——至少是临时性地违反。在我们将City字段更改之后,a1就处于无效的状态了。City更改后便不再与State或者ZipCode匹配。上面的代码看起来好像没什么问题,但是假设这段代码是一个多线程程序的一部分,那么任何在City更改过程中的上下文切换都可能导致另一个线程看到不一致的数据视图。

即使我们并不是在编写多线程应用程序,上面的代码仍然存在问题。假设ZipCode的值无效,因此抛出了一个异常。这时候我们实际上仅做了一部分改变,对象将处于一个无效的状态。为了修复这个问题,我们需要在Address结构中添加相当多的内部校验代码。这无疑将增加代码的体积和复杂性。为了完全实现异常安全,我们还需要在所有改变多个字段的代码块处放上防御性的代码。线程安全也要求我们在每一个属性访问器(get和set)上添加线程同步检查。总而言之,这将是一个相当可观的工作——而且我们还要考虑随着时间的推移,功能的增加,以及代码可能的扩展。

相反,让我们将Address结构实现为常量类型。首先,要将所有的实例字段都更改为只读字段:

public struct Address

{

  private readonly string  _line1;

  private readonly string  _line2;

  private readonly string  _city;

  private readonly string  _state;

  private readonly int    _zipCode;

  // 忽略其他细节。

}

同时要删除每个属性的所有set访问器:

public struct Address

{

  // ...

  public string Line1

  {

    get { return _line1; }

  }

  public string Line2

  {

    get { return _line2; }

  }

  public string City

  {

    get { return _city; }

  }

  public string State

  {

    get { return _state; }

  }

  public int ZipCode

  {

    get { return _zipCode; }

  }

}

现在我们得到了一个常量类型。为了让其可用,我们还需要添加必要的构造器来彻底初始化Address结构。目前看来,Address结构只需要一个构造器来为其每一个字段赋值。复制构造器就不必要了,因为C#默认的赋值操作符已经足够高效了。记住,默认的构造器仍然是有效的。使用默认构造器创建的Address对象中所有的字符串将为null,而zipCode将为0:

public struct Address

{

  private readonly string  _line1;

  private readonly string  _line2;

  private readonly string  _city;

  private readonly string  _state;

  private readonly int    _zipCode;

  public Address( string line1,

    string line2,

    string city,

    string state,

    int zipCode)

  {

    _line1 = line1;

    _line2 = line2;

    _city = city;

    _state = state;

    _zipCode = zipCode;

    ValidateState( state );

    ValidateZip( zipCode );

  }

  // 忽略其他细节。

}

要改变常量类型,我们需要创建一个新对象,而非在现有的实例上做修改:

// 创建一个Address:

Address a1 = new Address( "111 S. Main",

  "", "Anytown", "IL", 61111 );

// 使用重新初始化的方式来改变对象:

a1 = new Address( a1.Line1,

  a1.Line2, "Ann Arbor", "MI", 48103 );

现在a1只可能处于以下两个状态中的一个:原来位于Anytown的位置,或者位于Ann Arbor的新位置。我们将不可能再像前面的例子中那样把一个现有的Address对象更改为任何无效的临时状态。那些无效的中间态只可能存在于Address构造器的执行过程中,不可能出现在构造器之外。只要一个Address对象被构造好后,它的值将保持恒定不变。新版的Address也是异常安全的:a1或者为原来的值,或者为新构造的值。如果有异常在新的Address对象的构造过程中被抛出,那么a1将保持原来的值。

对于常量类型,我们还要确保没有任何漏洞会导致其内部状态被更改。由于值类型不支持派生类型,因此我们不必担心派生类型会更改其字段。但我们需要注意常量类型中的可变引用类型字段。当我们为这样的类型实现构造器时,需要对其中的可变类型进行防御性的复制。下面的例子假设Phone为一个具有常量性的值类型,因为我们只关心值类型的常量性:

// 下面的类型为状态的改变留下了漏洞。

public struct PhoneList

{

  private readonly Phone[] _phones;

  public PhoneList( Phone[] ph )

  {

    _phones = ph;

  }

  public IEnumerator Phones

  {

    get

    {

      return _phones.GetEnumerator();

    }

  }

}

Phone[] phones = new Phone[10];

// 初始化phones

PhoneList pl = new PhoneList( phones );

// 改变phones数组:

// 同时也改变了常量类型的内部状态。

phones[5] = Phone.GeneratePhoneNumber( );

我们知道,数组是一个引用类型。这意味着PhoneList结构内部引用的数组和外部的phones数组引用着同一块内存空间。这样开发人员就有可能通过修改phones来修改常量结构PhoneList。为了避免这种可能性,我们需要对数组做一个防御性的复制。上面的例子展示的是一个可变集合类型可能存在的漏洞。如果Phone为一个可变的引用类型,那么将更具危害性。在这种情况下,即使集合类型可以避免更改,集合中的值仍然可能会被更改。这时候,我们就需要对这样的类型在所有构造器中做防御性的复制了——事实上只要常量类型中存在任何可变的引用类型,我们都要这么做:

// 常量类型: 构造时对可变的引用类型进行复制。

public struct PhoneList

{

  private readonly Phone[] _phones;

  public PhoneList( Phone[] ph )

  {

     _phones = new Phone[ ph.Length ];

     // 因为Phone是一个值类型,所以可以直接复制值。

     ph.CopyTo( _phones, 0 );

  }

  public IEnumerator Phones

  {

    get

    {

      return _phones.GetEnumerator();

    }

  }

}

Phone[] phones = new Phone[10];

// 初始化phones

PhoneList pl = new PhoneList( phones );

// 改变phones数组:

// 不会改变pl中的副本。

phones[5] = Phone.GeneratePhoneNumber( );

当要返回一个可变的引用类型时,我们也要遵循同样的规则。例如,如果我们要添加一个属性来从PhoneList结构中获取整个数组,那么其中的访问器也要创建一个防御性的复制。更多细节可参见条款23。

初始化常量类型通常有三种策略,选择哪一种策略依赖于一个类型的复杂度。定义一组合适的构造器通常是最简单的策略。例如,上述的Address结构就是通过定义一个构造器来负责初始化工作。

我们也可以创建一个工厂方法(factory method)来进行初始化工作。这种方式对于创建一些常用的值比较方便。.NET框架中的Color类型就采用了这种策略来初始化系统颜色。例如,静态方法Color.FromKnownColor()和Color.FromName()可以根据一个指定的系统颜色名,来返回一个对应的颜色值。

最后,对于需要多个步骤操作才能完整构造出一个常量类型的情况,我们可以通过创建一个可变的辅助类来解决。.NET中的String类就采用了这种策略,其辅助类为System.Text.StringBuilder。我们可以使用StringBuilder类通过多步操作来创建一个String对象。在执行完所有必要的操作后,我们便可以通过StringBuilder类来获取期望的String对象。

具有常量性的类型使得我们的代码更加易于编写和维护。我们不应该盲目地为类型中的每一个属性都创建get和set访问器。对于目的是存储数据的类型来说,我们应该尽可能地将它们实现为具有常量性和原子性的值类型。在这些类型的基础上,我们可以很容易地构建更复杂的结构。