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,居然是用到的反射。畢竟編譯器擁有在編譯器就能識別具體的範型類了。現在可以馬後炮的講:正因為是編譯器只有在編譯期才確定具體範型型別,所以編譯器無法事先知道要直接呼叫哪些無參建構函式類,所以才用到了反射。
如果本文僅僅只是這樣,那我肯定沒有勇氣寫下這片文章的。因為其實已經
試想一下 ,如果你的框架中有些方法用到了無參建構函式範型約束,並且處於呼叫的熱路徑上,其實這樣效能是大打折扣的,因為反射 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 程式碼來生成表示式樹,進而快取下來達到近乎直接呼叫的效能。這也是為什麼我花了很長時間弄清楚這個的原因。
最後關於程式碼
程式碼地址在:https://github.com/MarsonShine/Books/tree/master/WHPerformanceDotNet/src/GenericOptimization
注意:我上傳這一版是下方第一個文章給出的例子的整理之後的版本。文中有很多程式碼我都沒貼出來,一是覺得意義不大,重要的是思考過程和實踐過程,還佔文章篇幅。二是還是想讓不知道這些的同學能自己動手編碼自己的版本,最後才看與那些大牛寫的版本的差距在哪,這樣才會更有收穫。
參考資料
- https://devblogs.microsoft.com/premier-developer/dissecting-the-new-constraint-in-c-a-perfect-example-of-a-leaky-abstraction/
- https://alexandrnikitin.github.io/blog/dotnet-generics-under-the-hood/
- https://www.microsoft.com/en-us/research/wp-content/uploads/2001/01/designandimplementationofgenerics.pdf
- 《編寫高效能.NET程式碼》