C# 范型約束 new() 你必須要知道的事

C# 范型約束 new() 你必須要知道的事

注意:本文不會講范型如何使用,關於范型的概念和范型約束的使用請移步谷歌。

本文要講的是關於范型約束無參構造函數 new 的一些底層細節和注意事項。寫這篇文章的原因也是因為看到 github 上,以及其他地方看到的代碼都是那麼寫的,而我一查相關的資料,發現鮮有人提到這方面的細節,所以才有了此文。

這裡我先直接拋出一段代碼,請大家看下這段代碼有什麼問題?或者說能說出什麼問題?

public static T CreateInstance<T>() where T: new() => new T();

先不要想這種寫法的合理性(實際上很多人都會諸如此類的這麼寫,無非就是中間多了一些業務處理,最後還是會 return new T())。先想一下,然後在看下面的分析。

假設這樣的問題出現在面試上,其實能有很多要考的點。

首先是范型約束的底層細節

如果說我們不知道範型底下到底做了什麼操作,我們也不用急,我們可以用 ILSpy 來看查看一下,代碼片段如下:

.method public hidebysig static 
    !!T CreateInstance<.ctor T> () cil managed 
{
    // Method begins at RVA 0x2053
    // Code size 6 (0x6)
    .maxstack 8
    
    IL_0000: call !!0 [System.Private.CoreLib]System.Activator::CreateInstance<!!T>()
    IL_0005: ret
} // end of method C::CreateInstance

沒有 ILSpy 的同學可以移步這裡在線查看

在 IL_0000 就能明顯看出范型約束 new() 的底層實現是通過反射來實現的。至於 System.Activator.CreateInstance<T> 方法實現我在這裡就不提了。只知道這裡用的是它就足夠了。不知道大家看到這裡有沒有覺得一絲驚訝,我當時是有被驚到的,因為我的第一想法就是覺得這麼簡單肯定是直接調用無參 .ctor,居然是用到的反射。畢竟編譯器擁有在編譯器就能識別具體的范型類了。現在可以馬後炮的講:正因為是編譯器只有在編譯期才確定具體範型類型,所以編譯器無法事先知道要直接調用哪些無參構造函數類,所以才用到了反射。

如果本文僅僅只是這樣,那我肯定沒有勇氣寫下這片文章的。因為其實已經有人早在 04 年園子里就提到了這一點。但是我查到的資料也就止步於此。

試想一下 ,如果你的框架中有些方法用到了無參構造函數范型約束,並且處於調用的熱路徑上,其實這樣性能是大打折扣的,因為反射 Activator.CreateInstance 性能肯定是遠遠不如直接調用無參構造函數的。

那麼有沒有什麼方法能夠在使用范型約束這個特徵的同時,又不會讓編譯器去用反射呢?

答案肯定是有的,這點我想喜歡動手實驗肯定早就知道了。其實我們可以用到委託來初始化類

范型約束 return new T() 的優化——委託

如果大家對這點都知道的話,可以略過本節(在這裡鼓勵大家可以寫出來造福大家呀,對於這點那些不知道的人(我)要花很長時間才弄清楚 -_-)。

讓我們把上面的例子改成如下方式:

public static Func<Bar> InstanceFactory => () => new Bar();

對於委託的底層相信大家還是都知道的,底層是通過生成一個類 C,在這個類中直接實例化類 Bar。下面我只貼出關鍵的代碼片段

.method public hidebysig specialname static 
    class [System.Private.CoreLib]System.Func`1<class Bar> get_InstanceFactory () cil managed 
{
    // Method begins at RVA 0x205a
    // Code size 32 (0x20)
    .maxstack 8

    IL_0000: ldsfld class [System.Private.CoreLib]System.Func`1<class Bar> C/'<>c'::'<>9__3_0'
    IL_0005: dup
    IL_0006: brtrue.s IL_001f

    IL_0008: pop
    IL_0009: ldsfld class C/'<>c' C/'<>c'::'<>9'
    IL_000e: ldftn instance class Bar C/'<>c'::'<get_InstanceFactory>b__3_0'()
    IL_0014: newobj instance void class [System.Private.CoreLib]System.Func`1<class Bar>::.ctor(object, native int)
    IL_0019: dup
    IL_001a: stsfld class [System.Private.CoreLib]System.Func`1<class Bar> C/'<>c'::'<>9__3_0'

    IL_001f: ret
} // end of method C::get_InstanceFactory

.method assembly hidebysig 
    instance class Bar '<get_InstanceFactory>b__3_0' () cil managed 
{
    // Method begins at RVA 0x2090
    // Code size 6 (0x6)
    .maxstack 8

    IL_0000: newobj instance void Bar::.ctor()
    IL_0005: ret
} // end of method '<>c'::'<get_InstanceFactory>b__3_0'

同樣我們可以通過 ILSpy 或者 在線查看示例 查看委託生成的代碼。

這裡可以明顯看出是不存在反射調用的,IL_000e 處直接調用編譯器生成的類 C 的方法 b__3_0 ,在這個方法中就會直接調用類 Bar 的構造函數。所以性能上絕對要比上種寫法要高得多。

看到這裡可能大家又有新問題了,眾所周知,委託要在初始化時就要確定表達式。所以與此處的范型動態調用是衝突的。的確沒錯,委託必須要在初始化表達式時就要確定類型。但是我們現在已經知道了委託是能夠避免讓編譯器不用反射的,剩下的只是解決動態表達式的問題,毫無疑問表達式樹該登場了。

范型約束 return new T() 的優化——表達式樹

對於這部分已經知道的同學可以跳過本節。

把委託改造成表達式樹那是非常簡單的,我們可以不假思索的寫出下面代碼:

private static readonly Expression<Func<T>> ctorExpression = () => new T();
public static T CreateInstance() where T : new() {
  var func = ctorExpression.Compile();
  return func();
}

到這裡其實就有點」舊酒裝新瓶「的意思了。不過有點要注意的是,如果單純只是表達式樹的優化,從執行效率上來看肯定是不如委託來的快,畢竟表達式樹多了一層構造表達式然後編譯成委託的過程。優化也是有的,再繼續往下講就有點「偏題」了。因為往後其實就是對委託,對表達式樹的性能優化問題。跟范型約束倒沒關係了

總結

其實如果面試真的有問到這個問題的話,其實考的就是對范型約束 new() 底層的一個熟悉程度,然後轉而從反射的點來思考問題的優化方案。因為這可以散發出很多問題,比如性能優化,從直接返回 new T() 到委託,因為委託無法做到動態變化,所以想到了表達式樹。那麼我們繼而也能舉一反三的知道,如果要繼續優化的話,在構造表達式樹時,我們可以用緩存來節省每次調用方法的構造表達式樹的時間(DI 的 CallSite 實現細節就是如此)。如果我們生思熟慮之後還要選擇繼續優化,那麼我們還可以從表達式樹轉到動態生成代碼這一領域,通過編寫 IL 代碼來生成表達式樹,進而緩存下來達到近乎直接調用的性能。這也是為什麼我花了很長時間弄清楚這個的原因。

最後關於代碼

代碼地址在://github.com/MarsonShine/Books/tree/master/WHPerformanceDotNet/src/GenericOptimization
注意:我上傳這一版是下方第一個文章給出的例子的整理之後的版本。文中有很多代碼我都沒貼出來,一是覺得意義不大,重要的是思考過程和實踐過程,還佔文章篇幅。二是還是想讓不知道這些的同學能自己動手編碼自己的版本,最後才看與那些大牛寫的版本的差距在哪,這樣才會更有收穫。

參考資料