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,居然是用到的反射。畢竟編譯器擁有在編譯器就能識別具體的泛型類了。現在可以馬后炮的講:正因為是編譯器只有在編譯期才確定具體泛型類型,所以編譯器無法事先知道要直接調用哪些無參構造函數類,所以才用到了反射。
關於 System.Activator.CreateInstance<T>()
的方法描述,在微軟官網api中的remark部分有提到。
如果本文僅僅只是這樣,那我肯定沒有勇氣寫下這片文章的。因為其實已經有人早在 04 年園子里就提到了這一點。但是我查到的資料也就止步於此。
試想一下 ,如果你的框架中有些方法用到了無參構造函數泛型約束,並且處於調用的熱路徑上,其實這樣性能是大打折扣的,因為反射 Activator.CreateInstance
性能肯定是遠遠不如直接調用無參構造函數的。
注意,我這里說的反射是通俗的概念,因為我找不到CLR內部方法實現的代碼,其實現過程細節有同學陳鑫偉在評論中指出來了。
那么有沒有什么方法能夠在使用泛型約束這個特征的同時,又不會讓編譯器去用反射呢?
答案肯定是有的,這點我想喜歡動手實驗肯定早就知道了。其實我們可以用到委托來初始化類。
泛型約束 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
注意:我上傳這一版是下方第一個文章給出的例子的整理之后的版本。文中有很多代碼我都沒貼出來,一是覺得意義不大,重要的是思考過程和實踐過程,還占文章篇幅。二是還是想讓不知道這些的同學能自己動手編碼自己的版本,最后才看與那些大牛寫的版本的差距在哪,這樣才會更有收獲。
性能測試對比結果
BenchmarkDotNet=v0.12.1, OS=Windows 10.0.18363.592 (1909/November2018Update/19H2)
Intel Core i5-9400 CPU 2.90GHz (Coffee Lake), 1 CPU, 6 logical and 6 physical cores
.NET Core SDK=5.0.100-rc.1.20452.10
[Host] : .NET Core 3.1.8 (CoreCLR 4.700.20.41105, CoreFX 4.700.20.41903), X64 RyuJIT [AttachedDebugger]
DefaultJob : .NET Core 3.1.8 (CoreCLR 4.700.20.41105, CoreFX 4.700.20.41903), X64 RyuJIT
Method | IterationCount | Mean | Error | StdDev |
---|---|---|---|---|
DirectConstructor | 1000 | 265.5 ns | 0.28 ns | 0.25 ns |
GenericConstraintConstructor | 1000 | 34,392.7 ns | 446.07 ns | 417.26 ns |
DelegateConstructor | 1000 | 6,451.6 ns | 103.58 ns | 91.82 ns |
ExpressionTreeConstructor | 1000 | 7,500.2 ns | 75.25 ns | 70.39 ns |
DynamicGenerateCodeConstructor | 1000 | 5,016.4 ns | 49.29 ns | 46.11 ns |
DirectConstructor | 10000000 | 2,576,799.3 ns | 1,416.08 ns | 1,105.58 ns |
GenericConstraintConstructor | 10000000 | 333,104,316.7 ns | 1,737,941.84 ns | 1,356,870.67 ns |
DelegateConstructor | 10000000 | 62,633,360.3 ns | 939,353.97 ns | 832,712.83 ns |
ExpressionTreeConstructor | 10000000 | 74,846,604.8 ns | 689,863.41 ns | 645,298.66 ns |
DynamicGenerateCodeConstructor | 10000000 | 51,316,999.0 ns | 976,672.25 ns | 1,045,028.36 ns |
參考資料
- 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代碼》