庖丁解牛聊委託,那些編譯器藏的和U3D給的(上)

chenjd發表於2019-02-16

0x01 從觀察者模式說起

在設計模式中,有一種我們常常會用到的設計模式——觀察者模式。那麼這種設計模式和我們的主題“如何在Unity3D中使用委託”有什麼關係呢?別急,先讓我們來聊一聊什麼是觀察者模式。

首先讓我們來看看報紙和雜誌的訂閱是怎麼一回事:

  1. 報社的任務便是出版報紙。

  2. 向某家報社訂閱他們的報紙,只要他們有新的報紙出版便會向你發放。也就是說,只要你是他們的訂閱客戶,便可以一直收到新的報紙。

  3. 如果不再需要這份報紙,則可以取消訂閱。取消之後,報社便不會再送新的報紙過來。

  4. 報社和訂閱者是兩個不同的主體,只要報社還一直存在著,不同的訂閱者便可以來訂閱或取消訂閱。

如果各位讀者能看明白我上面所說的報紙和雜誌是如何訂閱的,那麼各位也就瞭解了觀察者模式到底是怎麼一回事。除了名稱不大一樣,在觀察者模式中,報社或者說出版者被稱為“主題”(Subject),而訂閱者則被稱為“觀察者”(Observer)。將上面的報社和訂閱者的關係移植到觀察者模式中,就變成了如下這樣:主題(Subject)物件管理某些資料,當主題內的資料改變時,便會通知已經訂閱(註冊)的觀察者,而已經註冊主題的觀察者此時便會收到主題資料改變的通知並更新,而沒有註冊的物件則不會被通知。

當我們試圖去勾勒觀察者模式時,可以使用報紙訂閱服務,或者出版者和訂閱者來比擬。而在實際的開發中,觀察者模式被定義為了如下這樣:

觀察者模式:定義了物件之間的一對多依賴,這樣一來,當一個物件改變狀態時,它的所有依賴者都會收到通知並自動更新。

那麼介紹了這麼多觀察者模式的內容,是不是也該說一說委託了呢?是的,C#語言通過委託來實現回撥函式的機制,而回撥函式是一種很有用的程式設計機制,可以被廣泛的用在觀察者模式中。

那麼Unity3D本身是否有提供這種機制呢?答案也是肯定的,那麼和委託又有什麼區別呢?下面就讓我們來聊一聊這個話題。

0x02 向Unity3D中的SendMessage和BroadcastMessage說拜拜

當然,不可否認Unity3D遊戲引擎的出現是遊戲開發者的一大福音。但不得不說的是,Unity3D的遊戲指令碼的架構中是存在一些缺陷的。一個很好的例子就是本節要說的圍繞SendMessage和BroadcastMessage而構建的訊息系統。之所以說Unity3D的這套訊息系統存在缺陷,主要是由於SendMessage和BroadcastMessage過於依賴反射機制(reflection)來查詢訊息對應的回撥函式。頻繁的使用反射自然會影響效能,但是效能的損耗還並非最為嚴重的問題,更加嚴重的問題是使用這種機制之後程式碼的維護成本。為什麼說這樣做是一個很糟糕的事情呢?因為使用字串來標識一個方法可能會導致很多隱患的出現。舉一個例子:假如開發團隊中某個開發者決定要重構某些程式碼,很不巧,這部分程式碼便是那些可能要被這些訊息呼叫的方法定義的程式碼,那麼如果方法被重新命名甚至被刪除,是否會導致很嚴重的隱患呢?答案是yes。這種隱患的可怕之處並不在於可能引發的編譯時錯誤,恰恰相反,這種隱患的可怕之處在於編譯器可能都不會報錯來提醒開發者某些方法已經被改名甚至是不存在了,面對一個能夠正常的執行程式而沒有警覺是最可怕的,而什麼時候這個隱患會爆發呢?就是觸發了特定的訊息而找不到對應的方法的時候 ,但這時候發現問題所在往往已經太遲了。

另一個潛在的問題是由於使用了反射機制因而Unity3D的這套訊息系統也能夠呼叫宣告為私有的方法的。但是如果一個私有方法在宣告的類的內部沒有被使用,那麼正常的想法肯定都認為這是一段廢程式碼,因為在這個類的外部不可能有人會呼叫它。那麼對待廢程式碼的態度是什麼呢?我想很多開發者都會選擇消滅這段廢程式碼,那麼同樣的隱患又會出現,可能在編譯時並沒有問題,甚至程式也能正常執行一段時間,但是隻要觸發了特定的訊息而沒有對應的方法,那便是這種隱患爆發的時候。因而,是時候向Unity3D中的SendMessage和BroadcastMessage說拜拜了,讓我們選擇C#的委託來實現自己的訊息機制吧。

0x03 認識回撥函式機制—-委託

在非託管程式碼C/C++中也存在類似的回撥機制,但是這些非成員函式的地址僅僅是一個記憶體地址。而這個地址並不攜帶任何額外的資訊,例如函式的引數個數、引數型別、函式的返回值型別,因而我們說非託管C/C++程式碼的回撥函式不是型別安全的。而C#中提供的回撥函式的機制便是委託,一種型別安全的機制。為了直觀的瞭解委託,我們先來看一段程式碼:

using UnityEngine;

using System.Collections;


public class DelegateScript : MonoBehaviour

{  

    //宣告一個委託型別,它的例項引用一個方法
    internal delegate void MyDelegate(int num);

    MyDelegate myDelegate;

    void Start ()
    {

        //委託型別MyDelegate的例項myDelegate引用的方法

        //是PrintNum

        myDelegate = PrintNum;

        myDelegate(50);

        //委託型別MyDelegate的例項myDelegate引用的方法

        //DoubleNum       

        myDelegate = DoubleNum;

        myDelegate(50);

    }

    void PrintNum(int num)
    {
        Debug.Log ("Print Num: " + num);
    }

    void DoubleNum(int num)
    {
        Debug.Log ("Double Num: " + num * 2);
    }
}

下面我們來看看這段程式碼做的事情。在最開始,我們可以看到internal委託型別MyDelegate的宣告。委託要確定一個回撥方法簽名,包括引數以及返回型別等等,在本例中MyDelegate委託制定的回撥方法的引數型別是int型,同時返回型別為void。

DelegateScript類還定義了兩個私有方法PrintNum和DoubleNum,它們的分別實現了列印傳入的引數和列印傳入的引數的兩倍的功能。在Start方法中,MyDelegate類的例項myDelegate分別引用了這兩個方法,並且分別呼叫了這兩個方法。

看到這裡,不知道各位讀者是否會產生一些疑問,為什麼一個方法能夠像這樣myDelegate = PrintNum; “賦值”給一個委託呢?這便不得不提C#2為委託提供的方法組轉換。回溯C#1的委託機制,也就是十分原始的委託機制中,如果要建立一個委託例項就必須要同時指定委託型別和要呼叫的方法(執行的操作),因而剛剛的那行程式碼就要被改為:

new MyDelegate(PrintNum);

即便回到C#1的時代,這行建立新的委託例項的程式碼看上去似乎並沒有讓開發者產生什麼不好的印象,但是如果是作為較長的一個表示式的一部分時,就會讓人感覺很冗繁了。一個明顯的例子是在啟動一個新的執行緒時候的表示式:

Thread th = new Thread(new ThreadStart(Method));

這樣看起來,C#1中的方式似乎並不簡潔。因而C#2為委託引入了方法組轉換機制,即支援從方法到相容的委託型別的隱式轉換。就如同我們一開始的例子中做的那樣。

//使用方法組轉換時,隱式轉換會將
//一個方法組轉換為具有相容簽名的
//任意委託型別
myDelegate = PrintNum;
Thread th = new Thread(Method);

而這套機制之所以叫方法組轉換,一個重要的原因就是由於過載,可能不止一個方法適用。例如下面這段程式碼所演示的那樣:

using UnityEngine;
using System.Collections;

public class DelegateScript : MonoBehaviour
{  
    //宣告一個委託型別,它的例項引用一個方法

    delegate void MyDelegate(int num);

    //宣告一個委託型別,它的例項引用一個方法

    delegate void MyDelegate2(int num, int num2);

 

    MyDelegate myDelegate;

    MyDelegate2 myDelegate2;


    void Start ()
    {
        //委託型別MyDelegate的例項myDelegate引用的方法
        //是PrintNum
        
        myDelegate = PrintNum;
        
        myDelegate(50);
        
        //委託型別MyDelegate2的例項myDelegate2引用的方法
        //PrintNum的過載版本       

        myDelegate2 = PrintNum;

        myDelegate(50, 50);

    }

    void PrintNum(int num)
    {

        Debug.Log ("Print Num: " + num);

    }

    void PrintNum(int num1, int num2)
    {

        int result = num1 + num2;

        Debug.Log ("result num is : " + result);

    }
}

這段程式碼中有兩個方法名相同的方法:

void PrintNum(int num)

void PrintNum(int num1, int num2)

那麼根據方法組轉換機制,在向一個MyDelegate或一個MyDelegate2賦值時,都可以使用PrintNum作為方法組(此時有2個PrintNum,因而是“組”),編譯器會選擇合適的過載版本。

當然,涉及到委託的還有它的另外一個特點——委託引數的逆變性和委託返回型別的協變性。這個特性在很多文章中也有過介紹,但是這裡為了使讀者更加加深印象,因而要具體的介紹一下委託的這種特性。

在為委託例項引用方法時,C#允許引用型別的協變性和逆變性。協變性是指方法的返回型別可以是從委託的返回型別派生的一個派生類,也就是說協變性描述的是委託返回型別。逆變性則是指方法獲取的引數的型別可以是委託的引數的型別的基類,換言之逆變性描述的是委託的引數型別。

例如,我們的專案中存在的基礎單位類(BaseUnitClass)、士兵類(SoldierClass)以及英雄類(HeroClass),其中基礎單位類BaseUnitClass作為基類派生出了士兵類SoldierClass和英雄類HeroClass,那麼我們可以定義一個委託,就像下面這樣:

delegate Object TellMeYourName(SoldierClass soldier);

那麼我們完全可以通過構造一個該委託型別的例項來引用具有以下原型的方法:

string TellMeYourNameMethod(BaseUnitClass base);

在這個例子中,TellMeYourNameMethod方法的引數型別是BaseUnitClass,它是TellMeYourName委託的引數型別SoldierClass的基類,這種引數的逆變性是允許的;而TellMeYourNameMethod方法的返回值型別為string,是派生自TellMeYourName委託的返回值型別Object的,因而這種返回型別的協變性也是允許的。但是有一點需要指出的是,協變性和逆變性僅僅支援引用型別,所以如果是值型別或void則不支援。下面我們接著舉一個例子,如果將TellMeYourNameMethod方法的返回型別改為值型別int,如下:

int TellMeYourNameMethod(BaseUnitClass base);

這個方法除了返回型別從string(引用型別)變成了int(值型別)之外,什麼都沒有被改變,但是如果要將這個方法繫結到剛剛的委託例項上,編譯器會報錯。雖然int型和string型一樣,都派生自Object類,但是int型是值型別,因而是不支援協變性的。這一點,各位讀者在實際的開發中一定要注意。

好了,到此我們應該對委託有了一個初步的直觀印象。在本節中我帶領大家直觀的認識了委託如何在程式碼中使用,以及通過C#2引入的方法組轉換機制為委託例項引用合適的方法以及委託的協變性和逆變性。那麼本節就到此結束,接下來讓我們更進一步的探索委託。

相關文章