创建常量、原子性的值类型

概述

本文是《Effective C#》一书第七节的读书笔记。通过这篇文章,我主要想向大家说明一个我们平时可能不太会注意到的问题:创建具有常量性和原子性的值类型。

从类型设计谈起

从Class到Struct

假如我们要设计一个存储收信人地址的类型(Type), 我们管这个类型叫 Address。它应该包含这样几个属性:

Province   省
City       市
Zip        邮编

要求对Zip的格式进行控制(必须全为数字,且为6位),大家该如何设计呢?我想很多人会写成这样吧:

public class Address {
    private string province;
    private string city;
    private string zip;

    public string Province {
       get { return province; }
       set { province = value; }
    }

    public string City {
       get { return city; }
       set { city = value; }
    }

    public string Zip {
       get { return zip; }
       set {
           CheckZip(value);  // 验证格式
           zip = value;
       }
    }

    // 检测是不是正确的 zip
    private void CheckZip(string value) {
       string pattern = @"\d{6}";
       if(!Regex.IsMatch(value, pattern))
           throw new Exception("Zip is invalid! ");
    }
    public override string ToString() {
       return String.Format("Province: {0}, City: {1}, Zip: {2}", province, city, zip);
    }
}

这里已经存在第一个问题:当我们声明一个类时,更多的是定义一系列相关的操作(或者叫行为、方法),当然类中也会包含字段和属性,但这些字段通常都是为类的方法所使用,而属性则常用于表示类的状态(比如StringBuilder的Length),类的能力(比如StringBuilder的 Capacity),方法进行的状态或者阶段。而定义一个结构时,我们通常仅仅是用它来保存数据,而不提供方法,或者是仅提供对其自身进行操作或者转换的方法,而非对其它类型提供服务的方法。

Address 不包含任何的方法,它仅仅是将Provice、City、Zip这样的三个数据组织起来成为一个独立的个体,所以最好将其声明为一个Struct而非是一个Class。(这里也有例外的情况:如果Address包含二十个或者更多的字段,则考虑将其声明为Class,因为Class在参数传递时是传引用,而Struct是传值。在数据较小的情况下,传值的效率更高一些;而在数据较大的时候,传引用占据更小的内存空间。)

所以我们首先可以将Address声明为一个Struct而非Class。

数据不一致的问题

我们接下来使用一下刚刚创建的Address类型:

Address a = new Address();
a.Province = "陕西";
a.City = "西安";
a.Zip = "710068";
Console.WriteLine(a.ToString()); // Province: 陕西, City: 西安, Zip: 710068

看上去是没有问题的,但是回想下类型的定义,在给Zip属性赋值时是有可能抛出异常的,所以我们还是把它放在一个Try Catch语句中,同时我们给Zip赋一个错误的值,看会发生什么:

try {
    a.City = "青岛";
    a.Zip = "12345";      // 这里触发异常
    a.Province = "山东";
} catch {
}
Console.WriteLine(a.ToString());//Province: 陕西, City: 青岛, Zip: 710068

结果是出现了数据不一致的问题,当为Zip赋值的时候,因为引发了异常,所以对Zip以及其后的Province的赋值都失败了,但是对City的赋值是成功的。结果就是出现了Provice是陕西,City却是青岛这种情况。

即是在赋值Zip时没有引发异常,也会出现问题:在多线程情况下,当当前线程执行到修改了 City为“青岛”,但还没有修改 Zip 和 Province的时候(Zip仍为 “710068”、Province仍为“陕西”)。如果此时其他线程访问类型实例a,那么也将会读取到不一致的数据。

常量性和原子性

我们现在已经知道了上面存在的问题,那么接下来该如何改进呢?我们先来看看作者对常量性和原子性给的定义:

  • 对象的原子性:对象的状态是一个整体,如果一个字段改变,其他字段也要同时做出相应改变。简单来说,就是要么不改,要么全改。
  • 对象的常量性:对象的状态一旦确定,就不能再次更改了。如果想再次更改,需要重新构造一个对象。

我们已经知道了对象的原子性和常量性这两个概念,那么接下来该如何去实施呢?对于原子性,我们实施的办法是添加一个构造函数,在这个构造函数中为对象的所有字段赋值。而为了实施常量性,我们不允许在为对象赋值以后还能对对象状态进行修改,所以我们将属性中的set访问器删除掉,同时将字段声明为readonly:

public struct Address {
    private readonly string province;
    private readonly string city;
    private readonly string zip;

    public Address(string province, string city, string zip) {
       this.city = city;           
       this.province = province;
       this.zip = zip;
    CheckZip(zip);     // 验证格式
    }

    public string Province {
       get { return province; }
    }

    public string City {
       get { return city; }
    }

    public string Zip {
       get { return zip; }
    }
    // 其余略 ...
}

这样,我们对Address对象的创建,将所有字段的赋值都在构造函数中作为一个整体来进行;而当我们需要改变单个字段的值时,也需要重新创建对象再赋值。我们看下下面的测试:

Address a = new Address("陕西", "西安", "710068");

try {
    a = new Address("青岛", "山东", "22233");// 发生异常,对a重新赋值失败,但状态保持一致
} catch {
}

Console.WriteLine(a.ToString()); // 输出:Province: 陕西, City: 西安, Zip: 710068

避免外部类型对类型内部的访问

上面的方法解决了数据不一致的问题,但是还漏掉了一点:当类型内部维护着一个引用类型字段,比如说数组。尽管我们将它声明为了readonly,类型外部还是可以对它进行访问(如果你不清楚值类型和引用类型的区别,请参考 C#类型基础)。现在我们修改Address 类,添加一个数组phones,存储电话号码:

private readonly string[] phones;

public Address(string province, string city, string zip, string[] phones) {  
    // 略...
    this.phones = phones;
}

public string[] Phones {
    get { return phones; }
}

我们接下来做个测试:

string[] phones = { "029-88401100", "029-88500321" };
Address a = new Address("陕西", "西安", "710068", phones);

Console.WriteLine(a.Phones[0]);     // 输出: 029-88401100

string[] b = a.Phones;
b[0] = "029-XXXXXXXX";       // 通过b修改了 Address的内容

Console.WriteLine(a.Phones[0]); // 输出: 029-XXXXXXXX

可以看到,尽管 phones字段声明为了readonly,并且也只提供了get属性访问器。我们仍然可以通过 Address对象a外部的变量b,修改了a对象内部的内容。如何避免这种情况的发生呢?我们可以通过深度复制的方式来解决,在Phones的get属性访问器中添加如下代码:

public string[] Phones {
    get {
       string[] rtn = new string[phones.Length];
       phones.CopyTo(rtn, 0);
       return rtn;          
    }
}

在Get访问器中,我们创建了一个新的数组,并将Address对象本身的数组内容进行了拷贝,然后返回给调用者。此时,再次运行刚才的代码,由于b指向了新创建的这个数组对象,而非Address对象a内部的数组对象,所以对于b的修改将不再影响到a。再次运行刚才的代码,我们可以得到 029-88401100 的输出。

但是问题还没有结束,我们再看下面这段代码:

string[] phones = { "029-88401100", "029-88500321" };
Address a = new Address("陕西", "西安", "710068", phones);

Console.WriteLine(a.Phones[0]);     // 输出: 029-88401100

phones[0] = "029-XXXXXXXX";         // 通过phones变量修改了Address对象内部的数据
Console.WriteLine(a.Phones[0]); // 输出: 029-XXXXXXXX

再创建Address对象完毕,我们依然可以通过之前的数组变量来修改对象内部的数据,受到前面的启发,很容易想到我们可以在构造函数中对外部传递进来的数组进行深度复制:

public Address(string province, string city, string zip, string[] phones) {       
    // 前面略...
    this.phones = new string[phones.Length];
    phones.CopyTo(this.phones, 0);
    CheckZip(zip);    // 验证格式
}

这样,我们再次运行上面的代码,对于phones的修改便不会再影响到Address对象本身。

总结

这篇文章向大家讲述了类型设计时需要注意的三个问题:1、当创建类型的目的是为了存储一组相关的数据,且数据量不是很大的时候,将它声明为Struct比Class会获得更高的效率;2、将类型声明为具有原子性和常量性,可以避免可能出现的数据不一致问题;3、通过在构造函数和Get访问器中,对对象的字段进行深度复制,可以避免在类型的外部修改类型内部数据的问题。

感谢阅读,希望这篇文章能带给你帮助!