我在面试的时候经常会问一个问题:“谈谈值类型和引用的区别”。对于这个问题,绝大部分人都只会给我两个简洁的答案:“值类型分配在栈中,引用类型分配在堆中”,“在默认情况下,值类型参数传值(拷贝),引用类型参数传引用”。其实这个问题有很大的发挥空间,如果能够从内存布局、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这个结构体的荷载内容。那么问题又来了,FoobarStruct结构体包含的两个字段的类型分别是byte和long,对应的字节数分别是1和8,总字节数应该是9个字节才对,多出的7个字节是“内存地址对齐(Alignment)”造成的。由于要确保Bar字段基于8个字节的内存对齐,虽然Foo字段只需要使用一个字节,也需要添加7个空白字节。具体的内存布局请求参与相关的文档,在这里就不再赘述了。对于引用类型来说,变量与其承载的内容则是“分离”的。引用类型的实例分配在堆上,对应的地址存储在变量占据的栈内存上。x64机器使用8个字节表示内存地址,所以c1和c2这两个变量只占据8个字节就很容易理解了。

由于变量具有唯一的栈内存地址,其类型决定字节大小,所以一个变量可以确定一段连续的栈内存空间,我们在Utility类中定义了如下这个Read<T>方法将这段内存空间的字节内容读取出来。如代码片段所示,我们通过调用Unsafe.SizeOf<T>方法确定字节数量,并据此创建一个字节数组。通过上面定义的AsPointer<T>方法得到变量的地址后,将其传入Marshal的Copy方法将字节内容拷贝到数组中。

internal static class Utility
{
public static unsafe byte[] Read<T>(ref T value)
{
byte[] bytes = new byte[Unsafe.SizeOf<T>()];
Marshal.Copy(AsPointer(ref value), bytes, 0, bytes.Length);
return bytes;
}
}

在如下所示的演示程序中,我们依然按照上面的方式定义了四个变量并对它们进行了赋值,这次我们选择调用上面这个Read<T>方法将四个变量的字节内容以16进制的形式打印出来。

var s1 = new FoobarStruct(255, 1);
var c1 = new FoobarClass(255, 1);
var s2 = s1;
var c2 = c1; Console.WriteLine($"s1: {BitConverter.ToString(Utility.Read(ref s1))}");
Console.WriteLine($"c1: {BitConverter.ToString(Utility.Read(ref c1))}");
Console.WriteLine($"s2: {BitConverter.ToString(Utility.Read(ref s2))}");
Console.WriteLine($"c2: {BitConverter.ToString(Utility.Read(ref c2))}");

从如下所示的输出结果可以看出,s1与s2,以及c1和c2承载的字节内容是完全一致的。s1和s2存储的正好是FoobarStruct的两个字段的内容,而且我们还看到了byte类型的Foo字段因“内存对齐”添加的7个空白字节(FF-00-00-00-00-00-00-00)。

虽然c1和c2具有相同的字节内容,又如何确定它们就是我们创建的FoobarClass对象在堆上的内存地址呢?这也可以通过如下的程序来验证:我们创建了一个FoobarClass对象,并将其赋值给变量value。我们调用Read<T>方法确定该变量承载的8个字节,并调用BinaryPrimitives的ReadInt64LittleEndian方法(x86采用小端字节序)转换成long类型,然后进一步转换成IntPtr(nint)类型。指向FoobarClass对象的指针可以通过调用Unsafe的AsPointer方法获得,我们通过“解指针”得到以IntPtr类型表示的内存地址。调式断言可以确认两个IntPtr对象的值是相等的。

unsafe
{
var value = new FoobarClass(255, 1);
var bytes = Utility.Read(ref value);
var pointer1 = new nint(BinaryPrimitives.ReadInt64LittleEndian(bytes));
var pointer2 = *(nint*)Unsafe.AsPointer(ref value);
Debug.Assert(pointer1 == pointer2);
}

三、常规参数的传递

对于值类型和引用类型在参数传递过程中的差异,如果我们了解了变量的本质,就很好理解了。两者直接的差异是“没有差异”——当我们将一个变量作为参数传递给某个方法时,传递的总是变量对应的栈内存存储的内容。对于值类型,传递的就是实例本身的内容;对于引用类型,传递的就是实例的地址。

var s = new FoobarStruct(255, 1);
var c = new FoobarClass(255, 1);
Invoke(s, c);
Debug.Assert(s.Foo == 255);
Debug.Assert(s.Bar == 1);
Debug.Assert(c.Foo == 0);
Debug.Assert(c.Bar == 0); static void Invoke(FoobarStruct args, FoobarClass argc)
{
args.Foo = 0;
args.Bar = 0;
argc.Foo = 0;
argc.Bar = 0;
}

有了这个认识,对于如上这段代码表现出的针对两种类型参数传递的“差异”就不难理解了。如下面的代码片段所示,变量s、c以及Invoke方法的参数args和argc都被分配到栈内存上,虽然s与args,c与argc具有相同的内容,但是针对args的操作将不会对s造成影响,但是针对c和argc的操作最终作用在引用的FoobarClass对象上。

变量与参数具有不同的内存地址可以通过如下的程序来验证:我们定义了类型分别为FoobarStruct和FoobarClass的两个变量s和c,并将其内存地址打印出来。两个变量作为参数传入Invoke方法中,后者将参数的内存地址打印出来。

var s = new FoobarStruct(255, 1);
var c = new FoobarClass(255, 1);
Console.WriteLine($"s : {Utility.AsPointer(ref s)}");
Console.WriteLine($"c : {Utility.AsPointer(ref c)}");
Invoke(s, c);
static void Invoke(FoobarStruct args, FoobarClass argc)
{
Console.WriteLine($"args: {Utility.AsPointer(ref args)}");
Console.WriteLine($"argc: {Utility.AsPointer(ref argc)}");
}

输出结果如下,可以看出变量和对应的参数具有完全不同的内存地址。

四、ref参数的传递

我们都知道如果方法需要对原始值类型变量进行修改,就需要使用ref关键来修饰对应的参数。对于上面定义的Invoke方法,如果我们在FoobarStruct类型的参数args上添加了ref关键字,变量s表示的结构体就可以在这个方法中被修改了。

var s = new FoobarStruct(255, 1);
var c = new FoobarClass(255, 1);
Invoke(ref s, ref c);
Debug.Assert(s.Foo == 0);
Debug.Assert(s.Bar == 0);
Debug.Assert(c.Foo == 0);
Debug.Assert(c.Bar == 0); static void Invoke(ref FoobarStruct args, ref FoobarClass argc)
{
args.Foo = 0;
args.Bar = 0;
argc.Foo = 0;
argc.Bar = 0;
}

对于值类型ref参数的作用,几乎所有人都能够理解,但是我发现很多人理解不了引用类型的ref参数。在他们眼中,引用类型的参数传递的就是对象的引用,加上ref关键有什么意义呢?值类型和引用类型的ref参数究竟有什么区别呢?答案同样是“没有区别”,因为它们传递的就是变量自身的地址罢了(如下所示)。

由于值类型变量和承载内容的“同一性”,所以我们自然可以利用ref参数修改变量承载的实例;引用类型存储的是对象的内存地址,那么我们不仅仅可以通过ref参数修改目标对象,我们还可以按照如下的方式让变量指向另一个对象。当然值类型ref参数也可以采用相同指定一个全新的值赋予变量。

var c = new FoobarClass(255, 1);
var original = c;
Invoke(ref c);
Debug.Assert(!ReferenceEquals(original, c));
Debug.Assert(c.Foo == 0);
Debug.Assert(c.Bar == 0);
static void Invoke(ref FoobarClass argc)
{
argc = new FoobarClass(0, 0);
}

变量和对应的ref参数具有相同的内存地址,这可以通过如下这段程序来证明。

var s = new FoobarStruct(255, 1);
var c = new FoobarClass(255, 1); Console.WriteLine($"s : {Utility.AsPointer(ref s)}");
Console.WriteLine($"c : {Utility.AsPointer(ref c)}");
Invoke(ref s, ref c); static void Invoke(ref FoobarStruct args, ref FoobarClass argc)
{
Console.WriteLine($"args: {Utility.AsPointer(ref args)}");
Console.WriteLine($"argc: {Utility.AsPointer(ref argc)}");
}

输出结果:

值得一提的是,如果我们在方法(比如下面的Invoke1)中将ref参数直接赋值给另一个变量,此时又出现了拷贝。如果希望让ref参数和变量指向相同的内存地址,需要按照Invoke2方法那样同时在变量和参数上添加ref关键字。

var s = new FoobarStruct(255, 1);

Invoke1(ref s);
Debug.Assert(s.Foo == 255);
Debug.Assert(s.Bar == 1); Invoke2(ref s);
Debug.Assert(s.Foo == 0);
Debug.Assert(s.Bar == 0);
static void Invoke1(ref FoobarStruct args)
{
var s = args;
s.Foo = 0;
s.Bar = 0;
} static void Invoke2(ref FoobarStruct args)
{
ref var s = ref args;
s.Foo = 0;
s.Bar = 0;
}

五、in/out参数

由于ref参数赋予了方法“替换”原始变量的权力,这往往不是调用者希望的,此时就可以使用in关键字。in参数和ref参数在传递变量地址这方面是完全一致的,所以方法可以利用in参数修改原始变量的成员,但是类似于下面这总直接替换变量的行为是不支持的,将一个in参数以ref参数的形式赋值给一个ref变量也是不允许的。通过out关键字定义的输出参数和in/ref参数一样也是传递变量地址,也正是因为这样,方法才能通过参数赋值的方式将其传递给调用者。

static void Invoke1(in FoobarStruct args)
{
args = new FoobarStruct(0, 0);
} static void Invoke2(in FoobarStruct args)
{
ref var s = ref args;}

in/ref参数赋予了被调用方法直接修改或者替换原始变量的能力,那么如果我们没有这方面的需求,in/ref参数是否就无用武之地了呢?当然不是,in/ref参数可以避免针对值类型对象的拷贝,如果我们定义了一个较大的结构体,针对该结构体的参数传递将会导致大量的字节拷贝,如果我们使用in/ref参数,传递的字节总是固定的4个(x86)或者8个字节。

我们从IL代码的角度进一步探索常规参数传递和三种基于引用的参数传递,为此我们定义了如下这段简单的程序。如代码片段所示,Invoke方法定义了6个参数,arg、inArg和refArg分别为常规参数、in参数和ref参数,我们将它们赋值给三个对应的out参数。

var (arg1, arg2, arg3) = (1,1,1);
Invoke(arg1, in arg2, ref arg3, out var outArg1, out var outArg2, out var outArg3); static void Invoke(int arg, in int inArg, ref int refArg, out int outArg1, out int outArg2, out int outArg3)
{
outArg1 = arg;
outArg2 = inArg;
outArg3 = refArg;
}

如下所示的是Invoke方法对应的IL代码。看出虽然6个参数在C#中的类型都是Int32,但是标注了in/ref/out关键子的参数类型在IL中变成了int32&。由于inArg和refArg存储的是变量的地址,所以在利用ldarg.{index}指令将对应参数压入栈后,还需要进一步执行ldind.i4指令提取具体的值。

.method assembly hidebysig static
void '<<Main>$>g__Invoke|0_0' (
int32 arg,
[in] int32& inArg,
int32& refArg,
[out] int32& outArg1,
[out] int32& outArg2,
[out] int32& outArg3
) cil managed
{
.custom instance void [System.Runtime]System.Runtime.CompilerServices.CompilerGeneratedAttribute::.ctor() = (
01 00 00 00
)
.param [2]
.custom instance void [System.Runtime]System.Runtime.CompilerServices.IsReadOnlyAttribute::.ctor() = (
01 00 00 00
)
// Method begins at RVA 0x269e
// Header size: 1
// Code size: 15 (0xf)
.maxstack 8 // {
IL_0000: nop
// outArg1 = arg;
IL_0001: ldarg.3
IL_0002: ldarg.0
IL_0003: stind.i4
// outArg2 = inArg;
IL_0004: ldarg.s outArg2
IL_0006: ldarg.1
IL_0007: ldind.i4
IL_0008: stind.i4
// outArg3 = refArg;
IL_0009: ldarg.s outArg3
IL_000b: ldarg.2
IL_000c: ldind.i4
IL_000d: stind.i4
// }
IL_000e: ret
} // end of method Program::'<<Main>$>g__Invoke|0_0'

如下所示的IL代码体现了针对Invoke方法的调用。在对传入参数进行压栈过程中,对于第一个常规参数arg,会执行ldloc.{index}加载变量的值。至于其余5个基于引用/地址的参数,则需要执行ldloca.{index}加载变量的地址。

.method private hidebysig static
void '<Main>$' (
string[] args
) cil managed
{
// Method begins at RVA 0x2670
// Header size: 12
// Code size: 25 (0x19)
.maxstack 6
.entrypoint
.locals init (
[0] int32 arg1,
[1] int32 arg2,
[2] int32 arg3,
[3] int32 outArg1,
[4] int32 outArg2,
[5] int32 outArg3
) // int arg2 = 1;
IL_0000: ldc.i4.1
IL_0001: stloc.0
// int inArg2 = 1;
IL_0002: ldc.i4.1
IL_0003: stloc.1
// int refArg2 = 1;
IL_0004: ldc.i4.1
IL_0005: stloc.2
// Invoke(arg2, in inArg2, ref refArg2, out var _, out var _, out var _);
IL_0006: ldloc.0
IL_0007: ldloca.s 1
IL_0009: ldloca.s 2
IL_000b: ldloca.s 3
IL_000d: ldloca.s 4
IL_000f: ldloca.s 5
IL_0011: call void Program::'<<Main>$>g__Invoke|0_0'(int32, int32&, int32&, int32&, int32&, int32&)
// (no C# code)
IL_0016: nop
// }
IL_0017: nop
IL_0018: ret
} // end of method Program::'<Main>$'

六、总结

我们最后通过一个简单的类比来做一个总结。变量的目的在于传递信息,假设我们现在利用一个“盒子”来传递一幅世界名画,这个盒子就是变量,对于非引用性质的传递(作为方法参数,或者赋值给另一个变量),传递的都是盒子承载内容的拷贝。

如果是值类型,我们相当于我们将这幅画作“真迹”放到盒子中,所以传递的是这副画作的复制品,我们在复制品上所作的任何涂鸦自然不会对真迹造成影响。如果是引用类型,我们相当于将真迹存放到保险柜中,将保险柜的编号放到盒子中,那么我们每次从盒子中取出来的是这个编号的复制品,但是系统会自动根据这个编号从所在保险柜中的真迹供你欣赏,如果你想涂鸦的话,就真的毁了这副名画。

如果采用基于引用的传递(使用in/ref/out参数,或者针对ref 变量的赋值),相当于我们直接得到了这个盒子。对于值类型,意味着我们得到的也是真迹。不仅如此,我们还可以直接利用一副伪作将其掉包了。如果是值类型,相当于我们将盒子中的真迹换成了赝品。对于引用类型,我们先将赝品放在另一个保险柜中,将盒子中编号替换成这个保险柜的编号。

老生常谈:值类型 V.S. 引用类型的更多相关文章

  1. EffectiveC#6--区别值类型数据和引用类型数据

    1. 设计一个类型时,选择struct或者class是件简单的小事情,但是,一但你的类型发生了改变, 对所有使用了该类型的用户进行更新却要付出(比设计时)多得多的工作. 2.值类型:无多态但性能佳. ...

  2. C#中 哪些是值类型 哪些是引用类型

    DateTime属于 结构类型,所以是  值类型 在 C#中 简单类型,结构类型,枚举类型是值类型:其余的:接口,类,字符串,数组,委托都是引用类型

  3. 从CLR角度来看值类型与引用类型

    前言 本文中大部分示例代码来自于<CLR via C# Edition3>,并在此之上加以总结和简化,文中只是重点介绍几个比较有共性的问题,对一些细节不会做过深入的讲解. 前几天一直忙着翻 ...

  4. C#中的基元类型、值类型和引用类型

    C# 中的基元类型.值类型和引用类型 1. 基元类型(Primitive Type) 编译器直接支持的类型称为基元类型.基元类型可以直接映射到 FCL 中存在的类型.例如,int a = 10 中的 ...

  5. c#基础系列1---深入理解值类型和引用类型

    "大菜":源于自己刚踏入猿途混沌拾起,自我感觉不是一般的菜,因而得名"大菜",于自身共勉. 不知不觉已经踏入坑已10余年之多,对于c#多多少少有一点自己的认识, ...

  6. <NET CLR via c# 第4版>笔记 第5章 基元类型、引用类型和值类型

    5.1 编程语言的基元类型 c#不管在什么操作系统上运行,int始终映射到System.Int32; long始终映射到System.Int64 可以通过checked/unchecked操作符/语句 ...

  7. 3、二、c# 面向对像编程。类,结构、C# 数据类型(引用类型、值 类型、指针类型)、ref参数与out参数、方法的重载、静态类型与静态成员、继承与多态、委托与事件

    一.类 定义类使用class关键字. <access specifier> class class_name { // member variables 成员变量 <access s ...

  8. 【.Net基础二】浅谈引用类型、值类型和装箱、拆箱

    目前在看CLR via C#,把总结的记下来,索性就把他写成一个系列吧. 1.[.Net基础一] 类型.对象.线程栈.托管堆运行时的相互关系 2.[.Net基础二]浅谈引用类型.值类型和装箱.拆箱 引 ...

  9. c#中的引用类型和值类型

    一,c#中的值类型和引用类型 众所周知在c#中有两种基本类型,它们分别是值类型和引用类型:而每种类型都可以细分为如下类型: 什么是值类型和引用类型 什么是值类型: 进一步研究文档,你会发现所有的结构都 ...

  10. 重温CLR(四)基元类型、引用类型、值类型

    编程语言的基元类型 编译器直接支持的数据类型称为基元类型(primitive type).基元类型直接映射到framework类型(fcl)中存在的类型. 下表列出fcl类型 从另一个角度,可以认为C ...

随机推荐

  1. Redis为什么能抗住10万并发?揭秘性能优越的背后原因

    1. Redis简介 Redis是一个开源的,基于内存的,高性能的键值型数据库.它支持多种数据结构,包含五种基本类型 String(字符串).Hash(哈希).List(列表).Set(集合).Zse ...

  2. C++核心知识回顾(函数&参数、异常、动态分配)

    复习C++的核心知识 函数与参数 传值参数.模板函数.引用参数.常量引用参数 传值参数 int abc(int a,int b,int c) { return a + b * c; } a.b.c是函 ...

  3. 谷歌浏览器配置vue调试工具

    1.下载调试工具 下载地址:Vue Devtools_6.1.4_chrome扩展插件下载_极简插件 点击推荐下载 2.解压下载的压缩文件: 3.打开chrome浏览器,进入chrome://exte ...

  4. 华为云新一代iPaaS全域融合集成平台全新升级

    摘要:基于华为十多年的数字化转型实践,华为云通过组装式交付.数智驱动.DevOps.服务化架构.安全可信.韧性6大关键技术助力客户实现应用现代化和高质量增长,华为云新一代iPaaS全域融合集成平台RO ...

  5. day04-商家查询缓存03

    功能02-商铺查询缓存03 3.功能02-商铺查询缓存 3.6封装redis工具类 3.6.1需求说明 基于StringRedisTemplate封装一个工具列,满足下列需求: 方法1:将任意Java ...

  6. C# 闭包类对弱引用的坑

    闭包.弱引用的简单概念,大佬们描述的很多,有不了解的可以看看: 理解C#中的闭包 - 黑洞视界 - 博客园 (cnblogs.com) C#弱引用(WeakReference) - 简书 (jians ...

  7. python的format方法中文字符输出问题

    format方法的介绍 前言 提示:本文仅介绍format方法的使用和中文的输出向左右和居中输出问题 一.format方法的使用 format方法一般可以解决中文居中输出问题,假如我们设定宽度,当中文 ...

  8. day09-达人探店

    功能04-达人探店 5.功能04-达人探店 5.1发布&查看探店笔记 5.1.1发布探店笔记 探店笔记类似点评网站的评价,往往是图文结合.对应的表有两个: tb_blog:探店笔记表,包含笔记 ...

  9. pngquant 在 Windows 上压缩带中文路径的 png 图片

    pngquant 是一个优秀的 png 压缩工具,但是在 Windows 上不支持目录中带有 unicode 字符(例如中文)的文件.所以要用一个折中的办法(即标准输入)让 pngquant 压缩目录 ...

  10. sourcetree和git无法识别新增文件

    在工程中新建文件,但是git和sourcetree无法识别,我是用的是Xcode添加的文件和图片,全都无法识别.例如,新建一个类文件,.h和.m都是别不出来,但是工程文件显示已经添加相对应的类,所以肯 ...