Effective C# 原则17:装箱和拆箱的最小化
Item 17: Minimize Boxing and Unboxing
值类型是数据的容器,它们不具备多太性。另一方面就是说,.Net框架被设计成单一继承的引用类型,System.Object,在整个继承关系中做为根对象存在。设计这两种类型的目的是截然不同的,.Net框架使用了装箱与拆箱来链接两种不同类型的数据。装箱是把一个值类型数据放置在一个无类型的引用对象上,从而使一个值类型在须要时可以当成引用类型来使用。拆箱则是额外的从“箱”上拷贝一份值类型数据。装箱和拆箱可以让你在须要使用System.Object对象的地方使用值类型数据。但装箱与拆箱操作却是性能的强盗,在些时候装箱与拆箱会产生一些临时对象,它会导致程序存在一些隐藏的BUG。应该尽可能的避免使用装箱与拆箱。
装箱可以把一个值类型数据转化也一个引用类型,一个新的引用对象在堆上创建,它就是这个“箱子”,值类型的数据就在这个引用类型中存储了一份拷贝。参见图2.3,演示了装箱的对象是如何访问和存储的。箱子中包含一份这个值类型对象的拷贝,并且复制实现了已经装箱对象的接口。当你想从这个箱子中取回任何内容时,一个值类型数据的拷贝会被创建并返回。这就是装箱与拆箱的关键性概念:对象的一个拷贝存放到箱子中,而不管何时你再访问这个箱子时,另一个拷贝又会被创建。
图2.3,值类型数据在箱子中。把一个值类型数据转化成一个System.Object的引用,一个无名的引用类型会被创建。值类型的数据就存储在这个无名的引用对象中,所有的访问方法都要通过这个箱子才能到达值类型数据存储的地方。
最阴险的地方是这个装箱与拆箱很多时候是自动完成的!当你在任何一个期望类型是System.Object的地方使用值类型数据时,编译器会生成装箱与拆箱的语句。另外,当你通过一个接口指针来访问值类型数据时,装箱与拆箱也会发生。当你装箱时不会得到任何警告,即使是最简单的语句也一样。例如下面这个:
Console.WriteLine("A few numbers:{0}, {1}, {2}",
25, 32, 50);
使用重载的Console.WriteLine函数须要一个System.Object类型的数组引用,整型是值类型,所以必须装箱后才能传给重载的WriteLine方法。唯一可以强制这三个整数成为System.Object对象的方法就是把它们装箱。另外,在WriteLine内部,通过调用箱子对象上的ToString()方法来到达箱子内部。某种意义上讲,你生成了这样的结构:
int i =25;
object o = i; // box
Console.WriteLine(o.ToString());
在WriteLine内部,下面的执行了下面的代码:
object o;
int i = ( int )o; // unbox
string output = i.ToString( );
你可能自己从来不会写这样的代码,但是,却让编译器自动从一个指定的类型转化为System.Object,这确实是你做的。编译器只是想试着帮助你,它想让你成功(调用函数),它也很乐意在必要时候为你生成装箱和拆箱语句,从而把一个值类型数据转化成System.Object的实例。为了避免这么挑剔的惩罚,在使用它们来调用WriteLine之前,你自己应该把你的类型转化成字符串的实例。
Console.WriteLine("A few numbers:{0}, {1}, {2}",
25.ToString(), 32.ToString(), 50.ToString());
(译注:注意,在自己调用ToString方法时,还是会在堆上创建一个引用实例,但它的好处是不用拆箱,因为对象已经是一个引用类型了。)
这段代码使用已知的整数类型,而且值类型再也不会隐式的转化为System.Object类型。这个常见的例子展示了避免装箱的第一个规则:注意隐式的转化为System.Object,如果可以避免,值类型不应该被System.Object代替。
另一个常见情况就是,在使用.Net 1.x的集合时,你可能无意的把一个值类型转化成System.Object类型。任何时候,当你添加一个值类型数据到集合时中,你就创建了一个箱子。任何时候从集合中移出一个对象时,你得到的是箱子里的一个拷贝。从箱子里取一个对象时,你总是要创建一个拷贝。这会在应用程序中产生一些隐藏的BUG。编译器是不会帮你查找这些BUG的。这都是装箱惹的祸。让我们开始创建一个简单的结构,可以修改其中一个字段,并且把它的一些实例对象放到一个集合中:
public struct Person
{
private string _Name;
public string Name
{
get
{
return _Name;
}
set
{
_Name = value;
}
}
public override string ToString( )
{
Return _Name;
}
}
// Using the Person in a collection:
ArrayList attendees = new ArrayList( );
Person p = new Person( "Old Name" );
attendees.Add( p );
// Try to change the name:
// Would work if Person was a reference type.
Person p2 = (( Person )attendees[ 0 ] );
p2.Name = "New Name";
// Writes "Old Name":
Console.WriteLine(
attendees[ 0 ].ToString( ));
Person是一个值类型数据,在存储到ArrayList之前它被装箱。这会产生一个拷贝。而在移出的Persone对象上通过访问属性做一些修改时,另一个拷贝被创建。而你所做的修改只是针对的拷贝,而实际上还有第三个拷贝通过ToString()方法来访问attendees[0]中的对象。
正因为这以及其它一些原因,你应该创建一些恒定的值类型(参见原则7)。如果你非要在集合中使用可变的值类型,那就使用System.Array类,它是类型安全的。
如果一个数组不是一个合理的集合,以C#1.x中你可以通过使用接口来修正这个错误。尽量选择一些接口而不是公共的方法,来访问箱子的内部去修改数据:
public interface IPersonName
{
string Name
{
get; set;
}
}
struct Person : IPersonName
{
private string _Name;
public string Name
{
get
{
return _Name;
}
set
{
_Name = value;
}
}
public override string ToString( )
{
return _Name;
}
}
// Using the Person in a collection:
ArrayList attendees = new ArrayList( );
Person p = new Person( "Old Name" );
attendees.Add( p ); // box
// Try to change the name:
// Use the interface, not the type.
// No Unbox needed
(( IPersonName )attendees[ 0 ] ).Name = "New Name";
// Writes "New Name":
Console.WriteLine(
attendees[ 0 ].ToString( )); // unbox
装箱后的引用类型会实现原数据类型上所有已经实现的接口。这就是说,不用做拷贝,你可以通过调用箱子上的IPersonaName.Name方法来直接访问请求到箱子内部的值类型数据。在值类型上创建的接口可以让你访问集合里的箱子的内部,从而直接修改它的值。在值类型上实现的接口并没有让值类型成为多态的,这又会引入装箱的惩罚(参见原则20)。
在C#2.0中对泛型简介中,很多限制已经做了修改(参见原则49)。泛型接口和泛型集合会时同处理好集合与接口的困境。在那之前,我们还是要避免装箱。是的,值类型可以转化为System.Object或者其它任何的接口引用。这些转化是隐式的,使得发现它们成为繁杂的工作。这些也就是环境和语言的规则,装箱与拆箱操作会在你不经意时做一些对象的拷贝,这会产生一些BUG。同样,把值类型多样化处理会对性能有所损失。时刻注意那些把值类型转化成System.Object或者接口类型的地方:把值类型放到集合里,调用定义参数为System.Object类型的方法,或者强制转化为System.Object。能够避免就尽量避免!