条款6:明辨值类型和引用类型的使用场合
值类型还是引用类型?结构还是类?如何正确地使用它们?这里不是C++,在那里,所有的类型都被我们定义为值类型,然后我们可以选择创建它们的引用形式。这也不是Java,在那里,所有的类型都是引用类型[9]。在C#中,我们必须在设计类型的时候就决定类型实例的行为。这种决定非常重要。我们必须清楚这种决定的后果,因为后期的更改会导致许多代码在不经意间出现错误。在创建类型的时候选择struct或class关键字可能很简单,但如果之后要更改,所有使用我们类型的客户程序都要随之做很多更改。
说class优于struct或者struct优于class可能把问题简单化了。正确的选择依赖于我们期望将来的客户程序如何使用我们的类型。值类型不支持多态,比较适合存储供应用程序操作的数据。引用类型支持多态,应该用于定义应用程序的行为。在设计类型时,我们应该考虑类型的责任,根据期望的责任,我们才能判断创建何种类型。简而言之,结构用于存储数据,类用于定义行为。
由于C++和Java中一些常见的问题,.NET和C#才引入了值类型和引用类型的区别。在C++中,所有的参数和返回值都使用传值的方式来传递。传值的方式效率很高,但会带来一个问题:不完整复制(又叫对象切割)。如果在本该需要基类对象的地方,传递了一个派生类的对象,那么派生类的对象将只有属于基类的那一部分被复制。因此我们将失去关于这个派生类的信息。所调用的虚函数也将为基类的版本。
Java对这个问题的解决方式是从某种程度上摈弃值类型。所有Java中用户自定义的类型都是引用类型。所有的参数和返回值都以传引用的方式传递[10]。这种策略获得的好处是一致性,但在性能上却有一定的损失。而实际上有些类型没有必要支持多态。Java程序员因此要为每一个变量付出堆内存分配和垃圾收集的代价[11]。另外,对每个变量进行“解引用(dereference)”也要花费一些额外的时间。归根结底还是因为所有的变量都是引用类型。在C#中,我们使用struct或者class关键字来声明一个类型为值类型还是引用类型。值类型主要用于较小的轻量级类型,而引用类型则主要用于构建整个类层次(class hierarchy)。本节我们将展示一个类型的不同使用方法,从而帮助大家理解值类型和引用类型之间的区别。
我们先从下面的代码开始,这里的类型用做一个方法的返回值:
private MyData _myData;
public MyData Foo()
{
return _myData;
}
// 调用Foo()方法:
MyData v = Foo();
TotalSum += v.Value;
如果MyData是一个值类型,返回值将被复制到v的存储空间上,其中v处于栈上。但是,如果MyData是一个引用类型,我们实际上就将一个内部变量的引用暴露给了外界。这就打破了类型封装的原则(参见条款23)。
如果将上面的代码做如下改动:
private MyData _myData;
public MyData Foo()
{
return _myData.Clone( ) as MyData;
}
// 调用Foo()方法:
MyData v = Foo();
TotalSum += v.Value;
现在,v是_myData的一个副本。作为引用类型,_myData和v都位于堆上。这虽然避免了将类型的内部数据暴露给外界,但却在堆上创建了额外的对象。如果v是一个局部变量,它很快将变成垃圾,而且Clone方法还强制要求我们做运行时类型检查。总的来说,这样的做法是不够高效的。
因此,通过公有方法和属性暴露给外界的数据类型应该为值类型。当然,这也并不是说每一个公有成员返回的类型都应该是值类型。上面的代码实际上对于MyData有一种假设,那就是它的责任是用来存储数据的。
但是,考虑下面的代码:
private MyType _myType;
public IMyInterface Foo()
{
return _myType as IMyInterface;
}
// 调用Foo()方法:
IMyInterface iMe = Foo();
iMe.DoWork( );
虽然在上面的代码中,_myType仍然从Foo方法返回。但这次不是访问返回值内部的数据,而是通过一个定义好的接口来调用一个方法。这次访问MyType对象的目的不是其中的数据内容,而是它的行为——上面的代码使用了IMyInterface来表达行为,其实现则可以使用多种不同的类型。本例中,我们使用的是引用类型,而非值类型。MyType的责任在于它的行为,而非它的数据成员。
这段简单的代码向大家展示了值类型和引用类型之间的区别:值类型用于存储数据,引用类型用于定义行为。让我们再进一步看看这些类型如何在内存中存储,以及与存储模型相关的性能考虑。考虑下面的类:
public class C
{
private MyType _a = new MyType( );
private MyType _b = new MyType( );
// 略去其余的实现。
}
C var = new C();
上面的代码总共创建了多少对象?分别为多大?这要看情况而定。如果MyType是一个值类型,则只需要一次内存分配,大小为MyType类型大小的两倍[12]。但如果MyType是一个引用类型,将有3次内存分配:一次用于C对象,大小为8个字节(假设指针为32位);两次用于C对象中所包含的MyType对象的分配。结果不同的原因在于值类型在对象中采用内联(inline)的方式存储,而引用类型则不是。每个引用类型的变量中存储的只是一个引用,在存储空间上也需要额外的分配。
为了帮助大家理解这一点,考虑下面的代码:
MyType [] var = new MyType[ 100 ];
如果MyType是一个值类型,则只需要一次分配,大小为MyType对象大小的100倍。但如果MyType是一个引用类型,刚开始需要一次分配,分配后数组的各元素值为null。如果再初始化数组中的每个元素,我们总共将需要执行101次分配——101次分配要比1次分配耗费更多的时间。分配许多引用类型对象将在堆空间上造成很多碎片,从而降低系统的速度。如果我们创建的类型主要用于存储数据,值类型无疑是最佳的选择。
将类型设计为值类型还是引用类型是一个非常重要的决定。如果刚开始没有确定好,之后再将值类型改变为引用类型将带来很多层面的影响。考虑下面的类型:
public struct Employee
{
private string _name;
private int _ID;
private decimal _salary;
// 省略了各个属性。
public void Pay( BankAccount b )
{
b.Balance += _salary;
}
}
上面的例子相当简单,只包含了一个方法用于向Employee支付薪水。系统刚开始运行得很好,但随着时间的进展,我们可能需要不同种类的Employee:销售人员可以获取代理佣金,经理则可以获取红利奖金。因此,我们需要将Employee更改为一个类:
public class Employee
{
private string _name;
private int _ID;
private decimal _salary;
// 省略了各个属性。
public virtual void Pay( BankAccount b )
{
b.Balance += _salary;
}
}
这将会破坏许多目前正在使用原来Employee结构的代码。原来的“按值返回”将变为“按引用返回”。原来的“传值参数”将变为“传引用参数”。比如,下面一小段代码的行为就将发生很大的变化:
Employee e1 = Employees.Find( "CEO" );
e1.Salary += Bonus; // 添加一次奖金。
e1.Pay( CEOBankAccount );
如果Employee为一个结构,这段代码的含义将为“一次性的奖金发放”。但现在将Employee更改为一个类,这段代码表达的将是“对薪水的永久性提升”。本来应用“按值复制”的地方,现在被替换成了“按引用复制”。编译器会很愉快地帮助我们做这样的改变,CEO可能也很愉快。不过,CFO恐怕就要报告bug了。如果将值类型改变为引用类型会导致程序行为的改变,我们便不能这么做。
出现上述问题的原因在于Employee类型不再遵从值类型的设计原则了。除了存储Employee的数据外,我们还给它添加了责任——在本例中,就是向Employee支付薪水。责任是类的范畴。类可以很容易定义普通责任的多态实现。结构不能这么做,它们应该仅限于存储数据。
.NET文档推荐我们将类型的大小作为选择值类型还是引用类型的决定性因素。在现实中,一个更值得我们考虑的因素应该为类型的使用场合。如果具有比较简单的结构,或者是作为数据的载体,那就比较适合设计为值类型。值类型在内存管理方面也具有更好的效率:较少的堆内存碎片,较少的内存垃圾,以及较少的间接访问。更重要的是,值类型从方法或者属性中返回时使用的将是复制的方式——这避免了将内部结构的引用暴露给外界的危险。但值类型为此付出的是某些特性的缺失。值类型对常用的面向对象技术有着非常有限的支持。我们不能创建值类型的继承层次。我们应该将所有的值类型当作密封(sealed)类型。我们可以让值类型实现某些接口,但那需要装箱操作,条款17展示了其所导致的性能问题。我们应该将值类型当作数据的存储容器,而非OO(面向对象)环境中的对象。
一般而言,我们创建的引用类型总是要比值类型多。如果以下问题的答案都为Yes,那我们就应该创建值类型。大家可以将这些问题应用在前面的Employee例子上:
1. 该类型的主要职责是否用于数据存储?
2. 该类型的公有接口是否完全由一些数据成员存取属性定义?
3. 是否确信该类型永远不可能有子类?
4. 是否确信该类型永远不可能具有多态行为?
综上所述,我们应该将用于底层数据存储的类型设计为值类型,将用于定义应用程序行为的类型设计为引用类型。这样一来,我们既获得了从类对象内复制数据的安全性,也获得了基于栈的和内联方式的存储模型所带来的内存优势,同时还能利用标准的面向对象技术来创建应用程序的逻辑。如果对类型将来的应用情况不确定,那就应该使用引用类型。