【深入理解CLR】2:细谈值类型的装箱和拆箱

时间:2023-04-18 11:56:32

装箱  

总所周知,值类型是比引用类型更“轻型”的一种类型,因为它们不作为对象在托管堆中分配,不会被垃圾回收,也不通过指针来引用。但在许多情况下,都需要获取对值类型的一个实例的引用。例如,假定要创建一个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 = ; i < ; 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 可以重用,因为ArayList 根本不知道关于它的任何事情。注意,在这种情况下,已装箱值类型的生存期超过了未装箱的值类型的生存期。

拆箱

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

Point p = (Point) a[];

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

  拆箱不是直接将装箱过程倒过来。拆箱的代价比装箱低得多。拆箱其实就是获取一个指针的过程,该指针指向包含在一个对象中的原始值类型(数据字段)。事实上,指针指向的是已装箱实例中的未装箱部分。所以,和装箱不同,拆箱不要求在内存中复制任何字节。知道这个重要的区别之后,还应知道的一个重点在于,往往会紧接着拆箱操作发生一次字段的复制操作

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

  一个已装箱的值类型实例在拆箱时,内部会发生下面这些事情:
  1.  如果包含了“对已装箱值类型实例的引用”的变量为 null,就抛出一个 NullReferenceException 异常。
  2.  如果引用指向的对象不是所期待的值类型的一个已装箱实例,就抛出一个 InvalidCastException 异常。
上述第二条意味着以下代码不会如你可能预期的那样工作:

public static void Main() {
Int32 x = ;
Object o = x; // 对 x 进行装箱,o 引用已装箱的对象
Int16 y = (Int16) o; // 抛出一个 InvalidCastException 异常
}

下面是上述代码正确的写法:

public static void Main() {
Int32 x = ;
Object o = x; // 对 x 进行装箱,o 引用已装箱的对象
Int16 y = (Int16)(Int32) o; // 先拆箱为正确的类型,再进行转型
}

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

public static void Main() {
Point p;
p.x = p.y = ;
Object o = p; // 对 p 进行装箱;o 引用已装箱的实例
p = (Point) o; // 对 o 进行拆箱,将字段从已装箱的实例复制到栈变量中
}

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

再来看看以下代码:

public static void Main() {
Point p;
p.x = p.y = ;
Object o = p; // 对 p 进行装箱;o 引用已装箱的实例
// 将 Point 的 x 字段变成 2
p = (Point) o; // 对 o 进行拆箱,并将字段从已装箱的实例复制到栈变量中
p.x = ; // 更改栈变量的状态
o = p; // 对 p 进行装箱;o 引用新的已装箱实例
}

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

演示

public static void Main() {
Int32 v = ; // 创建一个未装箱的值类型变量
Object o = v; // o 引用一个已装箱的、包含值 5 的 Int32
v = ; // 将未装箱的值修改成 123
Console.WriteLine(v + ", " + (Int32) o); // 显示"123, 5"
}

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

.method public hidebysig static void Main() cil managed
{
.entrypoint
// 代码大小 45 (0x2d)
.maxstack
.locals init (int32 V_0,
object V_1)
// 将 5 加载到 v 中
IL_0000: ldc.i4.
IL_0001: stloc.
// 对 v 进行装箱,将引用指针存储到 o 中
IL_0002: ldloc.
IL_0003: box [mscorlib]System.Int32
IL_0008: stloc.
// 将 123 加载到 v 中
IL_0009: ldc.i4.s
IL_000b: stloc.
// 对 v 进行装箱,并将指针保留在栈上以进行 Concat(连接)操作
IL_000c: ldloc.
IL_000d: box [mscorlib]System.Int32
// 将字符串加载到栈上以执行 Concat 操作
IL_0012: ldstr ", "
// 对 o 进行拆箱:获取一个指针,它指向栈上的 Int32 的字段
IL_0017: ldloc.
IL_0018: unbox.any [mscorlib]System.Int32
// 对 Int32 进行装箱,并将指针保留在栈以进行 Concat 操作
IL_001d: box [mscorlib]System.Int32
// 调用 Concat
IL_0022: call string [mscorlib]System.String::Conct(object,
object,
object)
// 将从 Concat 返回的字符串传给 WriteLine
IL_0027: call void [mscorlib]System.Console::WriteLine(string)
// 从 Main 返回,终止这个应用程序
IL_002c: ret
} // end of method App::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 参数

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

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

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

  我们还可以这样调用 WriteLine,进一步提升上述代码的性能:

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

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

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

using System;
public sealed class Program {
public static void Main() {
Int32 v = ; // 创建一个未装箱的值类型变量
#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
}
}

总结

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

前面说过,未装箱值类型是比引用类型更“轻型”的类型。这要归结于以下两个原因:
    它们不在托管堆上分配。
    它们没有堆上的每个对象都有的额外成员,也就是一个“类型对象指针”和一个“同步块索引”。

  由于未装箱的值类型没有同步块索引,所以不能使用 System.Threading.Monitor 类型的各种方法(或者使用 C#的 lock 语句)让多个线程同步对这个实例的访问。(我想这很好的解释了lock 代码块只能对引用类型加锁的原因,就是因为值类型没有“同步块索引”)

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

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

  除此之外,将值类型的一个未装箱实例转型为类型的某个接口时,要求对实例进行装箱。这是因为接口变量必须包含对堆上的一个对象的引用

重要提示:

  在值类型中定义的成员不应修改类型的任何实例字段。也就是说,值类型应该是不可变(immutable)的。事实上,我建议将值类型的字段都标记为 readonly。这样一来,如果不慎写了一个方法企图更改一个字段,编译时就会报错。前面的例子非常清楚地揭示了这背后的原因。假如一个方法企图修改值类型的实例字段,调用这个方法就会产生非预期的行为。构造好一个值类型之后,如果不去调用任何会修改其状态的方法(或者如果根本不存在这样的方法),就不用再为什么时候会发生装箱和拆箱/字段复制而担心。如果一个值类型是不可变的,只需简单地复制相同的状态就可以了(不用担心有任何方法会修改这些状态),代码的任何行为都将在你的掌控之中。有许多开发人员审阅了本章节。在阅读了我的部分示例代码之后(比如前面的代码),他们告诉我他们再也不敢使用值类型了。这里我必须指出的是,希望记住我在这里描述的一些问题。这样一来,当代码真正出现这些问题的时候,就会心中有数。不过,虽然如此,但有一点是肯定的,不应害怕值类型。它们是有用的类型,有自己的适用场合。毕竟,程序偶尔还是需要 Int32 的。只是要注意,取决于值类型和引用类型的使用方式,它们的行为也会出现显著的区别。事实上,在前面的例子中,将 Point 声明为一个 class,而不是一个 struct,即可获得令人满意的结果。最后还要告诉你一个好消息,FCL 的核心值类型(Byte,Int32,UInt32,
Int64,UInt64,Single,Double,Decimal,BigInteger,Complex 以及所有 enums)都是“不可变”的,所以在使用这些类型时,不会发生任何稀奇古怪的事情。

PS:以上内容部分摘自CLR via C#,少部分是博主添加补充