装箱和拆箱的问题(NET1.1+)
【问】假设有这样一段代码
[C#]
namespace MyTest
{
public interface I
{
void Change(int x, int y);
}
public struct Point:I
{
public int X { get; set; }
public int Y { get; set; }
public override string ToString()
{
return X+","+Y;
}
public void Change(int x, int y)
{
X=x;
Y=y;
}
}
public class Program
{
static void Main(string[] args)
{
Point p = new Point();
p.X = 1;
p.Y = 2;
object o = p;
((I)p).Change(2, 2);
Console.WriteLine(p);
((Point)o).Change(2, 2);
Console.WriteLine(p);
}
}
}
[VB.net]
Namespace MyTest
Public Interface I
Sub Change(x As Integer, y As Integer)
End Interface
Public Structure Point
Implements I
Public Property X() As Integer
Get
Return m_X
End Get
Set(value As Integer)
m_X = value
End Set
End Property
Private m_X As Integer
Public Property Y() As Integer
Get
Return m_Y
End Get
Set(value As Integer)
m_Y = value
End Set
End Property
Private m_Y As Integer
Public Overrides Function ToString() As String
Return X & "," & Y
End Function
Public Sub Change(x__1 As Integer, y__2 As Integer) Implements I.Change
X = x__1
Y = y__2
End Sub
End Structure
Public Class Program
Public Shared Sub Main()
Dim p As New Point()
p.X = 1
p.Y = 2
Dim o As Object = p
DirectCast(p, I).Change(2, 2)
Console.WriteLine(p)
CType(o, Point).Change(2, 2)
Console.WriteLine(p)
End Sub
End Class
End Namespace
问上面程序运行之后,输出是什么?
【错误回答】
输出两次“2,2”。因为第一次强制把值类型转化成引用类型,根据引用类型的特点(如果:A=B,那么A改变了B也一定随着A改变;反之亦然);第二次在取出已经改变的Point,然后人为改变成2,2.
【正解】要回答这个问题,首先我们必须要彻底弄清楚“拆箱”和“装箱”在.NET中的内部运行机制。在.NET程序中总共有2中大类型——值类型和引用类型。“值类型”泛指struct类型(实际上,int,byte本质也是值类型,因为都可以找到他们的原型——Int32和Byte等)。其余都可以算作是引用类型了。这样一来,赋值时候总共有三种关系:
1) A=B(A,B都是值类型,则系统把右边的值拷贝给左边,A,B互不影响)。
2) A=B(A,B都是引用类型,且B继承于A,那么B受A的影响而影响)。
3) A=B(A是引用类型,B是值类型,且A转换成B。例如B实现了一个A接口,或者是把B直接赋值给object。通常,我们把“值类型”到“引用类型”成为“装箱”)
4) B=(B)A (A是引用类型,B是值类型,且在经过3之后进行强制转换。通常,我们把“引用类型”强制转换到“值类型”成为“拆箱”)。
其中1和2不再叙述,专门讨论3和4——对于3,大家要有一个概念——那就是当把任意的值类型赋值给引用类型的时候,引用类型其实引用的并不是“值类型”自身,而是值类型的一个拷贝(副本,相当于3中A引用B,其实A引用的是B的一个拷贝)。因此和B毫无关系。所以上述回答错误之处在于看到什么东西转化成”引用类型“就武断地认为“级联改变”,没有考虑到“级联改变”是“引用类型<=>引用类型”(是2的情况),而忽略了“值类型=引用类型”(两个类型不等的情况)。
根据这个道理,我们就能分析出题目运行结果:
1) 因为o是引用类型引用了p,且p是值类型。所以p相当于拷贝了一份自身被o引用。同样地,(I)p的时候,因为I是接口,也是引用类型,引用了p了的一个副本,和p无关。因此输出的p还是1,2.
2) 因为o得到的是p的副本,强制转化成Point之后也是对副本又进行了一次拷贝(是对副本的副本)做了改动,并非p自身,输出的也是1,2。因此我们说,把引用类型强制转化成值类型也一定发生了拷贝行为,相当于可以理解成“值类型=值类型“的情形。因此修改拷贝后的值类型也一定不会影响先前的引用类型中的那个“值类型”的值。同样地,第二次错误的回答也是基于“‘强制转换’总是把前一次的值取出来,所以改变还是原来的值”——总而言之,都是“以偏概全”,把“引用类型<=>引用类型”当成是万能公式去套用显然不行的。
总结:
1) 因为无论拆箱和装箱,都会引发数据拷贝的情况。因此我们尽量使用泛型或者重载函数等取代频繁的拆箱和装箱。
2) “拆箱”不能独立于“装箱”存在。对一个没有“装箱”的引用对象进行“拆箱”自然引发空引用异常。
【拓展】
“无论装箱和拆箱”总是要引发数据拷贝,这句话我们现在可以通过程序运行证得。不过我们似乎还不甘心——难道真是这样吗?为什么“装箱”或者“拆箱”要发生数据拷贝呢?为了解释这个问题,我们需要对.NET运行时候内存情况进行剖析——
在程序运行时,其实基于.NET的内存区域已经被自动划分成两大块——“堆”(heap)和“栈”(stack)。“栈”(stack)只用于存放值类型的数据(包含int,double等一切struct基本类型,以及自定义的struct类型)。当一个值类型和一个值类型通过赋值符号“=”发生关系时候,实际上进行了拷贝。比如我们有一个int a=1, 现在我们又声明了int b,同时令b=a。其实内存先给a分配了栈的区域,赋值1;然后又给b分配了栈的区域,把a的值拷贝给了b。因此b还是1.内存的图可以形象这样表述:
当为一个类(接口等)声明一个引用类型对象时,其实先在栈中存放了一个指向该对象的指针(尽管C#不建议使用指针,这是考虑对托管的堆释放造成不可靠的情况产生,但是类或者接口——进一步说,任意一个引用变量名,其本质是一个指针,或者说一个引用变量名),然后在“托管堆”中真正分配了这个类的实体对象,自然地,栈中的“类指针”指向托管堆的“实体”,比如当object obj = new object();时候,情况如下图:
现在情况是,因为“结构类型”总是在栈上创建,怎么也不肯到“堆”上去;但是“类指针”要引用“类实体”,该实体就必须在“堆”上创建。怎么办?唯一的办法就是当引用(指针)指向一个“值类型”时候,值类型自动复制一份到“堆”上去。所以从下图中可以看出,引用类型指向的“结构实体”和原来的实体毫不相干了。又如现在object obj = a;情况变成这个样子(实际上obj指向是a的副本a',不是a)
同样地,引用类型强制转化成值类型(因为值类型)只能放到“栈”上,于是只能再次自身拷贝一次,比如int d = (int)obj;