代码改变世界

5.3 值类型的装箱和拆箱

2012-01-04 17:00  iRead  阅读(710)  评论(0编辑  收藏  举报

  值类型是比引用类型更“轻型”的一种类型,因为它们不作为对象在托管堆中分配,不会被垃圾回收,也不通过指针来引用。但在许多情况下,都需要获取对值类型的一个实例的引用。例如,假定要创建一个ArrayList对象(System.Collections命名空间中定义的一个类型)类容纳一组Point结构,那么代码可能像下面这样:

  //声明一个值类型

  struct Point{

    public Int32 x,y;

  }

  public sealed class Program{

    public static void Main(){

      ArrayList a = new ArrayList();

      Point p;    //分配一个Point(不在堆中分配)

      for(Int32 i = 0 ;i <10; i++){

        p.x = p.y = i;      //初始化值类型中的成员

        a.Add(p);           //对值类型进行装箱,并将引用添加到ArrayList中

      }

    }

  }

  每一次循环迭代,都会初始化一个Point的值类型字段(x和y)。然后,这个Point会存储到ArrayList中。但让我们思考一下。ArrayList中究竟存储的是什么?是Point结构,Point结构的地址,还是其他完全不同的东西?要知道正确答案,必须研究ArrayList的Add方法,了解它的参数被定义成什么类型。在本例中,Add方法的原型如下:

  public virtual Int32 Add(Object value);

  可以看出,Add需要获取一个Object参数。换言之,Add需要获取对托管堆上的一个对象的引用(或指针)来作为参数。但在之前的代码中,传递的是p,也就是一个Point,是一个值类型。为了使代码正确工作,Point值类型必须转换成一个真正的、在堆中托管的对象,而且必须获取对这个对象的一个引用。

  为了将一个值类型转换成一个引用类型,要使用一个名为装箱(boxing)的机制。下面总结了对值类型的一个实例进行装箱操作时在内部发生的事情:

  1. 在托管堆中分配好内存。分配的内存量是值类型的各个字段需要的内存量加上托管堆的所有对象都有的两个额外成员(类型对象指针和同步块索引)需要的内存量。
  2. 值类型的字段复制到新分配的内存。
  3. 返回对象的地址。现在,这个地址是对一个对象的引用,值类型现在是一个引用类型。

  C#编译器会自动生成对一个值类型的实例进行装箱所需的IL代码,但你仍然需要理解内部发生的事情,否则很容易忽视代码长度问题和性能问题。

  在上述代码中,C#编译器检测到是向一个需要引用类型的方法传递一个值类型,所以会自动生成代码对对象进行装箱。在运行时,当前存在于Point值类型实例p中的字段会复制到新分配的Point对象中。已装箱的Point对象(现在是一个引用类型)的地址会返回给Add方法。Point对象会一直存在于堆中,直到被垃圾回收。Point值类型变量p可以重用,因为ArrayList根本不知道关于它的任何事情。注意,在这种情况下,已装箱值类型的生存期超过了未装箱的值类型的生存期。

注意:应该注意的是,FCL现在包含一组新的泛型集合类,它们使非泛型的集合类成为“过时”产物。例如,应该使用System.Collections.Generic.List<T>类,而不要使用System.Collections.ArrayList类。泛型集合类在非泛型集合类的基础上进行了大量增强。例如,API得到了简化和增强,集合类的性能也得到了显著提升。然而,最大的一个增强就是泛型集合类允许开发人员在操作值类型的集合时不需要对集合中的相进行装箱/拆箱处理。单单这一项涉及,就使性能提升了不少。托管堆中需要创建的对象减少了,进而减少了应用程序需要执行的垃圾回收的次数。除此之外,开发人员还获得了编译时的类型安全性,源代码也因为强制类型转换的次数减少而变得更清晰。所有这一切都将在第12章“泛型”详细解释。

  在知道装箱如何进行之后,接着谈谈拆箱。假定需要使用以下代码获取ArrayList的第一个元素:

  Point p = (Point) a[0];

  现在是要获取ArrayList的元素0中包含的引用(或指针),并试图将其放到一个Point值类型的实例p中。为了做到这一点,包含在已装箱Point对象中的所有字段都必须复制到值类型变量p中,后者在线程栈上。CLR分两步完成这个复制操作。第一步是获取已装箱的Point对象中的各个Point字段的地址。这个过程成为拆箱(unboxing)。第二步是将这些字段包含的值从堆中复制到基于栈的值类型实例中。

  拆箱不是直接将装箱过程倒过来。拆箱的代价比装箱低很多。拆箱指向的是已装箱实例中的未装箱部分。所以,和装箱不同,拆箱不要求在内存中复制任何字节。知道这个重要的区别之后,还应知道的一个重点在于,往往会紧接着拆箱操作发生一次字段的复制操作。

  显然,装箱和拆箱/复制操作会对应用程序的速度和内存消耗产生不利影响,所以应该注意编译器在什么时候生成代码来自动这些操作,并尝试手动编写代码,尽量避免自动生成代码的情况。

  一个已装箱的值类型实例在拆箱时,内部会发生下面的这些事情:

  1. 如果包含了“对已装箱值类型实例的引用”的变量为null,就抛出一个NullReferenceException异常。
  2. 如果引用指向的对象不是所期待的值类型的一个已装箱实例,就抛出一个InvalidCastException异常。(注:CLR还允许将一个值类型拆箱为同一个值类型的可空版本。)

  上述第二条意味着以下代码不会如你可能预期的那样工作:

  public static void Main(){

    Int32 x = 5;

    Object o = x;                       //对x进行装箱,o引用已装箱的对象

    Int16 y = (Int16) o;            //抛出一个InvalidCastException异常

  }

  从逻辑上说,完全可以获取o所引用一个已装箱的Int32,然后将其强制转换为一个Int16。然而,在对一个对象进行拆箱的时候,只能将其转型为原先装箱时的值类型—本例极为Int32。下面是上述代码正确的写法:

  public static void Main(){

    Int32 x = 5;

    Object o =x;               //对x进行装箱,o引用已装箱的对象

    Int16 y = (Int16)(Int32)o;

  }

  前面说过,在一次拆箱操作之后,经常紧接着一次字段复制。以下C#代码演示了拆箱和复制操作:

  public static void Main(){

    Point p;

    p.x = p.y = 1;

    Object o = p;              //对p进行装箱;o引用已装箱的实例

    p = (Point) o;              //对o进行拆箱,将字段从已装箱的实例复制到栈变量中

  }

  在最后一行,C#编译器会生成一条IL指令对o执行拆箱(获取已装箱实例中的字段的地址),并生成另一条IL指令将这些字段从堆复制到基于栈的变量p中。

  再来看看以下代码:

  public static void Main(){

    Point p;

     p.x = p.y = 1;

    Object o = p;              //对p进行装箱,o引用已装箱的实例

    //将Point的x字段变成2

    p=(Point) o;                //对o进行拆箱,并将字段从已装箱的实例复制到栈变量中

    p.x = 2;                        //更改栈变量的状态

    o = p;                           //对p进行装箱;o引用新的已装箱实例

  }

  最后三行代码唯一的目的就是将Point的x字段从1变成2。为此,首先要执行一次拆箱,再执行一次字段复制,再更改字段(在栈上),最后执行一次装箱(从而在托管堆上创建一个全新的已装箱实例)。希望你已体会到了装箱和拆箱/复制操作对引用程序性能的影响。

  有的语言(比如C++/CLI)允许在不复制字段的前提下对一个已装箱的值类型进行拆箱。拆箱返回的是一个已装箱对象的未装箱部分的地址(忽略对象的“类型对象指针”和“同步块索引”这两个额外的成员)。接着,可利用这个指针来操作未装箱实例的字段(这些恰好在堆上的一个已装箱的对象中)。例如,上述代码如果使用C++/CLI来写,效率会高很多,因为可以直接在已装箱的Point实例中修改Point的x字段的值。这样一来,就避免了在堆上分配一个新对象和复制所有字段两次。

重要提示:如果关心应用程序的性能,务必注意编译器在什么时候会生成代码来执行这些操作。遗憾的是,许多编译器都隐式地生成代码对对象进行装箱,所以我们有时可能并不知道自己写的代码会造成装箱操作。如果关心一个特定算法的性能,可以使用ILDasm.exe这样的一个工具来查看方法的IL代码,观察box这个IL指令会在哪里出现。

  再来看几个演示装箱和拆箱的例子:

  public static void Main(){

    Int32 v = 5;                //创建一个未装箱的值类型变量

    Object o = v;              //将o引用一个已装箱的、包含值5的Int32

    v=123;                         //将未装箱的值修改成123

    Console.WriteLine(v+”,”+(Int32)o); //显示”123,5”

  }

  可以从上述代码中看出发生了多少次装箱操作吗?如果说是3次,会不会觉得意外?让我们仔细分析一下代码,理解具体发生的事情。为了帮助理解,下面列出了为上述代码中的Main方法生成的IL代码。我为这些代码加上了注释,方便你看清楚发生的每一个操作:

  .method public hidebysig static void  Main() cil managed

  {

    .entrypoint

    // 代码大小     47 (0x2f)

    .maxstack  3

    .locals init ([0] int32 v,

    [1] object o)

    IL_0000:  nop

    //将5加载到v中

    IL_0001:  ldc.i4.5

    IL_0002:  stloc.0

    //对v进行装箱,将引用指针存储到o中

    IL_0003:  ldloc.0

    IL_0004:  box        [mscorlib]System.Int32

    IL_0009:  stloc.1

    //将123加载到v中

    IL_000a:  ldc.i4.s   123

    IL_000c:  stloc.0

    //对v进行装箱,并将指针保留在栈上进行Contact(连接)操作

    IL_000d:  ldloc.0

    IL_000e:  box        [mscorlib]System.Int32

    //将字符串加载到栈上以执行Contact操作

    IL_0013:  ldstr      ", "

    //对o进行拆箱:获取一个指针,它指向栈上的Int32的字段

    IL_0018:  ldloc.1

    IL_0019:  unbox.any  [mscorlib]System.Int32

    //对Int32进行装箱,并将指针保留在栈以进行Concat操作

    IL_001e:  box        [mscorlib]System.Int32

    //调用Concat

    IL_0023:  call       string [mscorlib]System.String::Concat(object,

                                                     object,

                                                     object)

    //将从Concat返回的字符串传给WriteLine

    IL_0028:  call       void [mscorlib]System.Console::WriteLine(string)

    IL_002d:  nop

    //从Main返回,终止这个应用程序

    IL_002e:  ret

  } // end of method Program::Main

  首先在栈上创建一个Int32未装箱值类型实例(v),并将其初始化为5。然后,创建一个Object类型的变量(o),并初始化它,让它指向v。但是,由于引用类型的变量必须始终指向堆中的对象,所以C#会生成正确的IL代码对v进行装箱,再将v的一个已装箱拷贝的地址存储到o中。接着,值123被放到未装箱的值类型实例v中,但这个操作不会影响已装箱的Int32,后者的值依然为5。

  接着调用WriteLine方法,WriteLine要求获取一个String对象。但是,当前没有字符串对象。相反,当前有三个数据项可供使用:一个未装箱的Int32值类型实例(v),一个String(它是一个引用类型),以及一个已装箱Int32值类型实例的引用(o),它需要转型为一个未装箱Int32。必须采取某种方式对这些数据项进行合并,以创建一个String。

  为了创建一个String,C#编译器生成代码来调用String对象的静态方法Concat。该方法有几个重载的版本,所有版本执行的操作都是一样的,唯一的区别是参数数量。由于需要连接三个数据项来创建一个字符串,所以编译器选择的是Concat方法的下面这个版本:

  public static string Concat(Object arg0,Object arg1,Object arg2);

  为第一个参数arg0传递的是v。但是,v是一个未装箱的值参数,而arg0是一个Object,所以必须对v进行装箱,并将已装箱的v的地址传给arg0.为arg1参数传递的是字符串”,”,它本质上是对一个String对象的引用。最后,对于arg2参数,o(对一个Object的引用)会转型为一个Int32。这要求执行一次拆箱操作(但不紧接着执行一次复制操作),从而获取包含在已装箱Int32中的未装箱Int32的地址。这个未装箱的Int32实例必须再次装箱,并将新的已装箱实例的内存地址传给Concat的arg2参数。

  Concat方法调用指定的每个对象的ToString方法,并连接每个对象的字符串表示。从Concat返回的String对象随机传给WriteLine方法,以显示最终的结果。

  应该指出的是,如果像下面这样写对WriteLine的调用,生成的IL代码将具有更高的执行效率:

  Console.WriteLine( v + “, “ + o);

  这和前面的版本几乎完全一致,只是移除了变量o之前的(Int32)强制转型。之所以具有更高的效率,是因为o已经是指向一个Object的引用类型,它的地址可以直接传给Concat方法。所以,在移除了强制转型之后,有两个操作可以避免:一次拆箱和一次装箱。不妨重新生成应用程序,并观察生成的IL代码,体会一下避免的操作:

  .method public hidebysig static void  Main() cil managed

  {

    .entrypoint

    // 代码大小     37 (0x25)

    .maxstack  3

    .locals init ([0] int32 v,

    [1] object o)

    IL_0000:  nop

    //将5加载到v中

    IL_0001:  ldc.i4.5

    IL_0002:  stloc.0

    //对v进行装箱,将引用指针存储到o中

    IL_0003:  ldloc.0

    IL_0004:  box        [mscorlib]System.Int32

    IL_0009:  stloc.1

    //将123加载到v中

    IL_000a:  ldc.i4.s   123

    IL_000c:  stloc.0

    //对v进行装箱,并将指针保留在栈上进行Contact(连接)操作

    IL_000d:  ldloc.0

    IL_000e:  box        [mscorlib]System.Int32

    //将字符串加载到栈上以执行Contact操作

    IL_0013:  ldstr      ", "

    //将已装箱Int32的地址加载到栈上以进行Concat操作

    IL_0018:  ldloc.1

    //调用Concat

    IL_0023:  call       string [mscorlib]System.String::Concat(object,

                                                     object,

                                                     object)

    //将从Concat返回的字符串传给WriteLine

    IL_0028:  call       void [mscorlib]System.Console::WriteLine(string)

    IL_002d:  nop

    //从Main返回,终止这个应用程序

    IL_002e:  ret

  } // end of method Program::Main

  简单对比一下这两个版本的Main方法的IL代码,会发现没有(Int32)转型的版本比有转型的版本小了10字节。第一个版本额外的拆箱/装箱步骤显然会生成更多的代码。更大的问题是,额外的装箱步骤会从托管堆中分配一个额外的对象,将来必须对其进行垃圾回收。两个版本的结果是一样的,而且速度上的差别并不明显。但是,假如在一个循环中发生额外的、不必要的装箱操作,就会严重影响应用程序的性能和内存消耗。

  甚至可以这样调用WriteLine进一步提升上述代码的性能:

  Console.WriteLine(v.ToString() + “, “+o);          //显示”123, 5”

  现在,会为未装箱的值类型实例v调用ToString方法,它返回一个String。String对象已经是引用类型,所以能直接传给Concat方法,不需要任何装箱操作。

  下面是演示装箱和拆箱的另一个例子:

  public static void Main(){

    Int32 v = 5;                          //创建一个未装箱的值类型变量

    Object o = v;                       //o引用v的已装箱版本

    v = 123;                                //将未装箱的值类型修改成123

    Console.WriteLine(v);      //显示”123”

    v = (Int32)o;                        //拆箱并将o复制到v

    Console.WriteLine(v);      //显示”5”

  }

  那么,上述代码发生了多少次装箱?答案是1次。之所以只发生一次装箱操作,是因为System.Console类定义了获取一个Int32作为参数的WriteLine方法的重载版本:

  public static void WriteLine(Int32 value);

  在前面对WriteLine的两次调用中,变量v(一个Int32未装箱值类型实例)是以传值方式传给方法的。现在,WriteLine方法也许会在它自己的内部对这个Int32进行装箱,但这已经我在我们的控制范围之内了。更重要的是,我们已经尽可能地从自己的代码中消除了装箱操作。

  仔细研究一下FCL,会发现许多方法都针对不同的值类型参数进行了重载。例如,System.Console类型提供了WriteLine方法的几个重载版本:

  public static void WriteLine(Boolean);

  public static void WriteLine(Char);

  public static void WriteLine(Char[]);

  public static void WriteLine(Int32);

  public static void WriteLine(UInt32);

  public static void WriteLine(Int64);

  public static void WriteLine(UInt64);

  public static void WriteLine(Single);

  public static void WriteLine(Double);

  public static void WriteLine(Decimal);

  public static void WriteLine(Object);

  public static void WriteLine(String);

  以下几个方法也有一组类似的重载版本:System.Console的Write方法,System.IO.BinaryWriter的Write方法。System.IO.TextWriter的Write和WriteLine方法,System.Runtime.Serialization.SerializationInfo的AddValue方法,System.Text.StringBuilder的Append和Insert方法。大多数方法进行重载唯一的目的就是减少常用值类型的装箱次数。

  但是,这些FCL类的方法不可能为你定义的值类型提供对应的重载版本。另外,即使是FCL中已经定义好的值类型,这些方法也可能没有提供对应的重载版本。调用一个方法并传递一个值类型时,如果不存在与这种值类型对应的一个重载版本,那么调用的肯定是获取一个Object参数的重载版本。将一个值类型实例作为一个Object传递,会造成装箱的发生,从而对性能产生不利影响。定义自己的类时,可将类中的方法定义为泛型(可通过类型约束的方式,将类型参数限制为值类型)。这样一来,方法就可以获取任何一种值类型而不必对其进行装箱。泛型主题将在第12章讨论。

  关于装箱最后要注意一点:如果知道自己写的代码会造成编译器反复对一个值类型进行装箱,请改成用手动方式对值类型进行装箱。这样代码会变得更小、更快。下面是一个例子:

  using System;

  public sealed class Program{

    public static void Main(){

      Int32 v = 5;                //创建一个未装箱的值类型变量

      #if INEFFICIENT

        //编译下面这一行时,v会被装箱三次,浪费时间和内存

        Console.WriteLine(“{0},{1},{2}”,v,v,v);

      #else

        //下面的代码能获得相同的结果,但无论执行速度,还是内存利用,都比前面的代码更胜一筹

        Object o = v;              //对v进行手动装箱(仅一次)

        //编译下面这行时,不会发生装箱

        Console.WriteLine(“{0},{1},{2}”,o,o,o);

      #endif

    }

  }

  在定义了INEFFICIENT符号的前提下编译上述代码,编译器会生成代码对v装箱3次,造成在堆上分配3个对象!这是十分浪费的一种行为,因为每个对象都有完全相同的内容:5。在没有定义INEFFICIENT符号的前提下编译上述代码,v只会装箱一次,所以只在堆上分配一个对象。随后,在对Console.WriteLine方法的调用中,对同一个已装箱的引用会被传递三次。第二版本执行起来快得多,在堆上分配的内存也要少得多。

  通过这些例子,很容易判断在什么时候一个值类型的实例需要装箱。简单地说,如果要获取对值类型的一个实例的引用,该实例就必须装箱。将一个值类型的实例传给需要获取一个引用类型的方法,就会发生这种情况。然而,这并不是要求对值类型实例进行装箱的唯一情况。

  前面说过,未装箱值类型是比引用类型更“轻型”的类型。这要归结于以下两个原因:

  • 它们不在托管堆上分配
  • 它们没有堆上的每个对象都有的额外成员,也就是一个“类型对象指针”和一个“同步块索引”。

  由于未装箱的值类型没有同步块索引,所以不能使用System.Threading.Monitor类型的各种方法(或者使用C#的lock语句)让多个线程同步对这个实例的访问。

  虽然未装箱的值类型没有类型对象指针,但仍可调用由类型继承或重写的虚方法(比如Equals,GetHashCode或者ToString)。如果你的值类型重写了其中任何一个虚方法,那么CLR可以非虚地调用该方法,因为值类型是隐式密封的,没有任何类型能够从它们派生。此外,用于调用虚方法的值类型实例不会被装箱。然而,如果你重写的虚方法要调用方法在基类中的实现,那么在调用基类的实现时,值类型实例就会装箱,以便通过this指针将对一个堆对象的引用传给基方法。

  然而,调用一个非虚的、继承的方法时(比如GetType或MemberwiseClone),无论如何都要对值类型进行装箱。这是因为这些方法是由System.Object定义的,所以这些方法期望this实参是指向堆上一个对象的指针。

  除此之外,将值类型的一个未装箱实例转型为类型的某个接口时,要求对实例进行装箱。这是因为接口变量必须包含对堆上的一个对象的引用(接口主题将在第13章“接口”中讨论)。以下代码对此进行了演示:

  using System;

  internal struct Point : IComparable

  {

    private Int32 m_x,m_y;

    //构造器负责初始化字段

    public Point(Int32 x, Int32 y){

      m_x = x;

      m_y = y;

    }

    //重写从System.ValueType继承的ToString方法

    public override String ToString(){

      //将Point作为字符串返回

      return String.Format(“{0},{1}”,m_x,m_y);

    }

    //实现类型安全的CompareTo方法

    public Int32 CompareTo(Point other){

      //使用勾股定理来计算哪个point距离原点(0,0)更远

      return Math.Sign(Math.Sqrt(m_x * m_x + m_y*m_y) – Math.Sign(Math.Sqrt(other.m_x * other.m_x + other.m_y * other.m_y));

    }

    //实现IComparable的CompareTo方法

    public Int32 CompareTo(Object o){

      if(GetType() != o.GetType()){

        throw new ArgumentException(“o is not a Point”);

      }

      //调用类型安全的CompareTo方法

      return CompareTo((Point)o);

    }

  }

  public static class Program

  {

    public static void Main(){

      //在栈上创建两个Point实例

      Point p1 = new Point(10,10);

      Point p2 = new Point(10,20);

                  

      //调用ToString(一个虚方法)时,不会对p1进行装箱

      Console.WriteLine(p1.ToString());             //显示”10,10”

      //调用GetType(一个非虚方法)时,要对p进行装箱

      Console.WriteLine(p1.GetType());            //显示”Point”

      //第一次调用CompareTo时,不会对p1进行装箱

      //由于调用的是CompareTo(Point),所以p2不会装箱

      Console.WriteLine(p1.CompareTo(p2));           //显示”-1”

      //p1要进行装箱,将引用放到c中

      IComparable c = p1;

      Console.WriteLine(c.GetType());               //显示”Point”

      //第二次调用CompareTo时,p1不需要装箱

      //由于向CompareTo传递的不是一个Point变量

      //所以会调用CompareTo(Object),它要求获取对一个已装箱Point的引用

      //c不会装箱,因为它引用的本来就是一个已装箱的Point

      Console.WriteLine(p1.CompareTo(c));    //显示”0”

      //第三次调用CompareTo时,c不需要装箱,因为它引用的本来就是一个已装箱的Point

      //p2会被装箱,因为调用的是CompareTo(Object)

      Console.WriteLine(c.CompareTo(p2));             //显示”-1”

      //对c进行拆箱,字段复制到p2中

      p2 = (Point)c;

      //证明字段已经复制到p2中

      Console.WriteLine(p2.ToString());             //显示”(10,10)”

    }

  }

  上述代码演示了设计装箱和拆箱的几种情形:

  • 调用ToString 在对ToString的调用中,p1不必装箱。从表面看,p1似乎必须装箱,因为ToString是从基类System.ValueType继承的一个虚方法。通常,为了调用一个虚方法,CLR需要判断对象的类型,以定位类型的方法表。由于p1是一个未装箱的值类型,所以不存在“类型对象指针”。然而,JIT编译器发现Point重写了ToString方法,所以会生成代码来直接(非虚)调用ToString方法,而不必进行任何装箱操作。编译器知道这里不存在多态性的问题,因为Point是一个值类型,没有类型能从它派生,不可能存在该虚方法的其他实现。但是,假如Point的ToString方法在内部调用base.ToString(),那么在调用System.ValueType的ToString方法时,值类型的实例会被装箱。
  • 调用GetType          调用非虚方法GetType时,p1必须进行装箱。Point的GetType方法是从System.Object继承的。所以,为了调用GetType,CLR必须使用指向一个类型对象的指针,而这个指针只能通过对p1进行装箱来获得。
  • 调用CompareTo(第一次)       在CompareTo的第一次调用中,p1不必装箱,因为Point实现了CompareTo方法,编译器能直接调用它。注意,我们向CompareTo传递了一个Point变量(p2),所以编译器会调用获取一个Point参数的CompareTo重载版本。这意味着p2以传值方式传给CompareTo,无需装箱。
  • 转型为IComparable      将p1转型为接口类型的变量c时,p1必须装箱,因为接口被定义为引用类型。p1在装箱之后,指向已装箱对象的指针会存储到变量c中。后面对GetType的调用证明c确实引用了堆上的一个已装箱的Point。
  • 调用CompareTo(第二次)     第二次调用CompareTo时,p1不需要装箱,因为Point实现了CompareTo方法,编译器能直接调用它。注意,此时向CompareTo传递的是一个IComparable类型的变量c,所以编译器会调用调取一个Object参数的CompareTo重载版本。这意味着传递的实参必须是一个指针,它必须引用堆上的一个对象。幸好,c确实引用一个已装箱的Point,所以c中的内存地址能直接传给CompareTo,无需额外进行装箱。
  • 调用CompareTo(第三次)     第三次调用CompareTo时,c引用的已经是堆上的一个已装箱Point对象。由于c是IComparable接口类型,所以只能调用接口的需要获取一个Object参数的CompareTo方法。这意味着传递的实参必须是引用了堆上的一个对象的指针。所以,p2会被装箱,指向这个已装箱对象的指针将传给CompareTo。
  • 转型为Point  将c转型为一个Point时,c引用的堆上的对象会被拆箱,其字段会从对复制到p2中。p2是栈上的一个Point类型的实例。

  我知道,对于引用类型、值类型和装箱/拆箱的所有这些讨论最初很容易让人产生挫折感。但是,任何.NET Framework开发人员只有在切实理解了这些概念之后,才能保证自己的长期成功。相信我,只有在深刻理解了之后,才能更快、更轻松地构建高效率的应用程序。(注:对于能直接在类型的方法表中找到的方法条目,在调用这些方法时,值类型都不需要进行装箱。这些方法条目包括:父类的虚拟方法,自己类型定义的方法,隐式实现的接口方法