揭開 C#.NET 官方原始碼 System.Func、System.Action、System.Delegate 及 delegate 關鍵字背後的技術面紗


我們談到 delegate 和 lambda、Func、Action 這幾個字眼時,腦中觀念上總是有些模糊不清,就像是遠方有一片烏雲,雖然遙遠,卻擔心有一天會飄過來。我認為基本觀念很重要,像是delegate/Delegate大小寫代表不同意思,全小寫的delegate是C#關鍵字,Delegate/MulticastDelegate是函式庫類別,Func和Action是BCL預先定義方法簽章的17組委派類型,這34組方法簽章都是泛型函式。另外,官方有些文件存在著一些描述上令人困惑的文字,甚至我聽到有「Func和Action就是繼承自Delegate類別的衍生類別」的奇怪說法在流傳著,基於存在著這些奇點現象,若我們真的想要知道上述觀念是否正確時,最好的方法是直接看原始碼才能見分曉。

本文試著透過官方.NET基礎類別函式庫(BCL)的原始程式碼(Source Code)一窺堂奧,解釋編譯器怎麼編譯一個delegate語法或者怎麼實作,並提出個人見解以饗諸公。

探索System.Func、System.Action、System.Delegate原始碼

二話不說先給兩個官方Source Code網址:

System.Func/System.Action:https://referencesource.microsoft.com/#mscorlib/system/action.cs 
System.Delegate:https://referencesource.microsoft.com/#mscorlib/system/delegate.cs,0dd8585ba1833ad7

坦白說我看不到10秒就看出「貓膩」了, 本文寫下心得,也可以說是依據個人有限知識+腦補的結果,但目的是期望能拋磚引玉,看到更多討論C#語法的觀念文,去幫助新手建立正確的程式根基。


先離題一下,談談這篇文章的濫觴,

隨著科技進步,現在的程式設計師跟以前寫程式的人的相比,有著很大的不同。

早期的程式設計師撰寫低階或中階語言,仰賴自身對計算機各種運算單元和IO知識,也要對資料結構的演算方法有足夠的學習,程式實體只有程式人員寫的語法和電腦機器碼兩種。

現代的程式設計師進行程式寫作是否能有高品質的程式碼產出,端依賴對該語言的關鍵字和類別方法的熟捻操控,以及是否能使用精準的語法(適當引用需要的型別類別、知識正確的邏輯表達式)。

一方面.NET可靠的開發編譯工具,愈來愈抽象的語法,讓我們可以更快寫出能執行出正確的資料流程、程式流程的程式碼。換句話說,不管語法如何高階抽象,我們都相信編譯器會正確的,按照我們語法所想像的功能,產生所需組件(可執行程式.exe或動態連結函式庫.dll)。

如此,現在和未來的我們可以不斷學習掌握語言的技巧,盡情用於表達抽象的概念,量產創意,不必處理屬於計算機概論裡的難題。

至於編譯器自動化的內部行為究竟如何,對於只想學習「物件導向程式」編寫技巧的大部分人來說,研究編譯器的轉譯結果反倒不是首要之務。


好,言歸正傳,

承上,我認為好的語言學習順序,最優先的是去學習一個語言的語法撰寫標準。同時,官方提供能告訴程式員所有顯式語法錯誤和解決錯誤的診斷工具,甚至有提示輸入和自動完成這些人性化裝備,其主要目的,是期望使人輕鬆快樂(專注邏輯,提高產能)的學習語言結構。對初學者而言,不需去疑神疑鬼懷疑黑盒子裡隱晦的東西,.NET絕對是語言教學的好環境。

可是,當我們學了高階的語言之後,一陣子後一定會再學另一種語言(相信我),常常會學得很慢,可有些人卻會學得很快,為什麼?因為,所有的語言的技術底層都是對x86處理器的指令集對應實作出來的資料處理技術,講快一點就是,低階的電腦知識懂越多,在這個資訊世界學什麼都快。

總之:越是隱晦的東西越多低階的電腦知識。

何謂「隱晦的東西」呢?譬如你在某個語句使用了類型A或方法A來構造你的語法, 但編譯器為了實現語句,會隱晦的進行一些類別轉換或記憶體配置,結果在轉譯中常常將你的流暢語法轉換為更真實反映基層物件的操作邏輯,置換n個類型X或方法X之後才進行最後編譯中間語言的工作,這就是編譯器檯面下的辛苦活兒。

換句話說,當你引用關鍵字delegate定義一個委派類型的參考指標時,後面"語句"一定是要是函式表達式,編譯器才能進行編譯。基於LINQ使用lambda,C# 3.0 需要進化成擁有一級(first-class)函式的語言,於是.NET就設計了34(17*2)組公版的方法簽章,這些簽章都是泛型函式類型,封裝成名為Func和Action可用委派類型,供任何程式宣告。當然,最主要還是成為Lambda被轉譯時隱含轉換的內定委派類型,想了解關於Lambda被轉譯的事,請參考我另一篇文章【從Lambda語法來探討.NET LINQ的技術底蘊到底在哪裡?】有詳細解說。

編譯器對宣告成delegate關鍵字的函式指標變數,會進行會必要的轉換工作(包含執行時期必要的服務物件的產生和資源分配)。若使用inline語法(lambda)則此函式指標變數的簽章類型就隱含轉換為語句左邊的函式或泛型Func/Action簽章類型。

上述這些知識可能都在我們的意料之外。不過,這些程序雖然重要卻也無關宏旨,反正你不用進廚房就可以燒一手好菜。

不過,煮了一手好菜卻說不出菜名可就會很尷尬。這裡建議平常提到委派兩個字都是應該做名詞用,講述BCL成員時則應該使用英文讓人知道是在說關鍵字和類別,才能避免閱讀混淆,討論才有交集。例如:「委派是一種定義簽章的類型, 也就是方法的傳回數值型別和參數清單類型。 您可以使用委派類型來宣告變數, 其可參考具有與委派相同簽章的任何方法。」(EventHandler Delegate)

先消化一下我們再繼續往下走 ...


一個語言要實現委派機制就需要動態繫結能力,.NET負責這部分功能的BCL是System.Reflection這個命名空間裡的所有類別,而其中最基礎的常識不外乎「反映(射)」 、「組件清單」、「參考指標」這些技術工具的實作類別。

動態繫結(Dynamic Binding)

反映(射) 、System.Reflection

要提【動態繫結】這個概念就得先複習一下「反映(射)」這個技術和甚麼叫「繫結」,這個概念透過反映(射)成為可能,根據說明:「反映(射)會提供語言編譯器所使用的基礎結構,以實作隱含晚期繫結。 繫結是尋找對應至唯一指定的類型宣告 (也就是實作) 的程序。 當此程序發生在執行階段,而不是在編譯時期時,它稱為晚期繫結。
...略
除了編譯器隱含地用來進行晚期繫結,反映也可明確地用於程式碼,來完成晚期繫結。」
所有的反映(射)功能的類別和API都在 System.Reflection 命名空間裡。

組件清單(Assembly Manifest)

編譯器在編譯到函式參考的時候,由於在執行時期run-time才會分配記憶位址,由於不能確定函式在整個動態函示庫或執行檔的相對位址,所以會等到執行程式時才把正確位址給繫結上去。繫結工作需要利用反射取得「執行時期型別」,反射時需要的組件資訊會存放在資訊清單(PE),由System.Reflection.Assambly類別去讀取。


每個程序集(無論是靜態程序集還是動態程序集)都包含一組數據,這些數據描述了程序集中的元素如何相互關聯。程序集清單包含此程序集元數據。程序集清單包含指定程序集的版本要求和安全性標識所需的所有元數據,以及定義程序集範圍並解析對資源和類的引用所需的所有元數據。程序集清單可以存儲在帶有Microsoft中間語言(MSIL)代碼的PE文件(.exe或.dll)中,也可以存儲在僅包含程序集清單信息的獨立PE文件中。

IntPtr Struct

這裡介紹IntPtr Struct(參考指標), IntPtr是一個安全的managed指標,它是實質型別的(ValueType)。

理論上我們希望程式中就可以調用IntPtr如同調用函式物件才對,但因為是只有位址,要Invoke一個Method需要進一步得到型別資訊,過程需要依賴反射來達成,由於反射過程繁瑣艱澀,於是.NET設計一個專門的物件來簡化操作,提供操控IntPtr的方法和管理多個IntPtr的能力,這個物件就是System.Delegate。


System.Delegate 委派抽象類別

System.Delegate被設計成一個專司處理反射及動態繫結工作的抽象物件,內含陣列可以收容管理所有符合該指定類型的代理函式。

而我們那個具有方法簽章的函式編譯出來後的編譯資訊和被IntPtr被"消化"並儲存到一個同時建立System.Delegate實例物件裡面,System.Delegate可以同時儲存很多組符合共變(協變)反變(逆變)簽章的函式參考指標IntPtr。

簡單的說:一般自訂委派類型和Func和Action委派類型,在執行初期時將編譯的函式參考存放到System.Delegate實例陣列中代管。


函式的動態繫結萬事俱備,某個重要功能已在頻頻揮手,沒錯!


再來,介紹System.MulticastDelegate和EventHandler這兩個Delegate的衍生類別,一樣先奉上官方Source Code網址:
System.MulticastDelegatehttps://referencesource.microsoft.com/#mscorlib/system/multicastdelegate.cs
System.EventHandler
https://referencesource.microsoft.com/#mscorlib/system/eventhandler.cs,3b79d2b06c15f250

(先聲明這些原始碼我沒多大時間去仔細研究,為避免腦補太嚴重,我不提細節,有興趣的請自行研究;)


事件驅動的工作委派代理函式

好酒沉甕底,今天這篇文章的重頭戲來了~

繼續腦補(誤);事件驅動程式機制主要就是將System.Delegate收集好的這些代理函式在事件被引發時依序傳遞target提供的EventArgs類別物件裡的資料給這些代理函式並執行這些函式,函式執行完就又回到下一個函式執行,直到所有函式參考指標IntPtr都調用完畢。

當事件被引發時,負責廣播事件的工作.NET交給了System.MulticastDelegate來處理,System.MulticastDelegate繼承了基底類別System.Delegate。

System.MulticastDelegate會使用繼承自基底類別System.Delegate的一些函式並重定義虛擬函式進行反映(射),取得代理函式的System.RuntimeType,以便能夠去繫結Method方法到工作列表之類介面,提供綁定之事件的調用,相信這裡需要一些狀態管理。

由於我沒有打算繼續深入研究原始碼,所以,只能猜測同樣是System.Delegate的衍生類別的EventHandler是不是就是System.MulticastDelegate逆變而成(我猜是),因為兩者都是Delegate的衍生類別。基於此,編譯器是否如同delegate關鍵字處理方式一樣隱晦的進行轉譯event這個關鍵字所需的建設工作呢?相信原理大同小異。

結論

整個委派的機制與編譯過程如何運作,透過System.Delegate原始碼中某些關鍵程式資訊的發現,證明了編譯器將一些我們語法中不存在且並不熟悉的System.MulticastDelegate物件會被創建並實例化,相信它會與程式介面頻繁的交互作用,負責管理繫結/替換/執行所保管的函式參考指標的對象函式,並傳遞事件及資源給所有代理函式。
剖析到這裡,關於委派技術的脈絡差不多寫完了。

佳句分享:【不疾而速,不行而至】(其他)(白話文)

感謝您撥冗閱讀,歡迎留言表達您的看法。

留言

這個網誌中的熱門文章

從Lambda語法來探討.NET LINQ的技術底蘊到底在哪裡?

C# 物件屬性的建構賦值與初始化 - C# constructor and object initializer