反射,是指程序在运行时进行自检的的能力,在编辑器的属性面板、序列化、GC 等方面非常有用。但是 C++ 语言本身不支持反射特性,UE 在 C++ 的语法基础上通过 UHT 实现了反射信息的生成,从而实现了运行时的反射的目的。
在之前的文章中,有一些涉及到 UE 的构建系统和反射相关的内容。
涉及了 UE 的构建系统文章:
基于 UE 的反射机制来做一些奇淫巧技的文章:
UE 的反射实现是依赖于构建系统中 UHT 来执行代码生成的,本篇文章对 UE 的反射做一个基础概念介绍,后续会花几篇文章完整地介绍 UE 里反射的实现机制。
UE 的反射可以实现 Enum 的反射 (UEnum
)、类反射(UClass
)、结构反射(UStruct
)、数据成员反射(UProperty
/FProperty
)、成员函数反射(UFunction
),可以在运行时访问到它们,其实反射被称作 属性系统 应该更合适。
可以根据这些反射信息来获取它们的类型信息,本篇文章以类反射为例子介绍一下 UE 的反射。
如以下纯 C++ 代码:
1 | class ClassRef |
想要在运行时获取 ClassRef
类有哪些数据成员、函数,要如何操作?
C++ 原生并没有提供这样的能力,相同的需求在 UE 中创建的类是以下形式:
1 |
|
其中关键需要注意的点:
RefObject.generated.h
文件- UCLASS 标记
- GENERATED_BODY 标记
- UPROPERTY 标记
- UFUNCTION 标记
本文不对它们的具体含义做过多的介绍,后续的文章会做详细的分析。
UCLASS
/USTRUCT
/UFUNCTION
/UPROPERTY
等可以在 ()
中添加很多的标记值以及 meta 参数,用于指导 UHT 来生成对应的反射代码,它们支持的参数可以在 UE 的文档中查看:
这种通过添加的 代码标记 来告诉 UE 的构建系统,由 UHT 来生成反射的代码,反射的代码保存在 gen.cpp
中,注意这些反射标记 只是 用来告诉 UHT 来生成代码的,在经过 C++ 的预处理阶段后它们大多都是空宏(有些是真的 C++ 宏),这也导致 UE 的反射标记有一个缺点:无法使用 C++ 的宏来包裹 UE 的反射标记,因为它们先于 预处理 执行。
而且,UHT 只是简单粗暴的关键字匹配硬扫描,限制很大。
对于继承自 UObject 的类而言,它的反射信息被创建出了一个 UClass 对象,可以通过这个对象在运行时获取对象类型的信息。并且,类内部的 反射数据成员 和反射成员函数 ,都会给生成对应的FProperty
和UFunction
对象,用来运行时访问到它们。
UClass 的继承关系:
UObjectBase
UObjectBaseUtility
UObject
UField
UStruct
UClass
针对继承自 UObject 的类,可以通过 GetClass()
来获取 UClass 实例,但是如果想直接获取某个类型的 UClass,则可以通过 StaticClass<UObject>
或者 UObject::StaticClass()
来获取。
UClass 中记录这类的继承关系、实现的接口、各种 Flag 等等,具体可以直接查阅 UClass 的类定义,通过它可以访问到该 UObject 的 C++ 类型中的信息。
而且,在运行时可以通过 TFieldIterator
来遍历 UClass 中的反射属性:
1 | URefObject::URefObject(const FObjectInitializer& Initializer):Super(Initializer) |
执行结果:
1 | LogTemp: Property Name: ival |
那么如何通过属性和成员函数的反射信息来访问到它们呢?
访问数据成员
首先,在 C++ 中类内存布局中是编译时固定的,所以一个数据成员在类中的位置是固定的,C++ 有一个特性叫做 指向类成员的指针,本质上就是描述了当前数据成员在类布局内的偏移值。这部分内容在我之前的文章中有介绍:C++ 中指向类成员的指针并非指针。
FProperty 做的就是类似的事情,记录反射数据成员的类内偏移信息,UE 中的实现也是通过 指向成员的指针 来实现的,这部分后面的文章会着重介绍,这里只介绍使用方法。
通过 FProperty 获取对象中值的方式,需要通过调用 FProperty
的ContainerPtrToValuePtr
来实现:
1 | for(TFieldIterator<FProperty> PropertyIter(GetClass());PropertyIter;++PropertyIter) |
这样就实现了通过 FProperty 来访问数据成员的目的,因为获取到的是数据成员的指针,所以修改它也是没问题的。
访问成员函数
通过反射访问函数则要复杂一些,因为要处理参数传递和返回值的接收问题。
前面已经提到了,UE 的反射成员函数会生成 UFunction
对象,函数的反射信息就在它里面,因为 UFUNCTION 是只能标记在继承自 UObject 的类中,所以 UE 封装了一套基于 UObject 的反射函数调用方式:
1 | // defined in UObject |
只有两个参数,第一个是传入 UFunction 的指针,第二个是 void*
指针,作为通用的方式来传递参数和接收返回值。
对于标记为 UFUNCTION 的函数,UHT 会为该函数生成一个 Thunk 函数,形如以下形式:
1 | DEFINE_FUNCTION(URefObject::execfunc) |
把 DEFINE_FUNCTION
宏展开之后就是固定的原型了:
1 |
|
在 ProcessEvent
会按照这个统一的原型去调用反射函数的 Thunk 函数,在从 Thunk 函数转发给真正的 C++ 函数,实现基于反射调用到真正 C++ 函数的目的,当然也可以通过在 UFUNCTION
中添加 CustomThunk
标记,不让 UHT 生成函数的 Thunk 函数,自己手动提供,可以用来解决特殊的需求(如 unlua 中的覆写)。
现在回到ProcessEvent
,它的函数原型中有三个关键点:
ProcessEvent
是UObject
的成员函数ProcessEvent
的第一个参数要是当前类中的 UFunctionvoid* Parms
必须要是连续内存结构
着重要讲的就是 void* Parms
怎么用。
首先,对于示例函数:
1 | bool func(int32 InIval); |
它接收一个参数,并返回一个 bool 值,如何通过一个参数来同时做这两件事情呢?
把他们封装为一个结构!如同下面这种形式:
1 | struct RefObject_eventFunc_Parms |
要传递给函数的参数排在前面,返回值为最后一个数据成员(如果有的话)。而且,也是可以通过 UFunction::ParmsSize
得到当前函数所有参数 + 返回值结构的大小,在运行时动态分配,并且可以通过 UFunction
的各个参数 FProperty
来访问到每个参数的内存,这部分内容暂时按下不表,后面的文章会详细的介绍。
所以,当我们通过 UClass
拿到指定的 UFunction 之后就可以做这个事情了:
1 | for(TFieldIterator<UFunction> PropertyIter(GetClass());PropertyIter;++PropertyIter) |
但是这样基于反射机制的函数调用有一个问题:无法处理参数和返回值为引用的情况。如:
1 | UFUNCTION() |
这两个函数生成的反射信息一摸一样!(对 L 参数生成的 FProperty 的 Flag 会多一个 CPF_OutParm
,返回值的 FProperty 还具有 CPF_ReturnParm
)。
因为 C++ 的引用必须要在初始化时进行绑定的:
1 | class ClassRef |
这就对 UHT 的参数结构造成了限制,因为默认情况下 UHT 给每个反射函数生成了参数结构,是通过先实例化,再把真正的参数赋值给该实例中成员的,就跟上面的例子一样,做了一次值拷贝。而引用无法修改,只能在初始化时设置。在调用 ProcessEvent 时,真正调用到 C++ 函数的时候,如果参数有引用则是绑定到的该 UHT 生成的结构中数据成员,而不是我们真实传递的参数。
基于反射的函数调用实际上进行了两步操作:
- 把参数赋值给 ProcessEvent 的函数参数结构
- ProcessEvent 再把参数结构传递给真正的 C++ 函数
这会造成通过 UFunction*
调用 ProcessEvent 传递的参数和想要获取的返回值也都只是原始参数的一份拷贝,而不是真正绑定的引用关系(因为本来调用时的参数传递到 ProcessEvent 之前都会被赋值到 UHT 创建出来的参数结构),在真正的 C++ 函数中对引用参数的修改也只能改动 UHT 生产的参数结构实例,而非我们传递的真正的参数。
本篇文章对 UE 的反射做了一个简单的介绍,并示例了通过 UClass
获取反射信息来访问数据成员和成员函数的方式,UE 中反射的实现会在后续的文章中详细介绍,下一篇会介绍 UE 反射实现所依赖的 C++ 特性,目前文章已发表:UE4 反射实现分析:C++ 特性。
参考资料: