internal static class Utility { // 堆代码 duidaima.com 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和s2占据的16个字节存储的就是FoobarStruct这个结构体的荷载内容。那么问题又来了,FoobarStruct结构体包含的两个字段的类型分别是byte和long,对应的字节数分别是1和8,总字节数应该是9个字节才对,多出的7个字节是“内存地址对齐(Alignment)”造成的。
由于要确保Bar字段基于8个字节的内存对齐,虽然Foo字段只需要使用一个字节,也需要添加7个空白字节。具体的内存布局请求参与相关的文档,在这里就不再赘述了。对于引用类型来说,变量与其承载的内容则是“分离”的。引用类型的实例分配在堆上,对应的地址存储在变量占据的栈内存上。x64机器使用8个字节表示内存地址,所以c1和c2这两个变量只占据8个字节就很容易理解了。
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)。
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对象上。
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)}"); }输出结果如下,可以看出变量和对应的参数具有完全不同的内存地址。
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参数究竟有什么区别呢?答案同样是“没有区别”,因为它们传递的就是变量自身的地址罢了(如下所示)。
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)}"); }输出结果:
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参数
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个字节(x64)。
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>$'六、总结