C# 中的 ref 已經被放開,或許你已經不認識了

一線碼農發表於2020-11-07

一:背景

1. 講故事

最近在翻 netcore 原始碼看,發現框架中有不少的程式碼都被 ref 給修飾了,我去,這還是我認識的 ref 嗎?就拿 Span 來說,程式碼如下:


    public readonly ref struct Span<T>
    {
        public ref T GetPinnableReference()
        {
            ref T result = ref Unsafe.AsRef<T>(null);
            if (_length != 0)
            {
                result = ref _pointer.Value;
            }
            return ref result;
        }

        public ref T this[int index]
        {
            get
            {
                return ref Unsafe.Add(ref _pointer.Value, index);
            }
        }             
    }

是不是到處都有 ref,在 struct 上有,在 local variable 也有,在 方法簽名處 也有,在 方法呼叫處 也有,在 屬性 上也有, 在 return處 也有,簡直是應有盡有,太??啦,那這一篇我們就來聊聊這個奇葩的 ref。

二:ref 各場景下的程式碼解析

1. 動機

不知道大家有沒有發現,在 C# 7.0 之後,語言團隊對效能這一塊真的是前所未有的重視,還專門為此出了各種類和底層支援,比如說 Span, Memory,ValueTask,還有本篇要介紹的ref。

在大家傳統的認知中 ref 是用在方法引數上,用於給 值型別 做引用傳值,一個是為了大家業務上需要多次原地修改的情況,二個是為了避免值型別的copy引發的效能開銷,不知道是哪一位大神腦洞大開,將 ref 應用在你所知道的程式碼各處,最終目的都是儘可能的提升效能。

2. ref struct 分析

從小就被教育 值型別分配在棧上,引用型別是在堆上,這話也是有問題的,因為值型別也可以分配在堆上,比如下面程式碼的 Location。


    public class Program
    {
        public static void Main(string[] args)
        {
            var person = new Person() { Name = "張三", Location = new Point() { X = 10, Y = 20 } };

            Console.ReadLine();
        }
    }

    public class Person
    {
        public string Name { get; set; }

        public Point Location { get; set; }  //分配在堆上
    }

    public struct Point
    {
        public int X { get; set; }
        public int Y { get; set; }
    }

其實這也是很多新手朋友學習值型別疑惑的地方,可以用 windbg 到託管堆找一下 Person 問問看,如下程式碼:


0:000> !dumpheap -type Person
         Address               MT     Size
0000010e368aadb8 00007ffaf50c2340       32     

0:000> !do 0000010e368aadb8
Name:        ConsoleApp2.Person
MethodTable: 00007ffaf50c2340
EEClass:     00007ffaf50bc5e8
Size:        32(0x20) bytes
File:        E:\net5\ConsoleApp1\ConsoleApp2\bin\Debug\netcoreapp3.1\ConsoleApp2.dll
Fields:
              MT    Field   Offset                 Type VT     Attr            Value Name
00007ffaf5081e18  4000001        8        System.String  0 instance 0000010e368aad98 <Name>k__BackingField
00007ffaf50c22b0  4000002       10    ConsoleApp2.Point  1 instance 0000010e368aadc8 <Location>k__BackingField

0:000> dp 0000010e368aadc8
0000010e`368aadc8  00000014`0000000a 00000000`00000000

上面程式碼最後一行 00000014`0000000a 中的 14 和 a 就是 y 和 x 的值,穩穩當當的存放在堆中,如果你還不信就看看 gc 0代堆的範圍。


0:000> !eeheap -gc
Number of GC Heaps: 1
generation 0 starts at 0x0000010E368A1030
generation 1 starts at 0x0000010E368A1018
generation 2 starts at 0x0000010E368A1000
ephemeral segment allocation context: none
         segment             begin         allocated              size
0000010E368A0000  0000010E368A1000  0000010E368B55F8  0x145f8(83448)

從最後一行可看出,剛才的 0000010e368aadc8 確實是在 0 代堆 0x0000010E368A1030 - 0000010E368B55F8 的範圍內。

接下來的問題就是能不能給 struct 做一個限制,就像泛型約束一樣,不準 struct 分配在堆上,有沒有辦法呢? 辦法就是加一個 ref 限定即可,如下圖:

從錯誤提示中可以看出,有意讓 struct 分配到堆上的操作都是嚴格禁止的,要想過編譯器只能將 class person 改成 ref struct person,也就是文章開頭 Span 和 this[int index] 這樣,動機可想而知,一切都是為了效能。

3. ref method 分析

給方法的引數傳引用地址,我想很多朋友都已經輕車熟路了,比如下面這樣:


        public static int GetNum(ref int i)
        {
            return i;
        }

現在大家可以試著跳出思維定勢,既然可以往方法內仍 引用地址 ,那能不能往方法外拋 引用地址 呢? 如果這也能實現就比較有意思了,我可以對集合內的某一些資料進行引用地址返回,在方法外照樣可以修改這些返回值,畢竟傳來傳去都是引用地址,如下程式碼所示:


    public class Program
    {
        public static void Main(string[] args)
        {
            var nums = new int[3] { 10, 20, 30 };

            ref int num = ref GetNum(nums);

            num = 50;

            Console.WriteLine($"nums= {string.Join(",",nums)}");

            Console.ReadLine();
        }

        public static ref int GetNum(int[] nums)
        {
            return ref nums[2];
        }
    }

可以看到,陣列的最後一個值已經由 30 -> 50 了,有些朋友可能會比較驚訝,這到底是怎麼玩的,不用想就是引用地址到處漂,不信的話,看看 IL 程式碼咯。


.method public hidebysig static 
	int32& GetNums (
		int32[] nums
	) cil managed 
{
	// Method begins at RVA 0x209c
	// Code size 13 (0xd)
	.maxstack 2
	.locals init (
		[0] int32&
	)

	// {
	IL_0000: nop
	// return ref nums[2];
	IL_0001: ldarg.0
	IL_0002: ldc.i4.2
	IL_0003: ldelema [System.Runtime]System.Int32
	IL_0008: stloc.0
	// (no C# code)
	IL_0009: br.s IL_000b

	IL_000b: ldloc.0
	IL_000c: ret
} // end of method Program::GetNums

.method public hidebysig static 
	void Main (
		string[] args
	) cil managed 
{
	IL_0013: ldloc.0
	IL_0014: call int32& ConsoleApp2.Program::GetNums(int32[])
	IL_0019: stloc.1
	IL_001a: ldloc.1
	IL_001b: ldc.i4.s 50
	IL_003e: pop
	IL_003f: ret
} // end of method Program::Main


可以看到,到處都是 & 取值運算子,更直觀一點的話用 windbg 看一下。


0:000> !clrstack -a
OS Thread Id: 0x7040 (0)
000000D4E777E760 00007FFAF1C5108F ConsoleApp2.Program.Main(System.String[]) [E:\net5\ConsoleApp1\ConsoleApp2\Program.cs @ 28]
    PARAMETERS:
        args (0x000000D4E777E7F0) = 0x00000218c9ae9e60
    LOCALS:
        0x000000D4E777E7C8 = 0x00000218c9aeadd8
        0x000000D4E777E7C0 = 0x00000218c9aeadf0

0:000> dp 0x00000218c9aeadf0
00000218`c9aeadf0  00000000`00000032 00000000`00000000

上面程式碼處的 0x00000218c9aeadf0 就是 num 的引用地址,繼續用 dp 看一下這個地址上的值為 16進位制的32,也就是十進位制的 50 哈。

三:總結

總的來說,netcore 就是在當初盛行的 雲端計算 和 虛擬化 時代誕生,基因和使命促使它必須要優化優化再優化,再小的螞蟻也是肉,最後就是 C# 大法 ??

更多高質量乾貨:參見我的 GitHub: dotnetfly

圖片名稱

相關文章