垃圾分类,从我做起!
引言
上篇我们谈到了为何设计一个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的一切新闻资讯、技巧问答、文章发布,欢迎关注。
个人原创,未经授权,谢绝转载!