《InsideUE4》UObject(二)類型系統概述

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

引言

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

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

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

類型系統

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

所以以後更多用「類型系統」這個更精確的術語來表述object之外的類型信息構建,而用「反射」這個術語來描述運行時得到類型的功能,通過類型信息反過來創建對象,讀取修改屬性,調用方法的功能行為。反射更多是一種行為能力,更偏向動詞。類型系統指的是程序運行空間內構建出來的類型信息樹組織,

C# Type

因C++本身運行時類型系統的疲弱,所以我們首先拿一個已經實現完善的語言,來看看其最後成果是什麼樣子。這裡選擇了C#而不是java,是因為我認為C#比java更強大優雅(不辯),Unity用C#作為腳本語言,UE本身也是用C#作為編譯UBT的實現語言。

在C#里,你可以通過以下一行代碼方便的得到類型信息:

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

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

  1. Assembly是程序集的意思,通常指的是一個dll。
  2. Module是程序集內部的子模塊劃分。
  3. Type就是我們最關心的Class對象了,完整描述了一個對象的類型信息。並且Type之間也可以通過BaseType,DeclaringType之類的屬性來互相形成Type關係圖。
  4. ConstructorInfo描述了Type中的構造函數,可以通過調用它來調用特定的構造函數。
  5. EventInfo描述了Type中定義的event事件(UE中的delegate大概)
  6. FiedInfo描述了Type中的欄位,就是C++的成員變數,得到之後可以動態讀取修改值
  7. PropertyInfo描述了Type中的屬性,類比C++中的get/set方法組合,得到後可以獲取設置屬性值。
  8. MethodInfo描述了Type中的方法。獲得方法後就可以動態調用了。
  9. ParameterInfo描述了方法中的一個個參數。
  10. Attributes指的是Type之上附加的特性,這個C++里並沒有,可以簡單理解為類上的定義的元數據信息。

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

C++ RTTI

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

typeid

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

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

dynamic_cast

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

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

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

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

C++當前實現反射的方案

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

基本思想是採用手動標記。在程序中用手動的方式註冊各個類,方法,數據。大概就像這樣:

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

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

模板

C++中的模板是應該也可以算是最大區別於別的語言的一個大殺器了,引導其強大的編譯器類型識別能力構建出相應的數據結構,理論上也是可以實現出一定的類型系統。舉一個Github實現比較優雅的C++RTTI反射庫做例子:rttr

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

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

編譯器數據分析

還有些人就想到既然C++編譯器編譯完整個代碼,那肯定是有完整類型信息數據的。那能否把它們轉換保存起來供程序使用呢?事實上這也是可行的,比如@vczh的GacUI里就分析了VC編譯生成後pdb文件,然後抽取出類型定義的信息實現反射。VC確實也提供了IDiaDataSource COM組件用來讀取pdb文件的內容。用法可以參考:GacUI Demo:PDB Viewer(分析pdb文件並獲取C++類聲明的詳細內容)。

理論上來說,只要你能獲取到跟編譯器同級別的類型信息,你基本上就像是全知了。但是缺點是分析編譯器的生成數據,太過依賴平台(比如只能VC編譯,換了Clang就是另一套方案),分析提取的過程往往也比較麻煩艱深,在正常的編譯前需要多加一個編譯流程。但優點也是得到的數據最是全面。

這種方案也因為太過麻煩,所以業內用的人不多。

工具生成代碼

自然的有些人就又想到,既然宏和模板的方法,太過麻煩。那我能不能寫一個工具來自動完成呢?只要分析好C++代碼文件,或者分析編譯器數據也行,然後用預定義好的規則生成相應的C++代碼來跟源文件對應上。

一個好例子就是Qt裡面的反射:

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

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

UE里UHT的方案

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

UCLASS()nclass HELLO_API UMyClass : public UObjectn{ntGENERATED_BODY()npublic:ntUPROPERTY(BlueprintReadWrite, Category = "Test")ntfloat Score;nntUFUNCTION(BlueprintCallable, Category = "Test")ntvoid CallableFuncTest();nntUFUNCTION(BlueprintNativeEvent, Category = "Test")ntvoid NativeFuncTest();nntUFUNCTION(BlueprintImplementableEvent, Category = "Test")ntvoid ImplementableFuncTest();n};n

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

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

總結

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

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

上篇:UObject(一)開篇

下篇:UObject(三)類型系統設定和結構

引用

  1. std::type_info
  2. How Qt Signals and Slots Work

UE4.14.1

---------------------------------------------------------------------------------------------------------------------------

知乎專欄:InsideUE4

UE4深入學習QQ群:456247757(非新手入門群,請先學習完官方文檔和視頻教程)

微信公眾號:aboutue,關於UE的一切新聞資訊、技巧問答、文章發布,歡迎關注。

個人原創,未經授權,謝絕轉載!

推薦閱讀:

Monster Farm 開發日誌 (六)基於MarkovChain的隨機名字生成器
The world at your fingertips — 天涯明月刀幕後2(破局)
觸樂Live專題上線:和三名熱愛遊戲的玩家一起打破「遊戲無用論」
元旦快樂,送大家一條水晶龍

TAG:虚幻引擎 | 游戏引擎 | 游戏开发 |