Effective C# 原则6:区别值类型数据和引用类型数据

JerryXia 发表于 , 阅读 (815)

值类型数据还是引用类型数据?结构还是类?什么你须要使用它们呢?这不是C++,你可以把所有类型都定义为值类型,并为它们做一个引用。这也不是Java,所有的类型都是值类型。你在创建每个类型实例时,你必须决定它们以什么样的形式存在。这是一个为了取得正确结果,必须在一开始就要面对的重要决定。(一但做也决定)你就必须一直面对这个决定给你带来的后果,因为想在后面再对它进行改动,你就不得不在很多细小的地方强行添加很多代码。当你设计一个类型时,选择struct或者class是件简单的小事情,但是,一但你的类型发生了改变,对所有使用了该类型的用户进行更新却要付出(比设计时)多得多的工作。

这不是一个简单的非此及彼的选择。正确的选择取决于你希望你的新类型该如何使用。值类型不具备多态性,但它们在你的应用程序对数据的存取却是性能有佳;引用类型可以有多态性,并且你还可以在你的应用程序中为它们定义一些表现行为。考虑你期望给你的类型设计什么样的职能,并根据这些职能来决定设计什么样的类型。结构存储数据,而类表现行为。

因为很多的常见问题在C++以及Javaj里存在,因此.Net和C#对值类型和引用类型的做了区分。在C++里,所有的参数和返回值都是以值类型的进行传递的。以值类型进行传递是件很有效率的事,但不得不承受这样的问题:对象的浅拷贝(partial
copying)(有时也称为slicing
object)。如果你对一个派生的对象COPY数据时,是以基类的形式进行COPY的,那么只有基类的部分数据进行了COPY。你就直接丢失了派生对象的所有信息。即使时使用基类的虚函数。

而Java语言呢,在放弃了值类型数据后,或多或少有些表现吧。Javs里,所有的用户定义类型都是引用类型,所有的参数及返回数据都是以引用类型进行传递的。这一策略在(数据)一致性上有它的优势,但在性能上却有缺陷。让我们面对这样的情况,有些类型不是多态性的--它们并不须要。Java的程序员们为所有的变量准备了一个内存堆分配器和一个最终的垃圾回收器。他们还须要为每个引用变量的访问花上额外的时间,因为所有的变量都是引用类型。在C#里,你或者用struct声明一个值类型数据,或者用class声明一个引用类型数据。值类型数据应该比较小,是轻量级的。引用类型是从你的类继承来的。这一节将练习用不同的方法来使用一个数据类型,以便你给掌握值类型数据和引用类型数据之间的区别。

我们开始了,这有一个从一个方法上返回的类型:

private MyData _myData;
public MyData Foo()
{
    return _myData;
}
// call it:
MyData v = Foo();
TotalSum += v.Value;

如果MyData是一个值类型,那么回返值会被COPY到V中存起来。而且v是在栈内存上的。然而,如果MyData是一个引用类型,你就已经把一个引用导入到了一个内部变量上。同时,
你也违犯了封装原则(见原则23)。
或者,考虑这个变量:

private MyData _myData;
public MyData Foo()
{
    return _myData.Clone( ) as MyData;
}
// call it:
MyData v = Foo();
TotalSum += v.Value;

现在,v是原始数据_myData的一个COPY。做为一个引用类型,两个对象都是在内存堆上创建的。你不会因为暴露内部数据而遇到麻烦。取而代之的是你会在堆上建立了一个额外的数据对象。如果v是局部变量,它很快会成为垃圾,而且Clone要求你在运行时做类型检测。总而言之,这是低效的。

以公共方法或属性暴露出去的数据应该是值类型的。但这并不是说所有从公共成员返回的类型必须是值类型的。对前面的代码段做一个假设,MyData有数据存在,它的责任就是保存这些数据。

但是,可以考虑选择下面的代码段:

private MyType _myType;
public IMyInterface Foo()
{
    return _myType as IMyInterface;
}
// call it:
IMyInterface iMe = Foo();
iMe.DoWork();

变量_myType还是从Foo方法返回。但这次不同的是,取而代之的是访问返回值的内部数据,通过调用一个定义好了的接口上的方法来访问对象。你正在访问一个MyType的对象,而不是它的具体数据,只是使用它的行为。该行为是IMyInterface展示给我们的,同时,这个接口是可以被其它很多类型所实现的。做为这个例子,MyType应该是一个引用类型,而不是一个值类型。MyType的责任是考虑它周围的行为,而不是它的数据成员。

这段简单的代码开始告诉你它们的区别:值类型存储数据,引用类型表现行为。现在我们深入的看一下这些类型在内存里是如何存储的,以及在存储模型上表现的性能。考虑下面这个类:

public class C
{
    private MyType _a = new MyType( );
    private MyType _b = new MyType( );

    // Remaining implementation removed.
}
C var = new C();

多少个对象被创建了?它们占用多少内存?这还不好说。如果MyType是值类型,那么你只做了一次堆内存分配。大小正好是MyType大小的2倍。然而,如果MyType是引用类型,那么你就做了三次堆内存分配:一次是为C对象,占8字节(假设你用的是32位的指针)(译注:应该是4字节,可能是笔误),另2次是为包含在C对象内的MyType对象分配堆内存。之所以有这样不同的结果是因为值类型是以内联的方式存在于一个对象内,相反,引用类型就不是。每一个引用类型只保留一个引用指针,而数据存储还须要另外的空间。

为了理解这一点,考虑下面这个内存分配:MyType [] var = new MyType[ 100
];如果MyType是一个值类型数据,一次就分配出100个MyType的空间。然而,如果MyType是引用类型,就只有一次内存分配。每一个数据元素都是null。当你初始化数组里的每一个元素时,你要上演101次分配工作--并且这101次内存分配比1次分配占用更多的时间。分配大量的引用类型数据会使堆内存出现碎片,从而降低程序性能。如果你创建的类型意图存储数据的值,那么值类型是你要选择的。

采用值类型数据还是引用类型数据是一个很重要的决定。把一个值类型数据转变为类是一个深层次的改变。考虑下面这种情况:

public struct Employee
{
    private string  _name;
    private int  _ID;
    private decimal  _salary;

    // Properties elided

    public void Pay( BankAccount b )
    {
        b.Balance += _salary;
    }
}

这是个很清楚的例子,这个类型包含一个方法,你可以用它为你的雇员付薪水。时间流逝,你的系统也公正的在运行。接着,你决定为不同的雇员分等级了:销售人员取得拥金,经理取得红利。你决定把这个Employee类型改为一个类:

public class Employee
{
    private string  _name;
    private int     _ID;
    private decimal _salary;

    // Properties elided

    public virtual void Pay( BankAccount b )
    {
        b.Balance += _salary;
    }
}

这扰乱了很多已经存在并使用了你设计的结构的代码。返回值类型的变为返回引用类型。参数也由原来的值传递变为现在的引用传递。下面代码段的行为将受到重创:

Employee e1 = Employees.Find( "CEO" );
e1.Salary += Bonus; // Add one time bonus.
e1.Pay( CEOBankAccount );

就是这个一次性的在工资中添加红利的操作,成了持续的提升。曾经是值类型COPY的地方,如今都变成了引用类型的引用。编译器很乐意为你做这样的改变,你的CEO更是乐意这样的改变。另一方面,你的CEO将会给你报告BUG。

你还是没能改变对值类型和引用类型的看法,以至于你犯下这样的错误还不知道:它改变了行为!

出现这个问题的原因就是因为Employee已经不再遵守值类型数据的的原则。

另外,定义为Empolyee的保存数据的元素,在这个例子里你必须为它添加一个职责:为雇员付工资。职责是属于类范围内的事。类可以被定义多态的,从而很容易的实现一些常见的职责;而结构则不充许,它应该仅限于保存数据。

在值类型和引用类型间做选择时,.Net的说明文档建议你把类型的大小做为一个决定因素来考虑。而实际上,更多的因素是类型的使用。简单的结构或单纯的数据载体是值类型数据优秀的候选对象。事实表明,值类型数据在内存管理上有很好的性能:它们很少会有堆内存碎片,很少会有垃圾产生,并且很少间接访问。

(译注:这里的垃圾,以及前面提到过的垃圾,是指堆内存上“死”掉的对象,用户无法访问,只等着由垃圾回收器来收集的对象,因此认为是垃圾。在.net里,一般说垃圾时,都是指这些对象。建议看一下.net下垃圾回收器的管理模型)

更重要是:当从一个方法或者属性上返回时,值类型是COPY的数据。这不会有因为暴露内部结构而存在的危险。But
you pay in terms of features.
值类型在面向对象技术上的支持是有限的。你应该把所有的值类型当成是封闭的。你可以建立一个实现了接口的值类型,但这须要装箱,原则17会给你解释这会带来性能方面的损失。把值类型就当成是一个数据的容器吧,不再感觉是OO里的对象。

你创建的引用类型可能比值类型要多。如果你对下面所有问题回答YES,你应该创建值类型数据。把下面的问题与前面的Employee例子做对比:

1、类型的最基本的职责是存储数据吗?
2、它的属性上有定义完整的公共接口来访问或者修改数据成员吗?
3、我对类型决不会有子类自信吗?
4、我对类型决不会有多太性自信吗?

把值类型当成一个低层次的数据存储类型,把应用程序的行为用引用类型来表现。

你会在从类暴露的方法那取得安全数据的COPY。你会从使用内联的值类型那里得到内存使用高率的好处。并且你可以用标准的面向对象技术创建应用程序逻辑。当你对期望的使用拿不准时,使用引用类型。

添加新评论