還不明白可空類型原理? 我可要挖到底了

一:背景

1. 講故事

下決心做好自媒體到現在有一個月了,關注我的兄弟應該知道我產出了不少文章,號里的粉絲也多起來了,我也盡最大努力做到有問必回,現在是基礎的、高深的問題都接踵而來,可我也只是一隻小菜鳥,想飛也飛不動了(┬_┬),昨天號里有位朋友被面試官問到可空類型的原理,回答的不好,面試官也是,面就面唄,又給不了多少銀子,還動不動就原理,哪有那麼多原理,搞得雙方都尷尬😄😄😄。

二:給我鋤頭我要挖到底

這種問題要怎麼挖呢? 我在之前的文章也聊過,C#程式碼到機器碼中間有兩個編譯過程,一個是csc編譯後的IL程式碼,一個是jit編譯後的native程式碼,所以搞懂IL程式碼和native程式碼就是我們要深究的方向,我還是把那篇文章的圖拿過來。

為了方便演示,我就定義一個int? 類型,接收非null和null兩種情況。


        static void Main(string[] args)
        {
            int? num1 = 10;
            int? num2 = null;

            Console.WriteLine("執行結束啦!");
            Console.ReadLine();
        }

1. 挖IL程式碼

挖IL程式碼簡單,用ILSPY小工具就可以了,編譯後生成的IL程式碼如下:


.method private hidebysig static 
	void Main (
		string[] args
	) cil managed 
{
	// Method begins at RVA 0x2048
	// Code size 36 (0x24)
	.maxstack 2
	.entrypoint
	.locals init (
		[0] valuetype [mscorlib]System.Nullable`1<int32> num1,
		[1] valuetype [mscorlib]System.Nullable`1<int32> num2
	)

	IL_0000: nop
	IL_0001: ldloca.s 0
	IL_0003: ldc.i4.s 10
	IL_0005: call instance void valuetype [mscorlib]System.Nullable`1<int32>::.ctor(!0)
	IL_000a: ldloca.s 1
	IL_000c: initobj valuetype [mscorlib]System.Nullable`1<int32>
	IL_0012: ldstr "執行結束啦!"
	IL_0017: call void [mscorlib]System.Console::WriteLine(string)
	IL_001c: nop
	IL_001d: call string [mscorlib]System.Console::ReadLine()
	IL_0022: pop
	IL_0023: ret
} // end of method Program::Main

這IL程式碼還是非常易懂的,比彙編簡單多啦(┬_┬),可以看到int ? 就是 System.Nullable<int32> ,然後從valuetype 標記可以看到這玩意是個值類型,所以把上面的程式碼迴轉成C#程式碼就是下面這樣。


    {
        static void Main(string[] args)
        {
            //int? num1 = 10;
            //int? num2 = null;

            Nullable<int> num3 = new Nullable<int>(10);
            Nullable<int> num4 = new Nullable<int>();

            Console.WriteLine("執行結束啦!");
            Console.ReadLine();
        }

很簡單吧,那怎麼輸出num3和num4呢? 直接Console.WriteLine就好了。

這裡你肯定有一個疑問,為什麼num3輸出10,而num4什麼都沒輸出呢? 哈哈,這是因為Nullable的ToString()被重寫了,再來看下ToString被重寫成啥樣了,程式碼如下:

public struct Nullable<T> where T : struct
{
   private bool hasValue;

   internal T value;

   [NonVersionable]
   [__DynamicallyInvokable]
   public Nullable(T value)
   {
   	this.value = value;
   	hasValue = true;
   }

   [__DynamicallyInvokable]
   public override string ToString()
   {
   	if (!hasValue)
   	{
   		return "";
   	}
   	return value.ToString();
   }
}

可以看到ToString方法里要麼返回空字元串要麼返回你在構造函數中塞入的value,這這麼簡單,IL程式碼挖到這裡就可以了。

2. 挖機器程式碼

要看num1和num2的機器程式碼,其實也就是看 Nullable<T> 的記憶體布局方式,這裡我使用windbg,還是使用 !clrstack -l 查看執行緒棧。


     int? num1 = 10;
     int? num2 =null;

0:007> ~0s
ntdll!ZwReadFile+0x14:
00007ffc`ec11aa64 c3              ret
0:000> !clrstack -l
OS Thread Id: 0x5364 (0)
        Child SP               IP Call Site
ConsoleApp4.Program.Main(System.String[]) [C:\dream\Csharp\ConsoleApp1\ConsoleApp4\Program.cs @ 21]
    LOCALS:
        0x00000018a9dfeaf8 = 0x0000000a00000001
        0x00000018a9dfeaf0 = 0x0000000000000000

00000018a9dfed08 00007ffcd5b66c93 [GCFrame: 00000018a9dfed08] 

LOCALS中可以看到,num1和num2的執行緒棧上存放的內容分別是0x0000000a000000010x0000000000000000, 不過這值也挺奇怪的,一個是1一個是0。。。我們用 dd 命令把地址轉儲出來。


0:000> dd 0x00000018a9dfeaf8 
00000018`a9dfeaf8  00000001 0000000a a9dfec08 00000018
0:000> dd 0x00000018a9dfeaf0 
00000018`a9dfeaf0  00000000 00000000 00000001 0000000a

在num1的記憶體區域中有一個十六進位值 0000000a ,這就是十進位的10,那前面的 00000001 是什麼東西呢? 大家不要忘啦, int?是語法糖, 你現在看的是 Nullable<T> 哈。。。

看清楚啦,這個結構體裡面有兩個值類型欄位,自然 00000001 就是 hasValue=true啦。 num2也就好理解了,兩個默認值也就是兩個0了。00000000 00000000

三:有意外發現

1. int? 比 int 要佔用更多的記憶體

如果你的記憶體數據量特別大的話,你就要當心了,int? 比 int 在x64上要多佔4個位元組,也就是多一倍,無論執行緒棧還是託管堆。

2. 為什麼bool要佔用 4位元組空間?

<1> 執行緒棧上的演示

肯定有人比較疑惑,bool在C#中不就是一個位元組嘛? 你怎麼說是4個位元組呢? 你要是問我,我只能說從windbg上看就是這樣的,x64系統的執行緒棧上就是以4個位元組為一個單位,你不信的話,我就在程式碼中定義不同欄位的 值類型,你看看在執行緒棧上的分布不就好啦,以事實說話。

           byte b1 = byte.MaxValue;
           byte b2 = byte.MaxValue;
           short b3 = short.MaxValue;
           short b4 = short.MaxValue;
           int b5 = int.MaxValue;
           int b6 = int.MaxValue;

0:000> !clrstack -l
OS Thread Id: 0xa98 (0)
ConsoleApp4.Program.Main(System.String[]) [C:\dream\Csharp\ConsoleApp1\ConsoleApp4\Program.cs @ 25]
   LOCALS:
       0x000000a8395fedbc = 0x00000000000000ff
       0x000000a8395fedb8 = 0x00000000000000ff
       0x000000a8395fedb4 = 0x0000000000007fff
       0x000000a8395fedb0 = 0x0000000000007fff
       0x000000a8395fedac = 0x000000007fffffff
       0x000000a8395feda8 = 0x000000007fffffff

然後把最小的地址0x000000a8395feda8 轉儲出來。


0:000> dd 0x000000a8395feda8
000000a8`395feda8  7fffffff 7fffffff 00007fff 00007fff
000000a8`395fedb8  000000ff 000000ff 395feec8 000000a8
000000a8`395fedc8  395fefc8 000000a8 395fee00 000000a8
000000a8`395fedd8  d5b66c93 00007ffc 98e72d30 000001ee
000000a8`395fede8  76504140 00007ffc 00000000 00000000
000000a8`395fedf8  00000000 00007ffc 395feef0 000000a8
000000a8`395fee08  971d0b20 000001ee 00000000 00000000
000000a8`395fee18  d5b66b79 00007ffc 00000000 00000000

對比一下可以看到上面的 7fffffff, 00007fff,000000ff就是相應的int,short,byte的MaxValue, 都是佔用4個位元組的空間,沒問題吧。

<2> 託管堆演示


    var arr1 = new int[] { 10 };
    var arr2 = new int?[] { 14 };

0:000> !clrstack -l
OS Thread Id: 0x23f8 (0)
000000859a1fec60 00007ffc76630967 ConsoleApp4.Program.Main(System.String[]) [C:\dream\Csharp\ConsoleApp1\ConsoleApp4\Program.cs @ 32]
    LOCALS:
        0x000000859a1feca0 = 0x000002773cb32d70
        0x000000859a1fec98 = 0x000002773cb32d90

000000859a1feeb8 00007ffcd5b66c93 [GCFrame: 000000859a1feeb8] 
0:000> !do 0x000002773cb32d70
Name:        System.Int32[]
MethodTable: 00007ffcd2d58538
EEClass:     00007ffcd2ec5918
Size:        28(0x1c) bytes
Array:       Rank 1, Number of elements 1, Type Int32 (Print Array)
Fields:
None
0:000> !do 0x000002773cb32d90
Name:        System.Nullable`1[[System.Int32, mscorlib]][]
MethodTable: 00007ffcd3fb2058
EEClass:     00007ffcd30221a0
Size:        32(0x20) bytes
Array:       Rank 1, Number of elements 1, Type VALUETYPE (Print Array)
Fields:
None

0:000> !objsize 0x000002773cb32d70
sizeof(000002773cb32d70) = 32 (0x20) bytes (System.Int32[])
0:000> !objsize 0x000002773cb32d90
sizeof(000002773cb32d90) = 32 (0x20) bytes (System.Nullable`1[[System.Int32, mscorlib]][])

可以看到,一個是28byte,一個是32byte,多出來的就是那個hasValue哈,有一點要注意了,用!objsize打出來都是32byte,這是因為28byte要靠8對齊就變成32byte啦, 然後我把兩個值類型轉儲出來,如下圖:

四: 總結

挖到這裡,不知道可挖到了面試官的盲區啦😄,總之int?就是 Nullable<T>, 而且可空比非可空多4個位元組的空間,最後大家要看自己情況使用啦。