Item 6: Distinguish Between Value Types and Reference Types

     值类型还是引用类型?结构体还是类?我们应当用哪个?这不是C++,把所有的类型都定义为值类型,之后我们可以再为其创建引用;这也不是java,所有的类型都是引用类型。当我们创建类型的时候我们必须明确它的行为,这对我们正确能选择值还是引用非常重要。我们必须仔细推敲我们的决定,因为一旦改变就可能在一些不显眼的地方造成错误。当我们创建一个类型的时候,将其声明为class还是struct是一个经常会遇到的问题。但是同事后修改代码相比,仔细考虑这个问题还是非常值得的。

     选择哪个并不仅仅是喜欢这个不喜欢那个这么简单。正确的选择取决于我们期望新类型如何发挥作用。值类型没有多态性,它们更适于在应用程序中储存数据之类的工作。引用类型有多态性,,适于定义应用程序的行为模式。我们应当通过分析新类型的作用来决定选择哪一种。结构体存储数据,类定义行为。

     在.Net和C#中我们要区分值类型和引用类型。在C++中所有的参数和返回值都是通过值类型来进行的。传值非常高效,但是也会遇到一些问题:例如局部拷贝(partial copying有的地方叫做截断效应(slicing problem))。当我们使用一个派生类的对象作为基类对象来进行传递时,只有基类的部分被传递了,而派生类中的一部分就被截断开来。

     Java中所有用户自定义类型都为引用类型,所有的参数和返回值都是通过引用来传递。这种处理策略具有一致性,但是在效率上有时会有些消耗。有些类型根本就不存在也不可能存在多态性。在创建和销毁这些类型实例时会有多余的消耗。在C#中,我们可以选择使用struct或class来决定使用值类型还是引用类型。值类型更轻量级,引用类型有多态性。合理使用不同作用的类型要求我们必须了解值类型和引用类型之间的区别。

     下面的例子中的方法返回一个MyData型的对象。

private MyData _myData;
public MyData Foo()
{
   
return _myData;
}

MyData v 
= Foo();
TotalSum 
+= v.Value;

     如果MyData是一个值类型,那么返回的是一个存储在v中的值的拷贝。如果它是引用类型,则返回了一个内部成员的引用,这也违反了类密封的要求。

     再考虑下面这段代码:

private MyData _myData;
public MyData Foo()
{
   
return _myData.Clone( ) as MyData;
}

MyData v 
= Foo();
TotalSum 
+= v.Value;

     现在v是_myData的一个拷贝。对于引用类型来说建立了两个对象,我们不会再引发内部成员暴露的问题。为此我们付出的代价就是新建了一个额外的对象。如果v是一个局部变量,它很快就会实效,而使用Clone方法则需要在运行时对类型进行检验。这些都会降低效率。

     通过公有方法和属性向外部传递数据的类型应当使用值类型。但是这并不是说所有从公有方法和属性返回的类型都应当为值类型。我们假设上例中MyData类型包含了一些数据,它的职责就是储存这些数据。

     但是考虑下面的代码:

private MyType _myType;
public IMyInterface Foo()
{
   
return _myType as IMyInterface;
}

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的两倍。如果它是引用类型,我们要分配三块内存空间:一块为C的对象,为8byte,两块为C对象内的MyType型对象。这种结果的原因是因为值类型将储存的值内嵌在类型内,而引用类型不是。每个引用类型的中的变量仅保留引用,其储存的值在另外的内存块中。

     选择使用值类型还是引用类型是非常重要的。一旦发生改变那就不仅仅是将struct换成class那么简单的了。考虑下面这段代码
public struct Employee
{
   
private string _name;
   private int _ID;
   
private decimal _salary;
   public void Pay( BankAccount b )
   
{
   b.Balance 
+= _salary;
   }

}

     这个简单的类型中包含了一个为员工支付工资的方法,而且运转正常。后来你决定将其换为类,因为有不同的员工:销售人员获得佣金,经理获得奖金。

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

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

}

     这个改动会影响到许多你使用结构体的地方。原本返回值类型的地方现在返回引用类型了。原本传递值类型参数的地方现在传递引用型参数了。这种变化会在下面的代码产生完全不同的表现。

Employee e1 = Employees.Find( "CEO" );
e1.Salary 
+= Bonus; 
e1.Pay( CEOBankAccount );

     应当是值增加一次的奖金变为永久增加了,因为原本是值的地方现在变成引用了。编译器并不会提出异议,但这是个bug。我们必须注意这并不是简单的替换一下的问题,这种改变也改变了程序的行为。

     造成问题产生的原因是Employee类型没有遵循值类型的规则。定义Employee时除了存储数据外,还为它添加了支付员工工资的责任。这是种责任应当使用类类型。类可以使用多态性,能很灵活的完成这些人物,而结构不可以。结构应当仅仅用来存储数据。

     在.Net的帮助性文档中还提到我们应当将类型的大小作为使用值或引用的原因之一。事实上,更重要的还是这个类型的应用。对于简单的结构和数据来说,值类型是不二之选。的确,值类型在内存管理上的效率更高:产生更少的碎片,更少的垃圾,更直接。最重要的是当从方法和属性返回时值类型返回的是原值的拷贝,这样不会有暴露内部成员的危险。当然它也有局限性,它不能实现多态性,无法从值类型中继承,就仿佛它们是sealed一样。我们可以使用值类型来实现接口,但是这需要boxing,这会降低效率。我们应当将值类型考虑为简单的存储容器,而不是OO中的object。

     我们应当更多的使用引用类型。下面有几个问题,如果的答案都是yes的话,我们就应当创建值类型。

     1. 这个类型是否仅负责数据存储?

     2. 这个类型的公有接口是否是用来传递或修改自身的数据成员?

     3. 是否确定这个类型不会有子类型?

     4. 是否确定这个类型不需要多态性?

     创建消耗较低的值类型来存储数据,创建引用类型类为你的应用程序定义行为。当拿不准使用什么的时候,那就用引用类型吧。

译自   Effective C#:50 Specific Ways to Improve Your C#                      Bill Wagner著

P.S. 由于我对Java一窍不通,所以本文中对Java的一些解释可能会不到位。
       虽说是单纯用来如果储存数据,但是如果结构十分复杂的话,那样传值还会比传引用高效么?恐怕会浪费大量的时间在应对struct中的数据吧。这样的话是不是应当传引用比较好呢?我也不是很清楚了。
       虽说将传值和传引用用错会造成一些麻烦,但相比起来将引用类型错用成值类型的错误会大的多,把值类型错定义成引用类型会造成内部成员暴露,降低效率,但是还不会造成致命伤。但是一旦发现将引用类型错定义成值类型,那可真是好多都白干了。值类型的局限太大了,还是谨慎使用的好。就像书里最后说的,不知道用什么就用引用类型。。。。。

下面是MSDN说的:
      数据类型分隔为值类型和引用类型。值类型要么是堆栈分配的,要么是在结构中以内联方式分配的。引用类型是堆分配的。引用类型和值类型都是从最终的基类 Object 派生出来的。当值类型需要充当对象时,就在堆上分配一个包装(该包装能使值类型看上去像引用对象一样),并且将该值类型的值复制给它。该包装被加上标记,以便系统知道它包含一个值类型。这个进程称为装箱,其反向进程称为取消装箱。装箱和取消装箱能够使任何类型像对象一样进行处理。

      回到目录

posted on 2006-09-11 08:39  aiya  阅读(824)  评论(0编辑  收藏  举报