曾子曰:吾日三省吾身——為人謀而不忠乎?與朋友交而不信乎?傳不習乎?

引言

上一篇我們談到了在游戲引擎,或者在程序和高級編程語言中,設計一個統(tǒng)一對象模型得到的好處,和要付出的代價,以及在UE里是怎么對之盡量降低規(guī)避的。那么從本篇開始,我們就開始談談如何開始構(gòu)建這么一個對象模型,并在此之上逐漸擴展以適應引擎的各種功能需求的。

眾所周知,一般游戲引擎最底層面對的都是操作系統(tǒng)API,硬件SDK,所能借助到的工具也往往只有C++本身。所以考慮從原生的C++基礎上,搭建對象系統(tǒng),往往得從頭開始造輪子,最底層也是最核心的機制當然必須得掌控在自己的手中,以后升級改善增加功能也才能不受限制。

那么,從頭開始的話,Object系統(tǒng)有那么多功能:GC,反射,序列化,編輯器支持……應該從哪一個開始?哪一個是必需的?GC不是,因為大不了我可以直接new/delete或者智能指針引用技術(shù),畢竟別的很多引擎也都是這么干的。序列化也不是,大不了每個子類里手寫數(shù)據(jù)排布,麻煩是麻煩,但是功能上也是可以實現(xiàn)的。編輯器支持,默認類對象,統(tǒng)計等都是之后額外附加的功能了。那你說反射為何是必需的?大多數(shù)游戲引擎用的C++沒有反射,不也都用得好好的?確實也如此,不利用反射的那些功能,不動態(tài)根據(jù)類型創(chuàng)建對象,不遍歷屬性成員,不根據(jù)名字調(diào)用函數(shù),大不了手寫繞一下,沒有過不去的坎。但是既然上文已經(jīng)論述了一個統(tǒng)一Object模型的好處,那么如果在Object身上不加上反射,無疑就像是砍掉了Object的一雙翅膀,讓它只能在地上行走,而不能在更寬闊空間內(nèi)發(fā)揮威力。還有另一個方面的考慮是,反射作為底層的系統(tǒng),如果實現(xiàn)完善了,也可以大大裨益其他系統(tǒng)的實現(xiàn),比如有了反射,實現(xiàn)序列化起來就很方便了;有沒有反射,也關(guān)系到GC實現(xiàn)時的方案選擇,完全是兩種套路。簡單舉個例,反射里對每個object有個class對象保存信息,所以理論上class身上就可以保存所有該類型的object指針引用,這個信息GC就可以利用起來實現(xiàn)一些功能;而沒有這個class對象的話,GC的實現(xiàn)就得走別的方案路子了。所以說是先實現(xiàn)反射,有了一個更加扎實的對象系統(tǒng)基礎后,再在此之上實現(xiàn)GC才更加的明智。

類型系統(tǒng)

雖然之上一直用反射的術(shù)語來描述我們熟知的那一套運行時得到類型信息的系統(tǒng),動態(tài)創(chuàng)建類對象等,但是其實“反射”只是在“類型系統(tǒng)”實現(xiàn)之后的附加功能,人們往往都太過注重最后表露的強大功能,而把樸實的本質(zhì)支撐給忘記了。想想看,如果我實現(xiàn)了class類用來提供Object的類型信息,但是不提供動態(tài)創(chuàng)建,動態(tài)調(diào)用函數(shù)等功能,請問還有沒有意義?其實還仍然是非常有意義的,光光是提供了一個類型信息,就提供了一個Object之外的靜態(tài)信息載體,也能構(gòu)建起object之間的派生從屬關(guān)系,想想如果UE里去掉了根據(jù)名字創(chuàng)建類對象的能力,是會損失一些便利功能,但確實也還沒有到元氣大傷的程度,GC依然能跑得起來。

所以以后更多用“類型系統(tǒng)”這個更精確的術(shù)語來表述object之外的類型信息構(gòu)建,而用“反射”這個術(shù)語來描述運行時得到類型的功能,通過類型信息反過來創(chuàng)建對象,讀取修改屬性,調(diào)用方法的功能行為。反射更多是一種行為能力,更偏向動詞。類型系統(tǒng)指的是程序運行空間內(nèi)構(gòu)建出來的類型信息樹組織,

C# Type

因C++本身運行時類型系統(tǒng)的疲弱,所以我們首先拿一個已經(jīng)實現(xiàn)完善的語言,來看看其最后成果是什么樣子。這里選擇了C#而不是java,是因為我認為C#比java更強大優(yōu)雅(不辯),Unity用C#作為腳本語言,UE本身也是用C#作為編譯UBT的實現(xiàn)語言。
在C#里,你可以通過以下一行代碼方便的得到類型信息:

Type type = obj.GetType();  //or typeof(MyClass)


本篇不是C#反射教程(關(guān)心的自己去找相關(guān)教程),但這里還是簡單提一下我們需要關(guān)注的:

  1. Assembly是程序集的意思,通常指的是一個dll。

  2. Module是程序集內(nèi)部的子模塊劃分。

  3. Type就是我們最關(guān)心的Class對象了,完整描述了一個對象的類型信息。并且Type之間也可以通過BaseType,DeclaringType之類的屬性來互相形成Type關(guān)系圖。

  4. ConstructorInfo描述了Type中的構(gòu)造函數(shù),可以通過調(diào)用它來調(diào)用特定的構(gòu)造函數(shù)。

  5. EventInfo描述了Type中定義的event事件(UE中的delegate大概)

  6. FiedInfo描述了Type中的字段,就是C++的成員變量,得到之后可以動態(tài)讀取修改值

  7. PropertyInfo描述了Type中的屬性,類比C++中的get/set方法組合,得到后可以獲取設置屬性值。

  8. MethodInfo描述了Type中的方法。獲得方法后就可以動態(tài)調(diào)用了。

  9. ParameterInfo描述了方法中的一個個參數(shù)。

  10. Attributes指的是Type之上附加的特性,這個C++里并沒有,可以簡單理解為類上的定義的元數(shù)據(jù)信息。

可以看到C#里的Type幾乎提供了一切信息數(shù)據(jù),簡直就像是把編譯器編譯后的數(shù)據(jù)都給暴露出來了給你。實際上C#的反射還可以提供其他更高級的功能,比如運行時動態(tài)創(chuàng)建出新的類,動態(tài)Emit編譯代碼,不過這些都是后話了(在以后講解藍圖時應該還會提到)。當前來說,我希望讀者們能有一個大概的印象就是,用代碼聲明定義出來的類型,當然可以通過一種數(shù)據(jù)結(jié)構(gòu)完整描述出來,并在運行時再得到。

C++ RTTI

而談到C++中的運行時類型系統(tǒng),我們一般會說RTTI(Run-Time Type Identification),只提供了兩個最基本的操作符:

typeid

這個關(guān)鍵字的主要作用就是用于讓用戶知道是什么類型,并提供一些基本對比和name方法,作用也頂多只是讓用戶判斷從屬于不同的類型,所以其實說起來type_info的應用并不廣泛,一般來說也只是把它當作編譯器提供的一個唯一類型Id。

const std::type_info& info = typeid(MyClass);class type_info
{public:
    type_info(type_info const&) = delete;
    type_info& operator=(type_info const&) = delete;    size_t hash_code() const throw();    bool operator==(type_info const& _Other) const throw();    bool operator!=(type_info const& _Other) const throw();    bool before(type_info const& _Other) const throw();    char const* name() const throw();
};

dynamic_cast

該轉(zhuǎn)換符用于將一個指向派生類的基類指針或引用轉(zhuǎn)換為派生類的指針或引用,使用條件是只能用于含有虛函數(shù)的類。轉(zhuǎn)換引用失敗會拋出bad_cast異常,轉(zhuǎn)換指針失敗會返回null。

Base* base=new Derived();
Derived* p=dynamic_cast<Derived>(base);if(p){...}else{...}

dynamic_cast內(nèi)部機制其實也是利用虛函數(shù)表里的類型信息來判斷一個基類指針是否指向一個派生類對象。其目的更多是用于在運行時判斷對象指針是否為特定一個子類的對象。

其他的比如運用模板,宏標記就都是編譯期的手段了。C++在RTTI方面也確實是非常的薄弱,傳說中的標準反射提案也遙遙無期,所以大家就都得八仙過海各顯神通,采用各種方式模擬實現(xiàn)了。C++都能用于去實現(xiàn)別的語言底層,不就是多一個輪子的事嘛。

C++當前實現(xiàn)反射的方案

既然C++本身沒提供足夠的類型信息,那我們就采用各種其他各種額外方式來搜集,并構(gòu)建保存起來之后供程序后繼使用。根據(jù)搜集信息的方式不同,C++的反射方案也有以下流派:

基本思想是采用手動標記。在程序中用手動的方式注冊各個類,方法,數(shù)據(jù)。大概就像這樣:

struct Test
{
    Declare_Struct(Test);
    Define_Field(1, int, a)
    Define_Field(2, int, b)
    Define_Field(3, int, c)
    Define_Metadata(3)
};

用宏偷梁換柱的把正常的聲明換成自己的結(jié)構(gòu)。簡單可見這種方式還比較的原始,寫起來也非常的繁瑣。因此往往用的不多。更重要的是往往需要打破常規(guī)的書寫方式,因此常常被摒棄掉。

模板

C++中的模板應該算是最大區(qū)別于別的語言的一個大殺器,引導其強大的編譯器類型識別能力構(gòu)建出相應的數(shù)據(jù)結(jié)構(gòu),理論上也是可以實現(xiàn)出類型系統(tǒng)的一部分功能。舉一個Github實現(xiàn)比較優(yōu)雅的C++RTTI反射庫做例子:rttr

#include <rttr/registration>using namespace rttr;struct MyStruct { MyStruct() {}; void func(double) {}; int data; };
RTTR_REGISTRATION
{
    registration::class_<MyStruct>("MyStruct")
         .constructor<>()
         .property("data", &MyStruct::data)
         .method("func", &MyStruct::func);
}

說實話,這寫得已經(jīng)非常簡潔優(yōu)雅了。算得上是達到了C++模板應用的巔峰。但是可以看到,仍然需要一個個的手動去定義類并獲取方法屬性注冊。優(yōu)點是輕量程序內(nèi)就能直接內(nèi)嵌,缺點是不適合懶人。

編譯器數(shù)據(jù)分析

還有些人就想到既然C++編譯器編譯完整個代碼,那肯定是有完整類型信息數(shù)據(jù)的。那能否把它們轉(zhuǎn)換保存起來供程序使用呢?事實上這也是可行的,比如@vczh的GacUI里就分析了VC編譯生成后pdb文件,然后抽取出類型定義的信息實現(xiàn)反射。VC確實也提供了IDiaDataSource COM組件用來讀取pdb文件的內(nèi)容。用法可以參考:GacUI Demo:PDB Viewer(分析pdb文件并獲取C++類聲明的詳細內(nèi)容)。
理論上來說,只要你能獲取到跟編譯器同級別的類型信息,你基本上就像是全知了。但是缺點是分析編譯器的生成數(shù)據(jù),太過依賴平臺(比如只能VC編譯,換了Clang就是另一套方案),分析提取的過程往往也比較麻煩艱深,在正常的編譯前需要多加一個編譯流程。但優(yōu)點也是得到的數(shù)據(jù)最是全面。
這種方案也因為太過麻煩,所以業(yè)內(nèi)用的人不多。

工具生成代碼

自然的有些人就又想到,既然宏和模板的方法,太過麻煩。那我能不能寫一個工具來自動完成呢?只要分析好C++代碼文件,或者分析編譯器數(shù)據(jù)也行,然后用預定義好的規(guī)則生成相應的C++代碼來跟源文件對應上。
一個好例子就是Qt里面的反射:

#include <QObject>class MyClass : public QObject{    Q_OBJECT  Q_PROPERTY(int Member1 READ Member1 WRITE setMember1 )  Q_PROPERTY(int Member2 READ Member2 WRITE setMember2 )  Q_PROPERTY(QString MEMBER3 READ Member3 WRITE setMember3 )  public:
      explicit MyClass(QObject *parent = 0);
  signals:
  public slots:
  public:
    Q_INVOKABLE int Member1();
    Q_INVOKABLE int Member2();
    Q_INVOKABLE QString Member3();
    Q_INVOKABLE void setMember1( int mem1 );
    Q_INVOKABLE void setMember2( int mem2 );
    Q_INVOKABLE void setMember3( const QString& mem3 );
    Q_INVOKABLE int func( QString flag );
  private:
    int m_member1;
    int m_member2;
    QString m_member3;
 };

大概過程是Qt利用基于moc(meta object compiler)實現(xiàn),用一個元對象編譯器在程序編譯前,分析C++源文件,識別一些特殊的宏Q_OBJECT、Q_PROPERTY、Q_INVOKABLE……然后生成相應的moc文件,之后再一起全部編譯鏈接。

UE里UHT的方案

不用多說,你們也能想到UE當前的方案也是如此,實現(xiàn)在C++源文件中空的宏做標記,然后用UHT分析生成generated.h/.cpp文件,之后再一起編譯。

UCLASS()class HELLO_API UMyClass : public UObject
{
    GENERATED_BODY()public:
    UPROPERTY(BlueprintReadWrite, Category = "Test")    float Score;

    UFUNCTION(BlueprintCallable, Category = "Test")    void CallableFuncTest();

    UFUNCTION(BlueprintNativeEvent, Category = "Test")    void NavtiveFuncTest();

    UFUNCTION(BlueprintImplementableEvent, Category = "Test")    void ImplementableFuncTest();
};

這種方式的優(yōu)點是能夠比較小的對C++代碼做修改,所要做的只是在代碼里加一些空標記,并沒有破壞原來的類聲明結(jié)構(gòu),而且能夠以比較友好的方式把元數(shù)據(jù)和代碼關(guān)聯(lián)在一起,生成的代碼再復雜也可以對用戶隱藏起來。一方面分析源碼得力的話能夠得到和編譯器差不多的信息,還能通過自己的一些自定義標記來提供更多生成代碼的指導。缺點是實現(xiàn)起來其實也是挺累人的,完整的C++的語法分析往往是超級復雜的,所以限制是自己寫的分析器只能分析一些簡單的C++語法規(guī)則和宏標記,如果用戶使用比較復雜的語法時候,比如用#if /#endif包裹一些聲明,就會讓自己的分析器出錯了,還好這種情況不多。關(guān)于多一次編譯的問題,也可以通過自定義編譯器的編譯腳本UBT來規(guī)避。

如果是熟悉C#的朋友,一眼就能看出來這和C#的Attribute的語法簡直差不多一模一樣,所以UE也是吸收了C#語法反射的一些優(yōu)雅寫法,并利用上了C++的宏魔法,當然生成的代碼里模板肯定也是少不了的。采取眾長最后確定了這種類型信息搜集方案。

總結(jié)

本篇主要是解釋了為何要以類型系統(tǒng)作為搭建Object系統(tǒng)的第一步,并描繪了C#語言里完善的類型系統(tǒng)看起來是什么樣子,接著討論了C++當前的RTTI工具,然后環(huán)顧一下當前C++業(yè)內(nèi)的各種反射方案。知道別人家好的是什么樣子,知道自己現(xiàn)在手里有啥,知道當前業(yè)內(nèi)別人家是怎么嘗試解決這個問題的,才能心中有數(shù)知道為何UE選擇了目前的方案,知道UE的這套方案在業(yè)內(nèi)算是什么水平。

依然說些廢話,筆者一向認為想解釋清楚一件東西,更多的應該是解釋清楚背后的各種概念。否則對著源碼,羅列出來各個類,說一下每個接口的作用,數(shù)據(jù)互相怎么引用,流程是怎么跑的,你能很快的就知道一大堆信息。你只是知道了What,How,但是還擋不住別人問一句Why。而功力的提升就在于問一個個why中,A辦法能做,B辦法也行,那為什么最后選了C方法?想要回答這個問題,你就得朔古至今,旁征博引,了解各種方法的理念,優(yōu)劣點,偏重傾向,綜合起來才能更好的進行權(quán)衡。而設計,就是權(quán)衡的藝術(shù)。這么寫起來也確實有點慢,但是個人權(quán)衡一下還是系統(tǒng)性更加的重要。寧愿慢點,質(zhì)量第一。

下篇,我們將開始講述在UE里是怎么組織類型信息數(shù)據(jù)。

引用