委託的簡化語法,聊聊匿名方法和閉包

發表於2016-05-31

0x00 前言

通過上一篇部落格《匹夫細說C#:庖丁解牛聊委託,那些編譯器藏的和U3D給的》的內容,我們實現了使用委託來構建我們自己的訊息系統的過程。但是在日常的開發中,仍然有很多開發者因為這樣或那樣的原因而選擇疏遠委託,而其中最常見的一個原因便是因為委託的語法奇怪而對委託產生抗拒感。

因而本文的主要目標便是介紹一些委託的簡化語法,為有這種心態的開發者們減輕對委託的抗拒心理。

0x01 不必構造委託物件

委託的一種常見的使用方式,就像下面的這行程式碼一樣:

其中括號中的OnSubHp是方法,該方法的定義如下:

上面列出的第一行程式碼的意思是向this.unit的OnSubHp事件登記方法OnSubHp的地址,當OnSubHp事件被觸發時通知呼叫OnSubHp方法。而這行程式碼的意義在於,通過構造SubHpHandler委託型別的例項來獲取一個將回撥方法OnSubHp進行包裝的包裝器,以確保回撥方法只能以型別安全的方式呼叫。

同時通過這個包裝器,我們還獲得了對委託鏈的支援。但是,更多的程式設計師顯然更傾向於簡單的表達方式,他們無需真正瞭解建立委託例項以獲得包裝器的意義,而只需要為事件註冊相應的回撥方法即可。例如下面的這行程式碼:

之所以能夠這樣寫,我在之前的部落格中已經有過解釋。雖然“+=”操作符期待的是一個SubHpHandler委託型別的物件,而this.OnSubHp方法應該被SubHpHandler委託型別物件包裝起來。但是由於C#的編譯器能夠自行推斷,因而可以將構造SubHpHandler委託例項的程式碼省略,使得程式碼對程式設計師來說可讀性更強。

不過,編譯器在幕後卻並沒有什麼變化,雖然開發者的語法得到了簡化,但是編譯器生成CIL程式碼仍舊會建立新的SubHpHandler委託型別例項。

簡而言之,C#允許通過指定回撥方法的名稱而省略構造委託型別例項的程式碼。

0x02 匿名方法初探

在上一篇博文中,我們可以看到通常在使用委託時,往往要宣告相應的方法,例如引數和返回型別必須符合委託型別確定的方法原型。而且,我們在實際的遊戲開發過程中,往往也需要委託的這種機制來處理十分簡單的邏輯,但對應的,我們必須要建立一個新的方法和委託型別匹配,這樣做看起來將會使得程式碼變得十分臃腫。

因而,在C#2的版本中,引入了匿名方法這種機制。什麼是匿名方法?下面讓我們來看一個小例子。

將這個DelegateTest指令碼掛載在某個遊戲場景中的物體上,執行編輯器,可以看到在除錯視窗輸出瞭如下內容。

My name is chenjiadong

UnityEngine.Debug:Log(Object)

My age is 26

UnityEngine.Debug:Log(Object)

在解釋這段程式碼之前,我需要先為各位讀者介紹一下常見的兩個泛型委託型別:Action以及Func。它們的表現形式主要如下:

從Action的定義形式上可以看到。Action是沒有返回值得。適用於任何沒有返回值的方法。

Func委託的定義是相對於Action來說。Action是沒有返回值的方法委託,Func是有返回值的委託。返回值的型別,由泛型中定義的型別進行約束。

好了,各位讀者對C#的這兩個常見的泛型委託型別有了初步的瞭解之後,就讓我們來看一看上面那段使用了匿名方法的程式碼吧。首先我們可以看到匿名方法的語法:先使用delegate關鍵字之後如果有引數的話則是引數部分,最後便是一個程式碼塊定義對委託例項的操作。而通過這段程式碼,我們也可以看出一般方法體中可以做到事情,匿名函式同樣可以做。

而匿名方法的實現,同樣要感謝編譯器在幕後為我們隱藏了很多複雜度,因為在CIL程式碼中,編譯器為原始碼中的每一個匿名方法都建立了一個對應的方法,並且採用了和建立委託例項時相同的操作,將建立的方法作為回撥函式由委託例項包裝。而正是由於是編譯器為我們建立的和匿名方法對應的方法,因而這些的方法名都是編譯器自動生成的,為了不和開發者自己宣告的方法名衝突,因而編譯器生成的方法名的可讀性很差。

當然,如果乍一看上面的那段程式碼似乎仍然很臃腫,那麼能否不賦值給某個委託型別的例項而直接使用呢?答案是肯定的,同樣也是我們最常使用的匿名方法的一種方式,那便是將匿名方法作為另一個方法的引數使用,因為這樣才能體現出匿名方法的價值——簡化程式碼。

下面就讓我們來看一個小例子,還記得List列表嗎?它有一個獲取Action作為引數的方法——ForEach,該方法對列表中的每個元素執行Action所定義的操作。下面的程式碼將演示這一點,我們使用匿名方法對列表中的元素(向量Vector3)執行獲取normalized的操作。

我們可以看到,一個引數為Vector3的匿名方法:

實際上作為引數傳入到了List的ForEach方法中。這段程式碼執行之後,我們可以在Unity3D的除錯視窗觀察輸出的結果。內容如下:

(0.4, 0.1, 0.9)

UnityEngine.Debug:Log(Object)

(0.5, 0.1, 0.8)

UnityEngine.Debug:Log(Object)

(0.6, 0.1, 0.8)

UnityEngine.Debug:Log(Object)

(0.7, 0.1, 0.7)

UnityEngine.Debug:Log(Object)

(0.8, 0.1, 0.6)

UnityEngine.Debug:Log(Object)

那麼,匿名方法的表現形式能否更加極致的簡潔呢?當然,如果不考慮可讀性的話,我們還可以將匿名方法寫成這樣的形式:

當然,這裡僅僅是給各位讀者們一個參考,事實上這種可讀性很差的形式是不被推薦的。

除了Action這種返回型別為void的委託型別之外,上文還提到了另一種委託型別,即Func。所以上面的程式碼我們可以修改為如下的形式,使得匿名方法可以有返回值。

在匿名方法中,我們使用了return來返回指定型別的值,並且將匿名方法賦值給了Func委託型別的例項。將上面這個C#指令碼執行,在Unity3D的除錯視窗我們可以看到輸出瞭如下內容:

My name is chenjiadong

UnityEngine.Debug:Log(Object)

26

UnityEngine.Debug:Log(Object)

可以看到,我們通過tellMeYourName和tellMeYourAge這兩個委託例項分別呼叫了我們定義的匿名方法。

當然,在C#語言中,除了剛剛提到過的Action和Func之外,還有一些我們在實際的開發中可能會遇到的預置的委託型別,例如返回值為bool型的委託型別Predicate。它的簽名如下:

而Predicate委託型別常常會在過濾和匹配目標時發揮作用。下面讓我們來再來看一個小例子。

上面這段程式碼通過使用Predicate委託型別判斷基礎單位(BaseUnit)到底是士兵(Soldier)還是英雄(Hero),進而統計列表中士兵和英雄的數量。正如我們剛剛所說的Predicate主要用來做匹配和過濾,那麼上述程式碼執行之後,輸出如下的內容:

英雄的個數為:2

UnityEngine.Debug:Log(Object)

士兵的個數為:5

UnityEngine.Debug:Log(Object)

當然除了過濾和匹配目標,我們常常還會碰到對列表按照某一種條件進行排序的情況。例如要對按照英雄的最大血量進行排序或者按照英雄的戰鬥力來進行排序等等,可以說是按照要求排序是遊戲系統開發過程中最常見的需求之一。

那麼是否也可以通過委託和匿名方法來方便的實現排序功能呢?C#又是否為我們預置了一些便利的“工具”呢?答案仍然是肯定的。我們可以方便的通過C#提供的Comparison委託型別結合匿名方法來方便的為列表進行排序。

Comparison的簽名如下:

由於Comparison委託型別是IComparison介面的委託版本,因而我們可以進一步來分析一下它的兩個引數以及返回值。如下表:

引數 型別 作用
x T 要比較的第一個物件
y T 要比較的第二個物件
返回值 含義
小於0 x小於y。
等於0 x等於y。
大於0 x大於y。

好了,現在我們已經明確了Comparison委託型別的引數和返回值的意義。那麼下面我們就通過定義匿名方法來使用它對英雄(Hero)列表按指定的標準進行排序吧。

首先我們重新定義Hero類,提供英雄的屬性資料。

之後使用Comparison委託型別和匿名方法來對英雄列表進行排序。

這樣,我們可以很方便的通過匿名函式來實現按英雄的ID排序、按英雄的maxHp排序、按英雄的attack排序、按英雄的defense排序以及按英雄的powerRank排序的要求,而無需為每一種排序都單獨寫一個獨立的方法。

0x03 使用匿名方法省略引數

好,通過上面的分析,我們可以看到使用了匿名方法之後的確簡化了我們在使用委託時還要單獨宣告對應的回撥函式的繁瑣。那麼是否可能更加極致一些,比如用在我們在前面介紹的事件中,甚至是省略引數呢?下面我們來修改一下我們在事件的部分所完成的程式碼,看看如何通過使用匿名方法來簡化它吧。

在之前的部落格的例子中,我們定義了AddListener來為BattleInformationComponent 的OnSubHp方法訂閱BaseUnit的OnSubHp事件。

其中this.OnSubHp方法是我們為了響應事件而單獨定義的一個方法,如果不定義這個方法而改由匿名方法直接訂閱事件是否可以呢?答案是肯定的。

在這裡我們直接使用了delegate關鍵字定義了一個匿名方法來作為事件的回撥方法而無需再單獨定義一個方法。但是由於在這裡我們要實現掉血的資訊顯示功能,因而看上去我們需要所有傳入的引數。

那麼在少數情況下,我們不需要使用事件所要求的引數時,是否可以通過匿名方法在不提供引數的情況下訂閱那個事件呢?答案也是肯定的,也就是說在不需要使用引數的情況下,我們通過匿名方法可以省略引數。還是在觸發OnSubHp事件時,我們只需要告訴開發者事件觸發即可,所以我們可以將AddListener方法改為下面這樣:

之後,讓我們執行一下修改後的指令碼。可以在Unity3D的除錯視窗看到如下內容的輸出:

英雄暴擊10000

UnityEngine.Debug:Log(Object)

呼救呼救,我被攻擊了!

UnityEngine.Debug:Log(Object)

0x04 匿名方法和閉包

當然,在使用匿名方法時另一個值得開發者注意的一個知識點便是閉包情況。所謂的閉包指的是:一個方法除了能和傳遞給它的引數互動之外,還可以同上下文進行更大程度的互動。

首先要指出閉包的概念並非C#語言獨有的。事實上閉包是一個很古老的概念,而目前很多主流的程式語言都接納了這個概念,當然也包括我們的C#語言。而如果要真正的理解C#中的閉包,我們首先要先掌握另外兩個概念:

1.外部變數:或者稱為匿名方法的外部變數指的是定義了一個匿名方法的作用域內(方法內)的區域性變數或引數對匿名方法來說是外部變數。下面舉個小例子,各位讀者能夠更加清晰的明白外部變數的含義:

這段程式碼中的區域性變數n對匿名方法來說是外部變數。

2.捕獲的外部變數:即在匿名方法內部使用的外部變數。也就是上例中的區域性變數n在匿名方法內部便是一個捕獲的外部變數。

瞭解了以上2個概念之後,再讓我們結合閉包的定義,可以發現在閉包中出現的方法在C#中便是匿名方法,而匿名方法能夠使用在宣告該匿名方法的方法內部定義的區域性變數和它的引數。而這麼做有什麼好處呢?想象一下,我們在遊戲開發的過程中不必專門設定額外的型別來儲存我們已經知道的資料,便可以直接使用上下文資訊,這便提供了很大的便利性。那麼下面我們就通過一個小例子,來看看各種變數和匿名方法的關係吧。

好了,接下來讓我們來分析一下這段程式碼中的變數吧。

  • 引數i是一個外部變數,因為在它的作用域內宣告瞭一個匿名方法,並且由於在匿名方法中使用了它,因而它是一個被捕捉的外部變數。
  • 變數outerValue是一個外部變數,這是由於在它的作用域內宣告瞭一個匿名方法,但是和i不同的一點是outerValue並沒有被匿名方法使用,因而它是一個沒有被捕捉的外部變數。
  • 變數capturedOuterValue同樣是一個外部變數,這也是因為在它的作用域內同樣宣告瞭一個匿名方法,但是capturedOuterValue和i一樣被匿名方法所使用,因而它是一個被捕捉的外部變數。
  • 變數str不是外部變數,同樣也不是EnclosingFunction這個方法的區域性變數,相反它是一個匿名方法內部的區域性變數。
  • 變數notOuterValue同樣不是外部變數,這是因為在它所在的作用域中,並沒有宣告匿名方法。

好了,明白了上面這段程式碼中各個變數的含義之後,我們就可以繼續探索匿名方法究竟是如何捕捉外部變數以及捕捉外部變數的意義了。

0x05 匿名方法如何捕獲外部變數

首先,我們要明確一點,所謂的捕捉變數的背後所發生的操作的確是針對變數而言的,而不是僅僅獲取變數所儲存的值。這將導致什麼後果呢?不錯,這樣做的結果是被捕捉的變數的存活週期可能要比它的作用域長,關於這一點我們之後再詳細討論,現在的當務之急是搞清楚匿名方法是如何捕捉外部變數的。

將這個指令碼掛載在遊戲物體上,執行Unity3D可以在除錯視窗看到如下的輸出內容:

捕獲外部變數hello world 你好世界999

UnityEngine.Debug:Log(Object)

你好世界

UnityEngine.Debug:Log(Object)

可這究竟有什麼特殊的呢?看上去程式很自然的列印出了我們想要列印的內容。不錯,這段程式碼向我們展示的不是列印出的究竟是什麼,而是我們這段程式碼從始自終都是在對同一個變數capturedOuterValue進行操作,無論是匿名方法內部還是正常的EnclosingFunction方法內部。

接下來讓我們來看看這一切究竟是如何發生的,首先我們在EnclosingFunction方法內部宣告瞭一個區域性變數capturedOuterValue並且為它賦值為hello world。接下來,我們又宣告瞭一個委託例項anonymousMethod,同時將一個內部使用了capturedOuterValue變數的匿名方法賦值給委託例項anonymousMethod,並且這個匿名方法還會修改被捕獲的變數的值,需要注意的是宣告委託例項的過程並不會執行該委託例項。

因而我們可以看到匿名方法內部的邏輯並沒有立即執行。好了,下面我們這段程式碼的核心部分要來了,我們在匿名方法的外部修改了capturedOuterValue變數的值,接下來呼叫anonymousMethod。我們通過列印的結果可以看到capturedOuterValue的值已經在匿名方法的外部被修改為了“hello world 你好世界”,並且被反映在了匿名方法的內部,同時在匿名方法內部,我們同樣將capturedOuterValue變數的值修改為了“你好世界”。

委託例項返回之後,程式碼繼續執行,接下來會直接列印capturedOuterValue的值,結果為“你好世界”。這便證明了通過匿名方法建立的委託例項不是讀取變數,並且將它的值再儲存起來,而是直接操作該變數。可這究竟有什麼意義呢?那麼,下面我們就舉一個例子,來看看這一切究竟會為我們在開發中帶來什麼好處。

仍舊回到我們開發遊戲的情景之下,假設我們需要將一個英雄列表中攻擊力低於10000的英雄篩選出來,並且將篩選出的英雄放到另一個新的列表中。如果我們使用List,則通過它的FindAll方法便可以實現這一切。但是在匿名方法出現之前,使用FindAll方法是一件十分繁瑣的事情,這是由於我們要建立一個合適的委託,而這個過程十分繁瑣,已經使FindAll方法失去了簡潔的意義。因而,隨著匿名方法的出現,我們可以十分方便的通過FindAll方法來實現過濾攻擊力低於10000的英雄的邏輯。下面我們就來試一試吧。

看到了嗎?在FindAllLowAttack方法中傳入的float型別的引數limit被我們在匿名方法中捕獲了。正是由於匿名方法捕獲的是變數本身,因而我們才獲得了使用引數的能力,而不是在匿名方法中寫死一個確定的數值來和英雄的攻擊力做比較。這樣在經過設計之後,程式碼結構會變得十分精巧。

0x06 區域性變數的儲存位置

當然,我們之前還說過將匿名方法賦值給一個委託例項時並不會立刻執行這個匿名方法內部的程式碼,而是當這個委託被呼叫時才會執行匿名方法內部的程式碼。那麼一旦匿名方法捕獲了外部變數,就有可能面臨一個十分可能會發生的問題。

那便是如果建立了這個被捕獲的外部變數的方法返回之後,一旦再次呼叫捕獲了這個外部變數的委託例項,那麼會出現什麼情況呢?也就是說,這個變數的生存週期是會隨著建立它的方法的返回而結束呢?還是繼續保持著自己的生存呢?下面我們還是通過一個小例子來一窺究竟。

將這個指令碼掛載在Unity3D場景中的某個遊戲物體上,之後啟動遊戲,我們可以看到在除錯視窗的輸出內容如下:

1

UnityEngine.Debug:Log(Object)

11

UnityEngine.Debug:Log(Object)

111

UnityEngine.Debug:Log(Object)

1111

UnityEngine.Debug:Log(Object)

如果看到這個輸出結果,各位讀者是否會感到一絲驚訝呢?因為第一次列印出1這個結果,我們十分好理解,因為在TestCreateActionInstance方法內部我們呼叫了一次action這個委託例項,而其區域性變數count此時當然是可用的。但是之後當TestCreateActionInstance已經返回,我們又三次呼叫了action這個委託例項,卻看到輸出的結果依次是11、111、111,是在同一個變數的基礎上累加而得到的結果。

但是區域性變數不是應該和方法一樣分配在棧上,一旦方法返回便會隨著TestCreateActionInstance方法對應的棧幀一起被銷燬嗎?但是,當我們再次呼叫委託例項的結果卻表示,事實並非如此。TestCreateActionInstance方法的區域性變數count並沒有被分配在棧上,相反,編譯器事實上在幕後為我們建立了一個臨時的類用來儲存這個變數。如果我們檢視編譯後的CIL程式碼,可能會更加直觀一些。下面便是這段C#程式碼對應的CIL程式碼。

我們可以看到這個編譯器生成的臨時的類的名字叫做’c__AnonStorey0’,這是一個讓人看上去十分奇怪,但是識別度很高的名字,我們之前已經介紹過編譯器生成的名字的特點,這裡就不贅述了。仔細來分析這個類,我們可以發現TestCreateActionInstance這個方法中的區域性變數count此時是編譯器生成的類’c__AnonStorey0’的一個欄位:

這也就證明了TestCreateActionInstance方法的區域性變數count此時被存放在另一個臨時的類中,而不是被分配在了TestCreateActionInstance方法對應的棧幀上。那麼TestCreateActionInstance方法又是如何來對它的區域性變數count執行操作呢?

答案其實十分簡單,那就是TestCreateActionInstance方法保留了對那個臨時類的一個例項的引用,通過型別的例項進而操作count變數。為了證明這一點,我們同樣可以檢視一下TestCreateActionInstance方法對應的CIL程式碼。

我們可以發現在IL_0000行,CIL程式碼建立了DelegateTest/’c__AnonStorey0’類的例項,而之後使用count則全部要通過這個例項。同樣,委託例項之所以可以在TestCreateActionInstance方法返回之後仍然可以使用count變數,也是由於委託例項同樣引用了那個臨時類的例項,而count變數也和這個臨時類的例項一起被分配在了託管堆上而不是像一般的區域性變數一樣被分配在棧上。

因此,並非所有的區域性變數都是隨方法一起被分配在棧上的,在使用閉包和匿名方法時一定要注意這一個很容易讓人忽視的知識點。當然,關於如何分配儲存空間這個問題,我之前在博文《匹夫細說C#:不是“棧型別”的值型別,從生命週期聊儲存位置》 也進行過討論,歡迎各位交流指正。

相關文章