Effective C# 5: 警惕隐式box和unbox操作对程序性能的影响

类别:.NET开发 点击:0 评论:0 推荐:
5.警惕隐式box和unbox操作对程序性能的影响

陈铭                   Microsoft .NET MVP
  “开心辞典、急智问答”——指出下面的程序段中包含的所有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 < 10000; ++ i) { //two box operations Console.WriteLine(k + “, “ + i); } 我们可以将隐式完成的变量k的box操作提到循环体的外部,从而减少程序box操作的次数,进而提高性能: int k = 5; object ok = (object)k; //manully box it for (int i = 0; i < 10000; ++ i) { //only one box operation Console.WriteLine(ok + “, “ + i); }

本文地址:http://com.8s8s.com/it/it45952.htm