分享

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

 兰亭文艺 2018-01-17
具有常量性的类型很简单,它们自创建后便保持不变。如果在构造的时候就验证了参数的有效性,我们就可以确保从此之后它都处于有效的状态。因为我们不可能再更改其内部状态。通过禁止在构建对象之后更改对象状态,我们实际上可以省却许多必要的错误检查。具有常量性的类型同时也是线程安全的:多个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 readonlystring  _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, stringline2,  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访问器。对于目的是存储数据的类型来说,我们应该尽可能地将它们实现为具有常量性和原子性的值类型。在这些类型的基础上,我们可以很容易地构建更复杂的结构。

    本站是提供个人知识管理的网络存储空间,所有内容均由用户发布,不代表本站观点。请注意甄别内容中的联系方式、诱导购买等信息,谨防诈骗。如发现有害或侵权内容,请点击一键举报。
    转藏 分享 献花(0

    0条评论

    发表

    请遵守用户 评论公约

    类似文章 更多