值类型与引用类型,究竟有何本质区别?
摘要:我在面试的时候经常会问一个问题:“谈谈值类型和引用的区别”。对于这个问题,绝大部分人都只会给我两个简洁的答案:“值类型分配在栈中,引用类型分配在堆中”,“在默认情况下,值类型参数传值(拷贝),引用类型参数传引用”。其实这个问题有很大的发挥空
我在面试的时候经常会问一个问题:“谈谈值类型和引用的区别”。对于这个问题,绝大部分人都只会给我两个简洁的答案:“值类型分配在栈中,引用类型分配在堆中”,“在默认情况下,值类型参数传值(拷贝),引用类型参数传引用”。其实这个问题有很大的发挥空间,如果能够从内存布局、GC、互操作、跨AppDomain传递等方面展开,相信会加分不少。这篇文章独辟蹊径,从“变量”的角度讨论值类型和引用类型的区别。
一、变量的地址
二、变量的值
三、常规参数的传递
四、ref参数的传递
五、in/out参数
六、总结
一、变量的地址
CLR是一个纯粹基于“栈”的虚拟机,所以在IL层面总是采用“压栈”的方式来传递参数,所以不论是引用类型还是值类型的变量,其变量自身都是分配在栈上。而x86机器指令则是基于“栈+寄存器”,所以有些变量可能会最终存储在某个寄存器上,不过这不是这篇文章关注的问题。既然变量分配在栈上,那么它必然映射一个内存地址,指向该地址的指针可以采用如下这个AsPointer方法实现的方式提取出来。
不论是值类型还是引用类型,变量都是分配在栈(或者寄存器)上,所以每个变量具有一个内存地址,如下这个AsPointer<T>方法通过调用Unsafe.AsPointer方法得到指定变量的指针(void*),然后将其转换成IntPtr(nint)类型。
internal static class Utility
{
public static unsafe nint AsPointer<T>(ref T value) => new(Unsafe.AsPointer(ref value));
}
在如下的演示程序中,我定义具有相同数据成员的两个类型,其中FoobarStruct为结构体,而FoobarClass为类。我们先后定义了四个变量s1、c1、s2和c2,其中s2和c2的值是由s1和c1赋予的。我们调用上面这个AsPointer<T>方法将四个变量的内存地址打印出来。
var s1 = new FoobarStruct(255, 1);
var c1 = new FoobarClass(255, 1);
var s2 = s1;
var c2 = c1;
Console.WriteLine($"s1: {Utility.AsPointer(ref s1)}");
Console.WriteLine($"c1: {Utility.AsPointer(ref c1)}");
Console.WriteLine($"s2: {Utility.AsPointer(ref s2)}");
Console.WriteLine($"c2: {Utility.AsPointer(ref c2)}");
public class FoobarClass
{
public byte Foo { get; set; }
public long Bar { get; set; }
public FoobarClass(byte foo, long bar)
{
Foo = foo;
Bar = bar;
}
}
public struct FoobarStruct
{
public byte Foo { get; set; }
public long Bar { get; set; }
public FoobarStruct(byte foo, long bar)
{
Foo = foo;
Bar = bar;
}
}
如下所示的是程序运行后控制台上的输出结果。可以看出虽然s1和s2、c1和c2虽然具有相同的“值”,但是变量本身具有独立的内存地址。我们可以进一步看出四个变量的地址是“递减的”,这印证了一句话“栈往下生长、堆往上生长”。
二、变量的“值”
对上面演示的这个例子来说,由于s1、c1、 s2和c2是依次定义的,所以它们对应的内存是连续的。不仅如此,我们还可以根据输出的地址计算出四个变量所占的内存大小。具体的布局如下,两个值类型的变量s1和s2占据16个字节,而两个引用类型的变量c1和c2则只占据8个字节。变量
对于值类型来说,变量与其承载的内容是“一体”的,也就是说变量占据的内存存储的就是它承载的内容。也就是说s1和s2占据的16个字节存储的就是FoobarStruct这个结构体的荷载内容。
