UE4中C++反射概述

UObject and UHT

Posted by bbkgl on February 4, 2021

夜深忽梦少年事

梦啼妆泪红阑干

之前研究C/C++调试器的时候,有思考过C++反射实现的问题。按照我不成熟的设想,反射必须付出内存开销的代价,因为反射需要一定的格式化调试信息描述内存布局。不过之前在知乎上看到过,C++标准协会想实现的反射是编译期反射,目标还是零开销抽象,听说进度喜人。

用Unity的时候,C#的反射就非常好用,不过C#是直接作为Unity引擎交互的唯一脚本存在的,且C#有Unity嵌入的mono虚拟机支持,本就具有很多的动态特性,所以反射的作用并不是很明显,而UE4中,C++作为静态纯编译型语言,需要与蓝图进行频繁交互,同时与编辑器交互,反射的作用就凸显出来了。

这几篇文章,我选择从反射开始讲,因为反射是UE4中C++动态特性的基础,后面的蓝图虚拟机函数/事件调用也都基于此。当然反射也是基于UObject对象系统来实现的,这两部分让很多开发者在使用UE4 C++的时候觉得非常奇怪,当然也非常便利。

以下的所有结论都非权威,仅菜鸡查阅资料及源码的一些心得,大佬亲喷。

如果要实现反射

关于运行时反射,应该至少需要记录以下信息。

类/结构体的反射

  1. 对象的地址
  2. 类对象的类型/内存布局信息

成员变量反射

  1. 成员变量的地址
  2. 成员变量的名字
  3. 成员变量的类型/内存布局信息

成员函数反射

  1. 成员函数的地址
  2. 成员函数的名字
  3. 成员变量的签名信息

要完成以上的信息记录,一般可以通过一个登记/注册操作来实现,即将上述信息全部注册/登记到一个表中。在其他语言C#/Java中,这个表就对应元数据或者元表。

img

学过编译原理的同学都知道,以上说到的这些信息,其实在编译器编译的时候都是有收集的,只是结束后这些信息可能并没有被完全利用起来。

为什么是没有完全利用,而不是完全不利用?因为我们还有程序的调试需求,ELF文件内置有dwarf格式的信息,而PE文件会对应有pdb文件。用gdb的时候是不是发现,在gdb里调试的很多行为,和使用反射的姿势,是不是有异曲同工之妙呢?

比较可惜的是,各家编译器(GCC/LLVM/MSVC)并没有统一格式(其实GCC和CLANG是统一的),所以通过编译信息实现反射就是非常麻烦的一件事情了。

车到山前必有路,这时候就会想到一种办法了,既然各家编译器没有统一的编译信息格式,那可不可以自己咬咬牙写个工具分析源代码去做这件事情呢?

当然可以。不过在UE4中生成的并不是数据,而是代码。

UE4中反射原理概述(UHT/UObject)

后面会通过一个例子进行讲解,这里先大概描述一下为了实现反射,UE4引擎中的一些设计。

目前从我的了解来看,UE4中的反射和QT的反射应该是拥有同样的设计理念,或者说方式都是一样的。之前实习的时候用C# 的反射做过 GameObject 远程调试工具,其实就是远程的属性编辑器,发现反射对属性编辑器来说应该是不可或缺的。

以我才疏学浅,且未必正确的认知来看,反射应该有两大基础:

  1. 统一的Object对象系统,即所有的反射对象都要继承自父类Object
  2. 统一的元数据生成,即需要反射的所有对象都需要被记录内存信息

在UE4中,“统一的Object对象系统” 就是 UObject,而“统一的元数据生成”,就是 UHT(unreal header tool)

直接看UE4中自动生成的C++代码就能初见端倪:

1612596885294

实际上 ACharacter 就是继承自 UObject

1612596984927

而见到的以下这些宏,就是用于生成元数据的(UE4中的元代码)。

1612597103991

当然这些宏,就离不开下面这张图了:

img

这张图表示的是各个类的继承关系,同名的类基本就对应同名的宏。

  • UFunction -> UFUNCTION
  • UProperty -> UProperty
  • UClass -> UClass

后续将进行详细的分析,并结合UHT的类型生成代码。