在一起!在一起!
引言
前文中我們闡述了類型系統構建的第一個階段:生成。UHT分析源碼的宏標記並生成了包含程序元信息的代碼,繼而編譯進程序,在程序啟動的時候,開始啟動類型系統的后續構建階段。而本文我們將介紹類型信息的收集階段。
C++ Static 自動注冊模式
另一種常用的C++常用的設計模式:Static Auto Register。典型的,當你想要在程序啟動后往一個容器里注冊一些對象,或者簿記一些信息的時候,一種直接的方式是在程序啟動后手動的一個個調用注冊函數:
#include "ClassA.h"
#include "ClassB.h"
int main()
{
ClassFactory::Get().Register<ClassA>();
ClassFactory::Get().Register<ClassB>();
[...]
}
這種方式的缺點是你必須手動的一個include之后再手動的一個個注冊,當要繼續添加注冊的項時,只能再手動的依次序在該文件里加上一條條目,可維護性較差。
所以根據C++ static對象會在main函數之前初始化的特性,可以設計出一種static自動注冊模式,新增加注冊條目的時候,只要Include進相應的類.h.cpp文件,就可以自動在程序啟動main函數前自動執行一些操作。簡化的代碼大概如下:
//StaticAutoRegister.h
template<typename TClass>
struct StaticAutoRegister
{
StaticAutoRegister()
{
Register(TClass::StaticClass());
}
};
//MyClass.h
class MyClass
{
//[...]
};
//MyClass.cpp
#include "StaticAutoRegister.h"
const static StaticAutoRegister<MyClass> AutoRegister;
這樣,在程序啟動的時候就會執行Register(MyClass),把因為新添加類而產生的改變行為限制在了新文件本身,對於一些順序無關的注冊行為這種模式尤為合適。利用這個static初始化特性,也有很多個變種,比如你可以把StaticAutoRegister聲明進MyClass的一個靜態成員變量也可以。不過注意的是,這種模式只能在獨立的地址空間才能有效,如果該文件被靜態鏈接且沒有被引用到的話則很可能會繞過static的初始化。不過UE因為都是dll動態鏈接,且沒有出現靜態lib再引用Lib,然后又不引用文件的情況出現,所以避免了該問題。或者你也可以找個地方強制的去include一下來觸發static初始化。
UE Static 自動注冊模式
而UE里同樣是采用這種模式:
template <typename TClass>
struct TClassCompiledInDefer : public FFieldCompiledInInfo
{
TClassCompiledInDefer(const TCHAR* InName, SIZE_T InClassSize, uint32 InCrc)
: FFieldCompiledInInfo(InClassSize, InCrc)
{
UClassCompiledInDefer(this, InName, InClassSize, InCrc);
}
virtual UClass* Register() const override
{
return TClass::StaticClass();
}
};
static TClassCompiledInDefer<TClass> AutoInitialize##TClass(TEXT(#TClass), sizeof(TClass), TClassCrc);
或者
struct FCompiledInDefer
{
FCompiledInDefer(class UClass *(*InRegister)(), class UClass *(*InStaticClass)(), const TCHAR* Name, bool bDynamic, const TCHAR* DynamicPackageName = nullptr, const TCHAR* DynamicPathName = nullptr, void (*InInitSearchableValues)(TMap<FName, FName>&) = nullptr)
{
if (bDynamic)
{
GetConvertedDynamicPackageNameToTypeName().Add(FName(DynamicPackageName), FName(Name));
}
UObjectCompiledInDefer(InRegister, InStaticClass, Name, bDynamic, DynamicPathName, InInitSearchableValues);
}
};
static FCompiledInDefer Z_CompiledInDefer_UClass_UMyClass(Z_Construct_UClass_UMyClass, &UMyClass::StaticClass, TEXT("UMyClass"), false, nullptr, nullptr, nullptr);
都是對該模式的應用,把static變量聲明再用宏包裝一層,就可以實現一個簡單的自動注冊流程了。
收集
在上文里,我們詳細介紹了Class、Struct、Enum、Interface的代碼生成的信息,顯然的,生成的就是為了拿過來用的。但是在用之前,我們就還得辛苦一番,把散亂分布在各個.h.cpp文件里的元數據都收集到我們想要的數據結構里保存,以便下一個階段的使用。
這里回顧一下,為了讓新創建的類不修改既有的代碼,所以我們選擇了去中心化的為每個新的類生成它自己的cpp生成文件——上文里已經分別介紹每個cpp文件的內容。但是這樣我們就接着迎來了一個新問題:這些cpp文件里的元數據散亂在各個模塊dll里,我們需要用一種方法重新歸攏這些數據,這就是我們在一開頭就提到的C++ Static自動注冊模式了。通過這種模式,每個cpp文件里的static對象在程序一開始的時候就會全部有機會去做一些事情,包括信息的收集工作。
UE4里也是如此,在程序啟動的時候,UE利用了Static自動注冊模式把所有類的信息都一一登記一遍。而緊接着另一個就是順序問題了,這么多類,誰先誰后,互相若是有依賴該怎么解決。眾所周知,UE是以Module來組織引擎結構的(關於Module的細節會在以后章節敘述),一個個Module可以通過腳本配置來選擇性的編譯加載。在游戲引擎眾多的模塊中,玩家自己的Game模塊是處於比較高級的層次的,都是依賴於引擎其他更基礎底層的模塊,而這些模塊中,最最底層的就是Core模塊(C++的基礎庫),接着就是CoreUObject,正是實現Object類型系統的模塊!因此在類型系統注冊的過程中,不止要注冊玩家的Game模塊,同時也要注冊CoreUObject本身的一些支持類。
很多人可能會擔心這么多模塊的靜態初始化的順序正確性如何保證,在c++標准里,不同編譯單元的全局靜態變量的初始化順序並沒有明確規定,因此實現上完全由編譯器自己決定。該問題最好的解決方法是盡可能的避免這種情況,在設計上就讓各個變量不互相引用依賴,同時也采用一些二次檢測的方式避免重復注冊,或者觸發一個強制引用來保證前置對象已經被初始化完成。目前在MSVC平台上是先注冊玩家的Game模塊,接着是CoreUObject,接着再其他,不過這其實無所謂的,只要保證不依賴順序而結果正確,順序就並不重要了。
Static的收集
在講完了收集的必要性和順序問題的解決之后,我們再來分別的看各個類別的結構的信息的收集。依然是按照上文生成的順序,從Class(Interface同理)開始,然后是Enum,接着Struct。接着請讀者朋友們對照着上文的生成代碼來理解。
Class的收集
對照着上文里的Hello.generated.cpp展開,我們注意到里面有:
static TClassCompiledInDefer<UMyClass> AutoInitializeUMyClass(TEXT("UMyClass"), sizeof(UMyClass), 899540749);
//……
static FCompiledInDefer Z_CompiledInDefer_UClass_UMyClass(Z_Construct_UClass_UMyClass, &UMyClass::StaticClass, TEXT("UMyClass"), false, nullptr, nullptr, nullptr);
再一次找到其定義:
//Specialized version of the deferred class registration structure.
template <typename TClass>
struct TClassCompiledInDefer : public FFieldCompiledInInfo
{
TClassCompiledInDefer(const TCHAR* InName, SIZE_T InClassSize, uint32 InCrc)
: FFieldCompiledInInfo(InClassSize, InCrc)
{
UClassCompiledInDefer(this, InName, InClassSize, InCrc); //收集信息
}
virtual UClass* Register() const override
{
return TClass::StaticClass();
}
};
//Stashes the singleton function that builds a compiled in class. Later, this is executed.
struct FCompiledInDefer
{
FCompiledInDefer(class UClass *(*InRegister)(), class UClass *(*InStaticClass)(), const TCHAR* Name, bool bDynamic, const TCHAR* DynamicPackageName = nullptr, const TCHAR* DynamicPathName = nullptr, void (*InInitSearchableValues)(TMap<FName, FName>&) = nullptr)
{
if (bDynamic)
{
GetConvertedDynamicPackageNameToTypeName().Add(FName(DynamicPackageName), FName(Name));
}
UObjectCompiledInDefer(InRegister, InStaticClass, Name, bDynamic, DynamicPathName, InInitSearchableValues);//收集信息
}
};
可以見到前者調用了UClassCompiledInDefer來收集類名字,類大小,CRC信息,並把自己的指針保存進來以便后續調用Register方法。而UObjectCompiledInDefer(現在暫時不考慮動態類)最重要的收集的信息就是第一個用於構造UClass*對象的函數指針回調。
再往下我們會發現這二者其實都只是在一個靜態Array里添加信息記錄:
void UClassCompiledInDefer(FFieldCompiledInInfo* ClassInfo, const TCHAR* Name, SIZE_T ClassSize, uint32 Crc)
{
//...
// We will either create a new class or update the static class pointer of the existing one
GetDeferredClassRegistration().Add(ClassInfo); //static TArray<FFieldCompiledInInfo*> DeferredClassRegistration;
}
void UObjectCompiledInDefer(UClass *(*InRegister)(), UClass *(*InStaticClass)(), const TCHAR* Name, bool bDynamic, const TCHAR* DynamicPathName, void (*InInitSearchableValues)(TMap<FName, FName>&))
{
//...
GetDeferredCompiledInRegistration().Add(InRegister); //static TArray<class UClass *(*)()> DeferredCompiledInRegistration;
}
而在整個引擎里會觸發此Class的信息收集的有UCLASS、UINTERFACE、IMPLEMENT_INTRINSIC_CLASS、IMPLEMENT_CORE_INTRINSIC_CLASS,其中UCLASS和UINTERFACE我們上文已經見識過了,而IMPLEMENT_INTRINSIC_CLASS是用於在代碼中包裝UModel,IMPLEMENT_CORE_INTRINSIC_CLASS是用於包裝UField、UClass等引擎內建的類,后兩者內部也都調用了IMPLEMENT_CLASS來實現功能。
流程圖如下:

思考:為何需要TClassCompiledInDefer和FCompiledInDefer兩個靜態初始化來登記?
我們也觀察到了這二者是一一對應的,問題是為何需要兩個靜態對象來分別收集,為何不合二為一?關鍵在於我們首先要明白它們二者的不同之處,前者的目的主要是為后續提供一個TClass::StaticClass的Register方法(其會觸發GetPrivateStaticClassBody的調用,進而創建出UClass對象),而后者的目的是在其UClass身上繼續調用構造函數,初始化屬性和函數等一些注冊操作。我們可以簡單理解為就像是C++中new對象的兩個步驟,首先分配內存,繼而在該內存上構造對象。我們在后續的注冊章節里還會繼續討論到這個問題。
思考:為何需要延遲注冊而不是直接在static回調里執行?
很多人可能會問,為什么static回調里都是先把信息注冊進array結構里,並沒有什么其他操作,為何不直接把后續的操作直接在回調里調用了,這樣結構反而簡單些。是這樣沒錯,但是同時我們也考慮到一個問題,UE4里大概1500多個類,如果都在static初始化階段進行1500多個類的收集注冊操作,那么main函數必須得等好一會兒才能開始執行。表現上就是用戶雙擊了程序,沒反應,過了好一會兒,窗口才打開。因此static初始化回調里盡量少的做事情,就是為了盡快的加快程序啟動的速度。等窗口顯示出來了,array結構里數據已經有了,我們就可以施展手腳,多線程也好,延遲也好,都可以大大改善程序運行的體驗。
Enum的收集
依舊是上文里的對照代碼,UENUM會生成:
static FCompiledInDeferEnum Z_CompiledInDeferEnum_UEnum_EMyEnum(EMyEnum_StaticEnum, TEXT("/Script/Hello"), TEXT("EMyEnum"), false, nullptr, nullptr);
//其定義:
struct FCompiledInDeferEnum
{
FCompiledInDeferEnum(class UEnum *(*InRegister)(), const TCHAR* PackageName, const TCHAR* Name, bool bDynamic, const TCHAR* DynamicPackageName, const TCHAR* DynamicPathName)
{
if (bDynamic)
{
GetConvertedDynamicPackageNameToTypeName().Add(FName(DynamicPackageName), FName(Name));
}
UObjectCompiledInDeferEnum(InRegister, PackageName, DynamicPathName, bDynamic);
// static TArray<FPendingEnumRegistrant> DeferredCompiledInRegistration;
}
};
在static階段會向內存注冊一個構造UEnum的函數指針用於回調:

注意到這里並不需要像UClassCompiledInDefer一樣先生成一個UClass,因為UEnum並不是一個Class,並沒有Class那么多功能集合,所以就比較簡單一些。
Struct的收集
對於Struct,我們先來看上篇里生成的代碼:
static FCompiledInDeferStruct Z_CompiledInDeferStruct_UScriptStruct_FMyStruct(FMyStruct::StaticStruct, TEXT("/Script/Hello"), TEXT("MyStruct"), false, nullptr, nullptr); //延遲注冊
static struct FScriptStruct_Hello_StaticRegisterNativesFMyStruct
{
FScriptStruct_Hello_StaticRegisterNativesFMyStruct()
{
UScriptStruct::DeferCppStructOps(FName(TEXT("MyStruct")),new UScriptStruct::TCppStructOps<FMyStruct>);
}
} ScriptStruct_Hello_StaticRegisterNativesFMyStruct; //static注冊
同樣是兩個static對象,前者FCompiledInDeferStruct繼續向array結構里登記函數指針,后者有點特殊,在一個結構名和對象的Map映射里登記“Struct相應的C++操作類”(后續解釋)。
struct FCompiledInDeferStruct
{
FCompiledInDeferStruct(class UScriptStruct *(*InRegister)(), const TCHAR* PackageName, const TCHAR* Name, bool bDynamic, const TCHAR* DynamicPackageName, const TCHAR* DynamicPathName)
{
if (bDynamic)
{
GetConvertedDynamicPackageNameToTypeName().Add(FName(DynamicPackageName), FName(Name));
}
UObjectCompiledInDeferStruct(InRegister, PackageName, DynamicPathName, bDynamic);// static TArray<FPendingStructRegistrant> DeferredCompiledInRegistration;
}
};
void UScriptStruct::DeferCppStructOps(FName Target, ICppStructOps* InCppStructOps)
{
TMap<FName,UScriptStruct::ICppStructOps*>& DeferredStructOps = GetDeferredCppStructOps();
if (UScriptStruct::ICppStructOps* ExistingOps = DeferredStructOps.FindRef(Target))
{
#if WITH_HOT_RELOAD
if (!GIsHotReload) // in hot reload, we will just leak these...they may be in use.
#endif
{
check(ExistingOps != InCppStructOps); // if it was equal, then we would be re-adding a now stale pointer to the map
delete ExistingOps;
}
}
DeferredStructOps.Add(Target,InCppStructOps);
}
另外的,搜羅引擎里的代碼,我們還會發現對於UE4里內建的結構,比如說Vector,其IMPLEMENT_STRUCT(Vector)也會相應的觸發DeferCppStructOps的調用。

這里的Struct也和Enum同理,因為並不是一個Class,所以並不需要比較繁瑣的兩步構造,憑着FPendingStructRegistrant就可以后續一步構造出UScriptStruct對象;對於內建的類型(如Vector),因其完全不是“Script”的類型,所以就不需要UScriptStruct的構建,那么其如何像BP暴露,我們后續再詳細介紹。
還有一點注意的是UStruct類型會配套一個ICppStructOps接口對象來管理C++struct對象的構造和析構工作,其用意就在於如果對於一塊已經擦除了類型的內存數據,我們怎么能在其上正確的構造結構對象數據或者析構。這個時候,如果我們能夠得到一個統一的ICppStructOps指針指向類型安全的TCppStructOps<CPPSTRUCT>對象,就能夠通過接口函數動態、多態、類型安全的執行構造和析構工作。
Function的收集
在介紹完了Class、Enum、Struct之后,我們還遺忘了一些引擎內建的函數的信息收集。我們在前文中並沒有介紹到這一點是因為UE已經提供了我們一個BlueprintFunctionLibrary的類來注冊全局函數。而一些引擎內部定義出來的函數,也是散亂分布在各處,也是需要收集起來的。
主要有這兩類:
- IMPLEMENT_CAST_FUNCTION,定義一些Object的轉換函數
IMPLEMENT_CAST_FUNCTION( UObject, CST_ObjectToBool, execObjectToBool );
IMPLEMENT_CAST_FUNCTION( UObject, CST_InterfaceToBool, execInterfaceToBool );
IMPLEMENT_CAST_FUNCTION( UObject, CST_ObjectToInterface, execObjectToInterface );
- IMPLEMENT_VM_FUNCTION,定義一些藍圖虛擬機使用的函數
IMPLEMENT_VM_FUNCTION(EX_CallMath, execCallMathFunction);
IMPLEMENT_VM_FUNCTION( EX_True, execTrue );
//……
而繼而查其定義:
#define IMPLEMENT_FUNCTION(cls,func) \
static FNativeFunctionRegistrar cls##func##Registar(cls::StaticClass(),#func,(Native)&cls::func);
#define IMPLEMENT_CAST_FUNCTION(cls, CastIndex, func) \
IMPLEMENT_FUNCTION(cls, func); \
static uint8 cls##func##CastTemp = GRegisterCast( CastIndex, (Native)&cls::func );
#define IMPLEMENT_VM_FUNCTION(BytecodeIndex, func) \
IMPLEMENT_FUNCTION(UObject, func) \
static uint8 UObject##func##BytecodeTemp = GRegisterNative( BytecodeIndex, (Native)&UObject::func );
/* A struct that maps a string name to a native function */
struct FNativeFunctionRegistrar
{
FNativeFunctionRegistrar(class UClass* Class, const ANSICHAR* InName, Native InPointer)
{
RegisterFunction(Class, InName, InPointer);
}
static COREUOBJECT_API void RegisterFunction(class UClass* Class, const ANSICHAR* InName, Native InPointer);
// overload for types generated from blueprints, which can have unicode names:
static COREUOBJECT_API void RegisterFunction(class UClass* Class, const WIDECHAR* InName, Native InPointer);
};
也可以發現有3個static對象收集到這些函數的信息並登記到相應的結構中去,流程圖為:

其中FNativeFunctionRegistrar用於向UClass里添加Native函數(區別於藍圖里定義的函數),另一個方面,在UClass的RegisterNativeFunc相關函數里,也會把相應的Class內定義的函數添加到UClass內部的函數表里去。
UObject的收集
如果讀者朋友們自己剖析源碼,還會有一個疑惑,作為Object系統的根類,它是怎么在最開始的時候觸發相應UClass的生成呢?答案在最開始的IMPLEMENT_VM_FUNCTION(EX_CallMath, execCallMathFunction)調用上,其內部會緊接着觸發UObject::StaticClass()的調用,作為最開始的調用,檢測到UClass並未生成,於是接着會轉發到GetPrivateStaticClassBody中去生成一個UClass*。

總結
因篇幅有限,本文緊接着上文,討論了代碼生成的信息是如何一步步收集到內存里的數據結構里去的,UE4利用了C++的static對象初始化模式,在程序最初啟動的時候,main之前,就收集到了所有的類型元數據、函數指針回調、名字、CRC等信息。到目前,思路還是很清晰的,為每一個類代碼生成自己的cpp文件(不需中心化的修改既有代碼),進而在其生成的每個cpp文件里用static模式搜羅一遍信息以便后續的使用。這也算是C++自己實現類型系統流行套路之一吧。
在下一個階段——注冊,我們將討論UE4接下來是如何消費利用這些信息的。
引用
UE4.15.1
知乎專欄:InsideUE4
UE4深入學習QQ群:456247757(非新手入門群,請先學習完官方文檔和視頻教程)
微信公眾號:aboutue,關於UE的一切新聞資訊、技巧問答、文章發布,歡迎關注。
個人原創,未經授權,謝絕轉載!
