垃圾分類,從我做起!
引言
上篇我們談到了為何設計一個Object系統要從類型系統開始做起,並探討了C#的實現,以及C++中各種方案的對比,最后得到的結論是UE采用UHT的方式搜集並生成反射所需代碼。接下來我們就應該開始着手設計真正的類型系統結構。
在之后的敘述中,我會同時用兩個視角來考察UE的這套Object系統:
一是以一個通用的游戲引擎開發者角度來從零開始設計,設想我們正在自己實現一套游戲引擎(或者別的需要Object系統的框架),在體悟UE的Object系統的同時,思考哪些是真正的核心部分,哪些是后續的錦上添花。踏出一條重建Object系統的路來。
二是以當前UE4的現狀來考量。UE的Object系統從UE3時代就已經存在了(再遠的UE3有知道的前輩還望告知),歷經風雨,修修補補,又經過UE4的大改造,所以一些代碼讀起來很是詰屈聱牙,筆者也並不敢說通曉每一行代碼寫成那樣的原由,只能盡量從UE的角度去思考這么寫有什么用意和作用。同時我們也要記得UE是很博大精深沒錯,但並不代表每一行代碼都完美。整體結構上很優雅完善,但也同樣有很多小漏洞和缺陷,也並不是所有的實現都是最優的。所以也支持讀者們在了解的基礎上進行源碼改造,符合自己本身的開發需求。
PS:類型系統不可避免的談到UHT(Unreal Header Tool,一個分析源碼標記並生成代碼的工具),但本專題不會詳細敘述UHT的具體工作流程和原理,只假定它萬事如我心意,UHT的具體分析后續會有特定章節討論。
設定
先假定我們已經接受了UE的設定:
在c++寫的class(struct一樣,只是默認public而已)的頭上加宏標記,在其成員變量和成員函數也同樣加上宏標記,大概就是類似C#Attribute的語法。在宏的參數可以按照我們自定的語法寫上內容。在UE里我們就可以看到這些宏標記:
#define UPROPERTY(...)
#define UFUNCTION(...)
#define USTRUCT(...)
#define UMETA(...)
#define UPARAM(...)
#define UENUM(...)
#define UDELEGATE(...)
#define UCLASS(...) BODY_MACRO_COMBINE(CURRENT_FILE_ID,_,__LINE__,_PROLOG)
#define UINTERFACE(...) UCLASS()
真正編譯的時候,大體上都是一些空宏。UCLASS有些特殊,一般情況下最后也都是空宏,另外一些情況下會生成一些特定的事件參數聲明等等。不過這暫時跟本文的重點無關。這里重點有兩點,一是我們可以通過給類、枚舉、屬性、函數加上特定的宏來標記更多的元數據;二是在有必要的時候這些標記宏甚至也可以安插進生成的代碼來合成編譯。
我們也暫時不用管UHT到底應該怎么實現,就也先假定有那么一個工具會在每次編譯前掃描我們的代碼,獲知那些標記宏的位置和內容,並緊接着分析下一行代碼的聲明含義,最后生成我們所需要的代碼。
還有兩個小問題是:
為何是生成代碼而不是數據文件?
畢竟C++平台和C#平台不一樣,同時在引用1里的UnrealPropertySystem(Reflection)里也提到了最重要的區分之處:
One of the major benefits of storing the reflection data as generated C++ code is that it is guaranteed to be in sync with the binary. You can never load stale or out of date reflection data since it’s compiled in with the rest of the engine code, and it computes member offsets/etc… at startup using C++ expressions, rather than trying to reverse engineer the packing behavior of a particular platform/compiler/optimization combo. UHT is also built as a standalone program that doesn’t consume any generated headers, so it avoids the chicken-and-egg issues that were a common complaint with the script compiler in UE3.
簡單來說就是避免了不一致性,否則又得有機制去保證數據文件和代碼能匹配上。同時跨平台需求也很難保證結構間的偏移在各個平台編譯器優化的不同導致得差異。所以還不如簡單生成代碼文件一起編譯進去得了。
如果標記應該分析哪個文件?
既然是C++了,那么生成的代碼自然也差不多是.h.cpp的組合。假設我們為類A生成了A.generated.h和A.generated.cpp(按照UE習俗,名字無所謂)。此時A.h一般也都需要Include "A.generated.h",比如類A的宏標記生成的代碼如果想跟A.generated.h里我們生成的代碼來個里應外合的話。另一方面,用戶對背后的代碼生成應該是保持最小驚訝的,用戶寫下了A.h,他在使用的時候自然也會想include "A.h",所以這個時候我們的A.generated.h就得找個方式一起安插進來,最方便的方式莫過於直接讓A.h include A.generated.h了。那既然每個需要分析的文件最后都會include這么一個*.generated.h,那自然就可以把它本身就當作一種標記了。所以UE目前的方案是每個要分析的文件加上該Include並且規定只能當作最后一個include,因為他也擔心會有各種宏定義順序產生的問題。
#include "FileName.generated.h"
如果你一開始想的是給每個文件也標記個空宏,其實倒也無不可,只不過沒有UE這么簡潔。但是比如說你想控制你的代碼分析工具在分析某個特定文件的時候專門定制化一些邏輯,那這種像是C#里AssemblyAttribute的文件宏標記就顯示出作用了。UHT目前不需要所以沒做罷了。
結構
在接受了設定之后,是不是覺得本來這個寫法有點怪的Hello類看起來也有點可愛呢?
#include "Hello.generated.h"
UClass()
class Hello
{
public:
UPROPERTY()
int Count;
UFUNCTION()
void Say();
};
先什么都不管,假裝UHT已經為我們搜集了完善的信息,然后這些信息在代碼里應該怎么儲存?這就要談到一些基本的程序結構了。一個程序,簡單來說,可以認為是由眾多的類型和函數嵌套組成的,類型有基礎類型,枚舉,類;類里面能夠再定義字段和函數,甚至是子類型;函數有輸入和輸出,其內部也依然可以定義子類型。這是C++的規則,但你在支持的時候就可以在上面進行縮減,比如你就可以不支持函數內定義的類型。
先來看看UE里形成的結構:
C++有聲明和定義之分,圖中黃色的的都可以看作是聲明,而綠色的UProperty可以看作是字段的定義。在聲明里,我們也可以把類型分為可聚合其他成員的類型和“原子”類型。
- 聚合類型(UStruct):
- UFunction,只可包含屬性作為函數的輸入輸出參數
- UScriptStruct,只可包含屬性,可以理解為C++中的POD struct,在UE里,你可以看作是一種“輕量”UObject,擁有和UObject一樣的反射支持,序列化,復制等。但是和普通UObject不同的是,其不受GC控制,你需要自己控制內存分配和釋放。
- UClass,可包含屬性和函數,是我們平常接觸到最多的類型
- 原子類型:
- UEnum,支持普通的枚舉和enum class。
- int,FString等基礎類型沒必要特別聲明,因為可以簡單的枚舉出來,可以通過不同的UProperty子類來支持。
把聚合類型們統一起來,就形成了UStruct基類,可以把一些通用的添加屬性等方法放在里面,同時可以實現繼承。UStruct這個名字確實比較容易引起歧義,因為實際上C++中USTRUCT宏生成了類型數據是用UScriptStruct來表示的。
還有個類型比較特殊,那就是接口,可以繼承多個接口。跟C++中的虛類一樣,不同的是UE中的接口只可以包含函數。一般來說,我們自己定義的普通類要繼承於UObject,特殊一點,如果是想把這個類當作一個接口,則需要繼承於UInterface。但是記得,生成的類型數據依然用UClass存儲。從“#define UINTERFACE(...) UCLASS()”就可以看出來,Interface其實就是一個特殊點的類。UClass里通過保存一個TArray<FImplementedInterface> Interfaces數組,其子項又包含UClass* Class來支持查詢當前類實現了那些接口。
最后是定義,在UE里是UProperty,可以理解為用一個類型定義個字段“type instance;”。UE有Property,其Property有子類,子類之多,一屏列不下。實際深入代碼的話,會發現UProperty通過模板實例化出特別多的子類,簡單的如UBoolProperty、UStrProperty,復雜的如UMapProperty、UDelegateProperty、UObjectProperty。后續再一一展開。
元數據UMetaData其實就是個TMap<FName, FString>的鍵值對,用於為編輯器提供分類、友好名字、提示等信息,最終發布的時候不會包含此信息。
為了加深一下概念,我列舉一些UE里的用法,把圖和代碼加解釋一起關聯起來理解的會更深刻些:
#include "Hello.generated.h"
UENUM()
namespace ESearchCase
{
enum Type
{
CaseSensitive,
IgnoreCase,
};
}
UENUM(BlueprintType)
enum class EMyEnum : uint8
{
MY_Dance UMETA(DisplayName = "Dance"),
MY_Rain UMETA(DisplayName = "Rain"),
MY_Song UMETA(DisplayName = "Song")
};
USTRUCT()
struct HELLO_API FMyStruct
{
GENERATED_USTRUCT_BODY()
UPROPERTY(BlueprintReadWrite)
float Score;
};
UCLASS()
class HELLO_API UMyClass : public UObject
{
GENERATED_BODY()
public:
UPROPERTY(BlueprintReadWrite, Category = "Hello")
float Score;
UFUNCTION(BlueprintCallable, Category = "Hello")
void CallableFuncTest();
UFUNCTION(BlueprintCallable, Category = "Hello")
void OutCallableFuncTest(float& outParam);
UFUNCTION(BlueprintCallable, Category = "Hello")
void RefCallableFuncTest(UPARAM(ref) float& refParam);
UFUNCTION(BlueprintNativeEvent, Category = "Hello")
void NativeFuncTest();
UFUNCTION(BlueprintImplementableEvent, Category = "Hello")
void ImplementableFuncTest();
};
UINTERFACE()
class UMyInterface : public UInterface
{
GENERATED_UINTERFACE_BODY()
};
class IMyInterface
{
GENERATED_IINTERFACE_BODY()
UFUNCTION(BlueprintImplementableEvent)
void BPFunc() const;
virtual void SelfFunc() const {}
};
先不用去管宏里面參數的含義,目前先形成大局的印象。但是注意,我這里沒有提到藍圖里可以創建的枚舉、接口、結構、類等。它們也都是相應的從各自UEnum、UScriptStruct、UClass再派生出來。這個留待之后再講。讀者們需要明白的是,一旦我們能夠用數據來表達類型了,我們就可以自定義出不同的數據來動態創建出不同的其他類型。
思考:為什么還需要基類UField?
UStruct好理解,表示聚合類型。那為什么不直接UProperty、UStruct、UEnum繼承於UObject?在筆者看來,主要有三點:
- 為了統一所有的類型數據,如果所有的類型數據類都有個基類的話,那么我們就很容易用一個數組把所有的類型數據都引用起來,可以方便的遍歷。另外也關乎到一個順序的問題,比如在類型A里定義了P1、F1、P2、F2,屬性和函數交叉着定義,在生成類型A的類型數據UClass內部就也可以是以同樣的順序,以后要是想回溯出來一份定義,也可以跟原始的代碼順序一致,如果是用屬性和函數分開保存的話,就會麻煩一些。
- 如上圖可見,所有的不管是聲明還是定義(UProperty、UStruct、UEnum),都可以附加一份額外元數據UMetaData,所以應該在它們的基類里保存。
- 方便添加一些額外的方法,比如加個Print方法打印出各個字段的聲明,就可以在UField里加上虛方法,然后在子類里重載實現。
UField名字顧名思義,就是不管是聲明還是定義,都可以看作是類型系統里的一個字段,或者叫領域也行,術語不同,但能理解到一個更抽象統一的意思就行。
思考:為什么UField要繼承於UObject?
這問題,其實也是在問,為什么類型數據也要同樣繼承於UObject?反過來問,如果不繼承會怎么樣?把繼承鏈斷開,類型數據自成一派,其實也未嘗不可。我們來列舉一下UObject身上有哪些功能,看看哪些是類型系統所需要的。
- GC,可有可無,類型數據一開始分配了就可以不釋放,當前GC也是利用了類型系統來支持對象引用遍歷
- 反射,略
- 編輯器集成,也可以沒有,編輯器就是利用類型數據來進行集成編輯的,當然當我們在藍圖里創建函數變量等操作其實也可以看作就是在編輯類型數據。
- CDO,不需要,每個類型的類型數據一般只有一份,CDO是用在對象身上的
- 序列化,必須有,類型數據當然需要保存下來,比如藍圖創建的類型。
- Replicate,用處不大,因為目前網絡間的復制也是利用了類型數據來進行的,類型數據本身的字段的改變復制想來好像沒有什么應用場景
- RPC,也無所謂
- 自動屬性更新,也不需要,類型數據一般不會那么頻繁變動
- 統計,可有可無
總結下來,發現序列化是最重要的功能,GC和其他一些功能算是錦上添花。所以歸結起來可有可無再加上一些必要功能,本着統一的思想,就讓所有類型數據也都繼承於UObject了,這樣序列化等操作也不需要寫兩套。雖然這看起來不是那么的純粹,但是總體上來說利大於弊。
在對象上,你可以用Instance->GetClass()來獲得UClass對象,在UClass本身上調用GetClass()返回的是自己本身,這樣可以用來區分對象和類型數據。
總結
UE的這套類型數據組織架構,以我目前的了解和知識,私以為優雅程度有80/100分。大體上可用,沒什么問題,從UE3時代修修改改過來,我覺得已經很不容易了。只是很多地方從技術角度上來說,不是那么的純粹,比如接口的類型數據也依然是UClass,但是卻又不允許包含屬性,這個從結構上就沒有做限制,只能通過UHT檢查和代碼中類型判斷來區分;又比如UStruct里包含的是UField鏈表,其實隱含的意思就是UStruct里既可以有嵌套類型又可以有屬性,靈活的同時也少了限制,嵌套類型目前是沒有了,但是UFunction也只能包含屬性,UScriptStruct只有屬性而不能有函數;還有UStruct里用UStruct* SuperStruct指向繼承的基類。但是UFunction的基Function是什么意義?所以之后如有含糊之時,讀者朋友們可以用下面這個圖結構來清醒一下:
可以簡單理解這就是UE想表達的真正含義。UMetaData雖然在UPackage里用TMap<UObject*,TMap<FName, FString>>來映射,但是實際上也只有UField里有GetMetaData的接口,所以一般UMetaData也只是跟UField關聯罷了。UStruct包含UProperty,UClass和UScriptStruct又包含UFunction,這才是一般實操時用到的數據關聯。
含糊之處當然無傷大雅,只不過如果讀者作為一個通用引擎研究開發者而言,也要認識到UE的系統的不足之處,不可一一照抄。讀者如果自己想要實現的話,左右有兩種方向,一種是向着類型單一,但是更多用邏輯來控制,比如C#的類型系統,一個Type之下可以獲得各種FieldInfo、MethodInfo等;另一種是向着類型細分,用結構來限制,比如增加UScriptInterface來表達Interface的元數據,把包含屬性和函數的功能封裝成PropertyMap和FunctionMap,然后讓UScriptStruct、UFunction、UClass擁有PropertyMap,讓UClass,UScriptInterface擁有FunctionMap。都有各自的利弊和靈活度不同,這里就不展開一一細說了,讀者們可以自己思考權衡。
我們當前更關注是如何理解UE這套類型系統(也叫屬性系統,為了和圖形里的反射作區分),所以下篇我們將繼續深入,了解UE里怎么開始開始構建這個結構。
上篇:類型系統概述
引用
- UnrealPropertySystem(Reflection)
- 虛幻4屬性系統(反射)翻譯 By 風戀殘雪
- Classes
- Interfaces
- Functions
- Properties
- Structs
UE4.14.2
知乎專欄:InsideUE4
UE4深入學習QQ群:456247757(非新手入門群,請先學習完官方文檔和視頻教程)
微信公眾號:aboutue,關於UE的一切新聞資訊、技巧問答、文章發布,歡迎關注。
個人原創,未經授權,謝絕轉載!