5.警惕隐式box和unbox操作对程序性能的影响
“开心辞典、急智问答”——指出下面的程序段中包含的所有box和unbox操作:
public interface IMovable {
void Move(int newx, int newy);
}
public struct Point : IMovable {
public int X, Y;
public void Move(int newx, int newy) {
X = newx; Y = newy;
}
}
...
Point pt1;
pt1.X = 1; pt1.Y = 1;
ArrayList al = new ArrayList(); //1
al.Add(pt1);
Point pt2 = (Point)al[0];
Type t = pt2.GetType();
Point temp = (Point)al[0]; //2
temp.Move(5, 5);
((IMovable)al[0]).Move(4, 4);
DateTime dt = DateTime.Now; //3
string str2 = dt.ToString();
string str1 = pt1.ToString();
int m = 1; //4
string str3 = m + ", " + dt;
...
在条款X中我们曾经介绍过box操作包含了内存分配、对象拷贝等“繁重”的工作,因此正确识别出
程序中存在的隐式box/unbox操作对提高.NET程序的性能至关重要。
单就概念而言,box和unbox操作非常易于理解:当程序期待一个引用类型的对象,而实际得到的
是一个值类型对象的时候,就必须通过box操作将其转换成引用类型;反之,如果程序期待一个简单
的值类型对象,而得到的却是经过box包装的,那么就必须再通过unbox操作将它转换回来。
然而,在实际使用当中——尤其是与接口继承、多态等面向对象特性混合使用的时候,正确判断
box和unbox操作的存在就不那么容易了。因此,还是让我们逐段的分析上面提到的程序,看看你究
竟掉进了几个陷阱。J
ArrayList al = new ArrayList();
al.Add(pt1);
Point pt2 = (Point)al[0];
Type t = pt2.GetType();
这是box和unbox操作最简单基本的形式:由于ArrayList的Add方法需要一个Object类型的参数,
而程序实际提供的参数pt1是一个值类型对象,所以编译器必须产生相应的box指令,在调用Add方
法之前生成一个与pt1对应的引用类型对象,并以该对象做为Add方法的参数。
由于.NET在数组实现上使用了一些特殊的手法,值类型数组的各种操作并不需要box和unbox
(参见条款2)。
在接下来的一条语句中,al[0]返回一个Object类型的对象引用,程序需要将其强制转换成Point
类型,所以编译器也必须产生相应的unbox指令来完成这种转换。
最后,GetType是Object的成员函数,而ValueType和Point都没有改写该函数(由于GetType不
是虚函数,改写GetType需要使用new关键字),所以这里实际调用的是从Object继承而来的GetType
成员函数。显然,这个函数总是假设自己作用于一个引用类型的对象,所以,编译器必须在调用
GetType函数之前对pt2进行box操作。
相比之下,接下来的代码要稍显复杂一些了,我们需要调用Point类的Move方法,那么我们有两个选择:
一是先将al[0]转换成Point对象,然后调用其方法:
Point temp = (Point)al[0];
temp.Move(5, 5);
我们前面已经分析过,这里强制类型转换的过程中实际包含了unbox操作。由于Move是Point的成员函数,
所以可以直接调用temp对象的Move方法而不需要再进行box操作。
需要注意的是,由于C#编译器的实现,temp实际上是unbox操作之后在堆栈上建立的新的Point对象。
所以调用temp对象的Move函数并不会影响ArrayList中al[0]的值。即使将这两个语句合并起来也无济于事:
((Point)al[0]).Move(5, 5); //同样不能改变al[0]
如果需要实际修改al[0],就必须将temp写回到ArrayList中去:
Point temp = (Point)al[0];
temp.Move(5, 5);
al[0] = temp;
另外一种方法是将其转换成IMovable接口,再调用Move方法:
((IMovable)al[0]).Move(4, 4);
也许有些出乎你的意料,使用这种方法并不需要任何的box/unbox操作。al[0]返回一个Object对象的引用,
将这样一个引用转换成一个IMovable接口显然不需要box操作,而调用一个接口函数同样无需box或者unbox。
使用这种方法的另一个好处是避免了前面提到的拷贝工作。通过接口函数调用,我们“就地”修改了
ArrayList当中的Point对象的值,既减少了代码量,同时也提高了程序的性能。
接下来是关于值类型对象ToString方法的调用:
DateTime dt = DateTime.Now;
string str2 = dt.ToString();
string str1 = pt1.ToString();
我们前面已经分析过调用GetType方法的情况,这里的ToString和GetType一样是Object的成员函数,
那么调用ToString不也是显然需要box操作的么?先别急,还是让我们逐个仔细分析:
DateTime: 我们当然希望DateTime的ToString方法能够得到它所包含的时间的字符表示,
因此DateTime结构改写了从Object继承来的ToString方法。在DateTime结构的ToString方法当中,
编译器和Runtime确信当前操作的一定是DateTime对象的实例,而不是其他从DateTime继承的类型,
因为.NET不允许从值类型进一步的继承。既然编译器和Runtime都能正确处理这种值类型对象的情况,
那么在用box操作把它包装起来就显得多此一举了。所以,调用DateTime的ToString方法并不需要
box操作。
Point: Point的情况又不相同,由于我们在编写Point结构的时候忘记改写ToString方法,
Point.ToString会直接调用从Object继承的版本,返回自己的类型全名。显然,Object的ToString方
法是针对引用类型编写的,也就是在这个函数调用过程中编译器和Runtime期待的是一个对象引用,所以
必须经由box操作来转换当前的值类型对象,从而得到一个有效的对象引用。
还剩下最后一个语句了:
int m = 1;
string str3 = m + ", " + dt;
而这里真正困难的是这种对象的拼接工作究竟是如何完成的? 为了简化对象和字符串的拼接,C#编译器
可以直接接受上面的语句,并且把它转换成对String类的Concat方法的调用:
public static string Concat(params object[]);
public static string Concat(params string[]);
上面的语句实际上本转换成了:
string str3 = String.Concat(m, “,”, dt);
显然String类的Concat方法只能接受Object和String这样的引用类型对象,而m和dt这样的值类型对象
就必须先通过box操作转换才能作为String.Concat函数的参数。
通过对上面语句的简单改写,我们实际上完全可以避免这两次box操作:
string str3 = m.ToString() + ”,” + dt.ToString();
由于System.Int32和DateTime均改写了Object.ToString方法,调用ToString方法不再需要额外的
box操作了。考虑到避免box操作意味着减少了内存分配和拷贝工作,了解程序中潜在的box/unbox操作并
尽可能的避免它们对程序的性能大有裨益。
最后,让我们总结一下常见的隐式box/unbox操作以及可能采用的避免这些操作的方法:
函数参数:当函数参数声明为Object类型,而实际传递的参数是一个值类型对象的时候。这一
类函数在设计的时候应该尽可能为值类型提供重载调用形式,例如Console.Write,除了提供Object参数调
用形式以外,为各种内建的值类型也都提供了重载调用形式。
使用容器对象:除了数组以外,其它的.NET容器(例如ArrayList)都是直接容纳引用类型对
象的。用它们来容纳值类型对象需要额外的box/unbox操作。因此如果程序性能至关重要的话,应当尽量使用
值类型数组取代其它容器对象。另外,通过定义Interface,也可以减少不必要的box/unbox操作。
调用Object类方法:调用值类型对象从Object类继承来的方法虚要先对对象进行box操作。因
此最好为值类型对象提供Object中定义的虚函数的实现,这样除了较少了潜在的box/unbox操作以外,还使
得诸如ToString方法的语义更加合理。
调用接口函数:如果值类型实现了某个接口,将值类型对象转换成接口需要进行box操作。
如果你能够正确识别程序中隐藏的box/unbox操作,那么一些简单的优化手段在这里同样适用。例如下面的代码:
int k = 5;
for (int i = 0; i