.NET 基礎拾遺(4):委託、事件、反射與特性

發表於2015-10-14

一、委託基礎

1.1 簡述委託的基本原理

委託這個概念對C++程式設計師來說並不陌生,因為它和C++中的函式指標非常類似,很多碼農也喜歡稱委託為安全的函式指標。無論這一說法是否正確,委託的的確確實現了和函式指標類似的功能,那就是提供了程式回撥指定方法的機制。

在委託內部,包含了一個指向某個方法的指標(這一點上委託實現機制和C++的函式指標一致),為何稱其為安全的呢?因此委託和其他.NET成員一樣是一種型別,任何委託物件都是繼承自System.Delegate的某個派生類的一個物件,下圖展示了在.NET中委託的類結構:

從上圖也可以看出,任何自定義的委託都繼承自基類System.Delegate,在這個類中,定義了大部分委託的特性。那麼,下面可以看看在.NET中如何使用委託:

執行結果如下圖所示:

上述程式碼中定義了一個名為TestDelegate的新型別,該型別直接繼承自System.MulticastDelegate,而且其中會包含一個名為Invoke、BeginInvoke和EndInvoke的方法,這些步驟都是由C#編譯器自動幫我們完成的,可以通過Reflector驗證一下如下圖所示:

需要注意的是,委託既可以接受例項方法,也可以接受靜態方法(如上述程式碼中接受的就是靜態方法),其區別我們在1.2中詳細道來。最後,委託被呼叫執行時,C#編譯器可以接收一種簡化程式設計師設計的語法,例如上述程式碼中的:td(1)。但是,本質上,委託的呼叫其實就是執行了在定義委託時所生成的Invoke方法。

1.2 委託回撥靜態方法和例項方法有何區別?

首先,我們知道靜態方法可以通過類名來訪問而無需任何例項物件,當然在靜態方法中也就不能訪問型別中任何非靜態成員。相反,例項方法則需要通過具體的例項物件來呼叫,可以訪問例項物件中的任何成員。

其次,當一個例項方法被呼叫時,需要通過例項物件來訪問,因此可以想象當繫結一個例項方法到委託時必須同時讓委託得到例項方法的程式碼段和例項物件的資訊,這樣在委託被回撥的時候.NET才能成功地執行該例項方法。

下圖展示了委託內部的主要結構:

① _target是一個指向目標例項的引用,當繫結一個例項方法給委託時,該引數會作為一個指標指向該方法所在型別的一個例項物件。相反,當繫結一個靜態方法時,該引數則被設定為null。

② _methodPtr則是一個指向繫結方法程式碼段的指標,這一點和C++的函式指標幾乎一致。繫結靜態方法或例項方法在這個成員的設定上並沒有什麼不同。

System.MulticastDelegate在內部結構上相較System.Delegate增加了一個重要的成員變數:_prev,它用於指向委託鏈中的下一個委託,這也是實現多播委託的基石。

1.3 神馬是鏈式委託?

鏈式委託也被稱為“多播委託”,其本質是一個由多個委託組成的連結串列。回顧上面1.2中的類結構,System.MulticastDelegate類便是為鏈式委託而設計的。當兩個及以上的委託被連結到一個委託鏈時,呼叫頭部的委託將導致該鏈上的所有委託方法都被執行。

下面看看在.NET中,如何申明一個鏈式委託:

其執行結果如下圖所示:

可以看到,呼叫頭部的委託導致了所有委託方法的執行。通過前面的分析我們也可以知道:為委託+=增加方法以及為委託-=移除方法讓我們看起來像是委託被修改了,其實它們並沒有被修改。事實上,委託是恆定的。在為委託增加和移除方法時實際發生的是建立了一個新的委託,其呼叫列表是增加和移除後的方法結果。

另一方面,+= 或-= 這是一種簡單明瞭的寫法,回想在WindowsForm或者ASP.NET WebForms開發時,當新增一個按鈕事件,VS便會自動為我們生成類似的程式碼,這樣一想是不是又很熟悉了。

現在,我們再用一種更簡單明瞭的方法來寫:

其執行結果與上圖一致,只不過C#編譯器的智慧化已經可以幫我們省略了很多程式碼。

最後,我們要用一種比較複雜的方法來寫,但是卻是鏈式委託的核心所在:

我們在實際開發中經常使用第二種方法,但是卻不能不瞭解方法三,它是鏈式委託的本質所在。

1.4 鏈式委託的執行順序是怎麼樣的?

前面我們已經知道鏈式委託的基本特性就是一個以委託組成的連結串列,而當委託鏈上任何一個委託方法被呼叫時,其後面的所有委託方法都將會被依次地順序呼叫。那麼問題來了,委託鏈上的順序是如何形成的?這裡回顧一下上面1.3中的示例程式碼,通過Reflector反編譯一下,一探究竟:

從編譯後的結果可以看到,+=的本質又是呼叫了Delegate.Combine方法,該方法將兩個委託連結起來,並且把第一個委託放在第二個委託之前,因此可以將兩個委託的相加理解為Deletegate.Combine(Delegate a,Delegate b)的呼叫。我們可以再次回顧System.MulticastDelegate的類結構:

其中_prev成員是一個指向下一個委託成員的指標,當某個委託被連結到當前委託的後面時,該成員會被設定為指向那個後續的委託例項。.NET也是依靠這一個引用來逐一找到當前委託的所有後續委託並以此執行方法。

那麼,問題又來了?程式設計師能夠有能力控制鏈式委託的執行順序呢?也許我們會說,只要在定義時按照需求希望的順序來依次新增就可以了。但是,如果要在定義完成之後突然希望改變執行順序呢?又或者,程式需要按照實際的執行情況再來決定鏈式委託的執行順序呢?

接下來就是見證奇蹟的時刻:

上述程式碼呼叫了定義在System.MulticastDelegate中的GetInvocationList()方法,用以獲得整個鏈式委託中的所有委託。接下來,我們就可以按照我們所希望的順序去執行它們。

1.5 可否定義有返回值方法的委託鏈?

委託的方法既可以是無返回值的,也可以是有返回值的,但如果多一個帶返回值的方法被新增到委託鏈中時,我們需要手動地呼叫委託鏈上的每個方法,否則只能得到委託鏈上最後被呼叫的方法的返回值。

為了驗證結論,我們可以通過如下程式碼進行演示:

其執行結果如下圖所示:

從上圖可以看到,雖然委託鏈中的所有方法都被正確執行,但是我們只得到了最後一個方法的返回值。在這種情況下,我們應該如何得到所有方法的返回值呢?回顧剛剛提到的GetInvocationList()方法,我們可以利用它來手動地執行委託鏈中的每個方法。

通過上述程式碼,委託鏈中每個方法的返回值都不會丟失,下圖是執行結果:

1.6 簡述委託的應用場合

委託的功能和其名字非常類似,在設計中其思想在於將工作委派給其他特定的型別、元件、方法或程式集。委託的使用者可以理解為工作的分派者,在通常情況下使用者清楚地知道哪些工作需要執行、執行的結果又是什麼,但是他不會親自地去做這些工作,而是恰當地把這些工作分派出去。

這裡,我們假設要寫一個日誌子系統,該子系統的需求是使用者希望的都是一個單一的方法傳入日誌內容和日誌型別,而日誌子系統會根據具體情況來進行寫日誌的動作。對於日誌子系統的設計者來說,寫一條日誌可能需要包含一系列的工作,而日誌子系統決定把這些工作進行適當的分派,這時就需要使用一個委託成員。

下面的程式碼展示了該日誌子系統的簡單實現方式:

① 定義列舉:日誌的類別

② 定義委託,由日誌使用者直接執行來完成寫日誌的工作

③ 定義日誌管理類,在構造方法中為記錄日誌委託定義了預設的邏輯(這裡採用了部分類的書寫,將各部分的委託方法分隔開,便於理解)

日誌管理類定義了一些列符合Log委託的方法,這些方法可以被新增到記錄日誌的委託物件之中,以構成整個日誌記錄的動作。在日後的擴充套件中,主要的工作也集中在新增新的符合Log委託定義的方法,並且將其新增到委託鏈上。

④ 在Main方法中呼叫LogManager的Log委託例項來寫日誌,LogManager只需要管理這個委託,負責分派任務即可。

程式碼中初始化委託成員的過程既是任務分派的過程,可以注意到LogManager的UseUTCTime和UseLocalTime方法都是被委託成員進行了重新的分配,也可以理解為任務的再分配。

下圖是上述程式碼的執行結果,將日誌資訊寫入了C:\TestLog.txt中:

 

二、事件基礎

事件這一名稱對於我們.NET碼農來說肯定不會陌生,各種技術框架例如WindowsForm、ASP.NET WebForm都會有事件這一名詞,並且所有的定義都基本相同。在.NET中,事件和委託在本質上並沒有太多的差異,實際環境下事件的運用卻比委託更加廣泛。

2.1 簡述事件的基本使用方法

在Microsoft的產品文件上這樣來定義的事件:事件是一種使物件或類能夠提供通知的成員。客戶端可以通過提供事件處理程式為相應的事件新增可執行程式碼。設計和使用事件的全過程大概包括以下幾個步驟:

下面我們來按照規範的步驟來展示一個通過控制檯輸出事件的使用示例:

① 定義一個控制檯事件ConsoleEvent的引數型別ConsoleEventArgs

② 定義一個控制檯事件的管理者,在其中定義了事件型別的私有成員ConsoleEvent,並定義了事件的傳送方法SendConsoleEvent

③ 定義了事件的訂閱者Log,在其中通過控制檯時間的管理類公開的事件成員訂閱其輸出事件ConsoleEvent

④ 在Main方法中進行測試:

當該程式執行時,ConsoleManager負責在控制檯輸出測試的字串訊息,與此同時,訂閱了控制檯輸出事件的Log類物件會在指定的日誌檔案中寫入這些字串訊息。可以看出,這是一個典型的觀察者模式的應用,也可以說事件為觀察者模式提供了便利的實現基礎。

2.2 事件和委託有神馬聯絡?

事件的定義和使用方式與委託極其類似,那麼二者又是何關係呢?經常聽人說,委託本質是一個型別,而事件本質是一個特殊的委託型別的例項。關於這個解釋,最好的辦法莫過於通過檢視原始碼和編譯後的IL程式碼進行分析。

① 回顧剛剛的程式碼,在ConsoleManager類中定義了一個事件成員

EventHandler是.NET框架中提供的一種標準的事件模式,它是一個特殊的泛型委託型別,通過檢視後設資料可以驗證這一點:

正如上面程式碼所示,我們定義一個事件時,實際上是定義了一個特定的委託成員例項。該委託沒有返回值,並且有兩個引數:一個事件源和一個事件引數。而當事件的使用者訂閱該事件時,其本質就是將事件的處理方法加入到委託鏈之中。

② 下面通過Reflector來檢視一下事件ConsoleEvent的IL程式碼(中間程式碼),可以更方便地看到這一點:

首先,檢視EventHandler的IL程式碼,可以看到在C#編譯器編譯delegate程式碼時,編譯後是成為了一個class。

其次,當C#編譯器編譯event程式碼時,會首先為型別新增一個EventHandler<T>的委託例項物件,然後為其增加一對add/remove方法用來實現從委託鏈中新增和移除方法的功能。

通過檢視add_ConsoleEvent的IL程式碼,可以清楚地看到訂閱事件的本質是呼叫Delegate的Combine方法將事件處理方法繫結到委託鏈中。

Summary:事件是一個特殊的委託例項,提供了兩個供訂閱事件和取消訂閱的方法:add_event和remove_event,其本質都是基於委託鏈來實現。

2.3 如何設計一個帶有很多事件的型別?

多事件的型別在實際應用中並不少見,尤其是在一些使用者介面的型別中(例如在WindowsForm中的各種控制元件)。這些型別動輒將包含數十個事件,如果為每一個事件都新增一個事件成員,將導致無論使用者是否用到所有事件,每個型別物件都將佔有很大的記憶體,那麼對於系統的效能影響將不言而喻。事實上,.NET的開發小組運用了一種比較巧妙的方式來避免這一困境。

Solution:當某個型別具有相對較多的事件時,我們可以考慮顯示地設計訂閱、取消訂閱事件的方法,並且把所有的委託連結串列儲存在一個集合之中。這樣做就能避免在型別中定義大量的委託成員而導致型別過大。

下面通過一個具體的例項來說明這一設計:

① 定義包含大量事件的型別之一:使用EventHandlerList成員來儲存所有事件

② 定義包含大量事件的型別之二:申明多個具體的事件

③ 定義事件的訂閱者(它對多事件型別內部的構造一無所知)

④ 編寫入口方法來測試多事件的觸發

最終執行結果如下圖所示:

總結EventHandlerList的用法,在多事件型別中為每一個事件都定義了一套成員,包括事件的委託原型、事件的訂閱和取消訂閱方法,在實際應用中,可能需要定義事件專用的引數型別。這樣的設計主旨在於改動包含多事件的型別,而訂閱事件的客戶並不會察覺這樣的改動。設計本身不在於減少程式碼量,而在於有效減少多事件型別物件的大小。

2.4 如何使用事件模擬場景:貓叫->老鼠逃跑 & 主人驚醒

這是一個典型的觀察者模式的應用場景,事件的發源在於貓叫這個動作,在貓叫之後,老鼠開始逃跑,而主人則會從睡夢中驚醒。可以發現,主人和老鼠這兩個型別的動作相互之間沒有聯絡,但都是由貓叫這一事件觸發的。

設計的大致思路在於,貓類包含並維護一個貓叫的動作,主人和老鼠的物件例項需要訂閱貓叫這一事件,保證貓叫這一事件發生時主人和老鼠可以執行相應的動作。

(1)設計貓類,為其定義一個貓叫的事件CatCryEvent:

(2)設計老鼠類,在其構造方法中訂閱貓叫事件,並提供對應的處理方法

(3)設計主人類,在其構造犯法中訂閱貓叫事件,並提供對應的處理方法

(4)最後在Main方法中進行場景的模擬:

這裡定義了一隻貓,兩隻老鼠與一個主人,當貓的CatCry方法被執行到時,會觸發貓叫事件CatCryEvent,此時就會通知所有這一事件的訂閱者。本場景的關鍵之處就在於主人和老鼠的動作應該完全由貓叫來觸發。下面是場景模擬程式碼的執行結果:

三、反射基礎

3.1 反射的基本原理是什麼?其實現的基石又是什麼?

反射是一種動態分析程式集、模組、型別及欄位等目標物件的機制,它的實現依託於後設資料。後設資料,就是描述資料的資料。在CLR中,後設資料就是對一個模組定義或引用的所有東西的描述系統。

3.2 .NET中提供了哪些型別實現反射?

在.NET中,為我們提供了豐富的可以用來實現反射的型別,這些型別大多數都定義在System.Reflection名稱空間之下,例如Assembly、Module等。利用這些型別,我們就可以方便地動態載入程式集、模組、型別、方法和欄位等元素。

下面我們來看一個使用示例,首先是建立一個程式集SimpleAssembly,其中有一個類為SimpleClass:

其次是對程式集中的模組進行分析,分別利用反射對程式集、模組和類進行分析:

最後編寫入口方法來嘗試分析一個具體的程式集:

上面的程式碼按照 程式集->模組->型別 三個層次的順序來動態分析一個程式集,當然還可以繼續遞迴型別內部的成員,最後通過CreateInstance方法來動態建立了一個型別,這些都是反射經常被用來完成的功能,執行結果如下圖所示:

3.3 如何使用反射實現工廠模式?

工廠模式是一種比較常用的設計模式,其基本思想在於使用不同的工廠型別來打造不同產品的部件。例如,我們在打造一間屋子時,可能需要窗戶、屋頂、門、房樑、柱子等零部件。有的屋子需要很多根柱子,而有的屋子又不需要窗戶。在這樣的需求下,就可以使用工廠模式。

(1)工廠模式的傳統實現和其弊端

下圖展示了針對屋子設計的傳統工廠模式架構圖:

上圖的設計思路是:

①使用者告訴工廠管理者需要哪個產品部件;

②工廠管理者分析使用者傳入的資訊,生成合適的實現工廠介面的型別物件;

③通過工廠生產出相應的產品,返回給使用者一個實現了該產品介面的型別物件;

通過上述思路,實現程式碼如下:

①首先是定義工廠介面,產品介面與產品型別的列舉

②其次是具體實現產品介面的產品類:窗戶、屋頂和柱子

③然後是具體實現工廠介面的工廠類:實現介面返回一個具體的產品物件

④最後是工廠管理類:組織起眾多的產品與工廠

按照國際慣例,我們實現一個入口方法來測試一下:

在Customer類中,我們通過工廠管理類根據需要的不同零件型別獲取到了不同的產品零件,其執行結果如下圖所示:

當一個新的產品—地板需要被新增時,我們需要改的地方是:新增零件列舉記錄、新增針對地板的工廠類、新增新地板產品類,修改工廠管理類(在switch中新增一條case語句),這樣設計的優點在於無論新增何種零件,產品使用者都不需要關心內部的變動,可以一如既往地使用工廠管理類來得到希望的零件,而缺點也有以下幾點:

①工廠管理類和工廠類族耦合;

②每次新增新的零件都需要新增一對工廠類和產品類,型別會越來越多;

(2)基於反射的工廠模式的實現

利用反射機制可以實現更加靈活的工廠模式,這一點體現在利用反射可以動態地獲知一個產品由哪些零部件組成,而不再需要用一個switch語句來逐一地尋找合適的工廠。

①產品、列舉和以上一致,這裡的改變主要在於新增了兩個自定義的特性,這兩個特性會被分別附加在產品型別和產品介面上:

②下面是產品介面和產品類族的定義,其中產品介面使用了ProductListAttribute特性,而每個產品都使用了ProductAttribute特性:

③下面是修改後的工廠類,由於使用了反射特性,這裡一個工廠型別就可以生產所有的產品:

④最後時修改後的工廠管理類,核心只有三行程式碼:

上述程式碼中最主要的變化在於兩點:其一是工廠管理類不再需要根據不同的零件尋找不同的工廠,因為只有一個工廠負責處理所有的產品零件;其二是產品型別和產品介面應用了兩個自定義特性,來方便工廠進行反射。ProductAttribute附加在產品類上,標註了當前型別代表了哪個產品零件。而ProductListAttribute則附加在產品介面之上,方便反射得知一共有多少產品零件。

這時需要新增一個新的地板產品零件型別時,我們需要做的是:新增零件列舉記錄,新增代表地板的型別,修改新增在IProduct上的屬性初始化引數(增加地板型別),可以看到這時呼叫者、工廠管理類和工廠都不再需要對新新增的零件進行改動,程式只需要新增必要的型別和列舉記錄即可。當然,這樣的設計也存在一定缺陷:反射的執行效率相對較低,在產品零件相對較多時,每生產一個產品就需要反射遍歷這是一件相當耗時的工作。

 

四、特性基礎

特性機制可以幫助程式設計師以申明的方式進行程式設計,而不再需要考慮實現的細節。

4.1 神馬是特性?如何自定義一個特性?

(1)特性是什麼?

特性是一種有別於普通指令式程式設計的程式設計方式,通常被稱為申明式程式設計方式。所謂申明式程式設計方式就是指程式設計師只需要申明某個模組會有怎樣的特性,而無需關心如何去實現。下面的程式碼就是特性在ASP.NET MVC中的基本使用方式:

當一個特性被新增到某個元素上時,該元素就被認為具有了這個特性所代表的功能或性質,例如上述程式碼中Add方法在新增了HttpPost特性之後,就被認為只有遇到以POST的方式請求該方法時才會被執行。

Note:特性在被編譯器編譯時,和傳統的命令式程式碼不同,它會被以二進位制資料的方式寫入模組檔案的後設資料之中,而在執行時再被解讀使用。特性也是經常被反射機制應用的元素,因為它本身是以後設資料的形式存放的。

(2)如何自定義特性

除了直接使用.NET中內建的所有特性之外,我們也可以建立自己的特性來實現業務邏輯。在上面反射工廠的實現中就使用到了自定義特性。具體來說,定義一個特性的本質就是定義一個繼承自System.Attribute類的型別,這樣的型別就被編譯器認為是一個特性型別。

下面我們看看如何自頂一個特性並使用該特性:

①定義一個繼承自System.Attribute的型別MyCustomAttribute

一個繼承自System.Attribute的型別,就是一個自定義特性,並且可以將其新增到適合的元素之上。特性將會被寫入到後設資料之中,所以特性的使用基本都是基於反射機制。

②在入口方法中使用MyCustomAttribute

為入口方法所在的型別UseMyCustomAttribute類新增了一個自定義特性,就可以在該類的方法中通過呼叫該型別的GetCustomAttributes方法獲取所有新增到該型別的自定義特性陣列,也就可以方便使用該自定義特性所具備的性質和能力(例如程式碼中的屬性成員可以方便獲取)。

關於自定義特性,有幾點需要注意:

  • 雖然沒有強制規定,但按照約定最好特性型別的名字都以Attribute結尾;
  • 在C#中為了方便起見,使用特性時都可以省略特性名字後的Attribute,例如上述程式碼中的[MyCustom(“UseMyCustomAttribute”)]代替了[MyCustomAttribute(“UseMyCustomAttribute”)];
  • 特性型別自身也可以新增其他的特性;

4.2 .NET中特性可以在哪些元素上使用?

特性可以被用來使用到某個元素之上,這個元素可以是欄位,也可以是型別。對於類、結構等元素,特性的使用可以新增在其定義的上方,而對於程式集、模組等元素的特性來說,則需要顯式地告訴編譯器這些特性的作用目標。例如,在C#中,通過目標關鍵字加冒號來告訴編譯器的使用目標:

我們在設計自定義特性時,往往都具有明確的針對性,例如該特性只針對型別、介面或者程式集,限制特性的使用目標可以有效地傳遞設計者的意圖,並且可以避免不必要的錯誤使用特性而導致的後設資料膨脹。AttributeUsage特性就是用來限制特性使用目標元素的,它接受一個AttributeTargets的列舉物件作為輸入來告訴AttributeUsage西望望對特性做何種限定。例如上面展示的一個自定義特性,使用了限制範圍:

Note:一般情況下,自定義特性都會被限制適用範圍,我們也應該養成這樣的習慣,為自己設計的特性加上AttributeUsage特性,很少會出現使用在所有元素上的特性。即便是可以使用在所有元素上,也應該顯式地申明[AttributeUsage(AttributesTargets.All)]來提高程式碼的可讀性。

4.3 如何獲知一個元素是否申明瞭某個特性?

在.NET中提供了很多的方法來查詢一個元素是否申明瞭某個特性,每個方法都有不同的使用場合,但是萬變不離其宗,都是基於反射機制來實現的。

首先,還是以上面的MyCustomAttribute特性為例,新建一個入口方法類Program:

(1)System.Attribute.IsDefined方法

(2)System.Attribute.GetCustomerAttribute方法

(3)System.Attribute.GetCustomerAttributes方法

(4)System.Reflection.CustomAttributeData型別

下圖是四種方式的執行結果:

這四種方法各有其特點,但都可以實現查詢某個元素是否申明瞭某個特性的這一功能。其中,可以看到第(4)種方式,可以對特性進行分析,但無法得到其例項。另外,自定義特性被申明為sealed表示不可繼承,這是因為在特性被檢查時,無法分別制定特性和其派生特性,這一點需要我們注意。

4.4 一個元素是否可以重複申明同一個特性?

對於有些業務邏輯來說,一個特性反覆地申明在同一個元素上市沒有必要的,但同時對於另一些邏輯來說,又非常有必要對同一元素多次申明同一特性。很幸運,.NET的特性機制完美支援了這一類業務需求。

當一個特性申明瞭AttributeUsage特性並且顯式地將AllowMultiple屬性設定為true時,該特性就可以在同一元素上多次申明,否則的話編譯器將報錯。

例如下面一段程式碼,型別Program多次申明瞭MyCustomAttribute特性:

通常情況下,重複申明同一特性往往會傳入不同的引數。此外,如果不顯式地設定AllowMultiple屬性時多次申明同一特性會如何呢?在這種情況下,編譯器將會認為自定義特性不能多次申明在同一元素上,會出現以下的編譯錯誤:

 

參考資料

(1)朱毅,《進入IT企業必讀的200個.NET面試題》

(2)張子陽,《.NET之美:.NET關鍵技術深入解析》

(3)王濤,《你必須知道的.NET》

相關文章