UE反射实现分析:基础概念

反射,是指程序在运行时进行自检的的能力,在编辑器的属性面板、序列化、GC等方面非常有用。但是C++语言本身不支持反射特性,UE在C++的语法基础上通过UHT实现了反射信息的生成,从而实现了运行时的反射的目的。

在之前的文章中,有一些涉及到UE的构建系统和反射相关的内容。

涉及了UE的构建系统文章:

基于UE的反射机制来做一些奇淫巧技的文章:

UE的反射实现是依赖于构建系统中UHT来执行代码生成的,本篇文章对UE的反射做一个基础概念介绍,后续会花几篇文章完整地介绍UE里反射的实现机制。

UE的反射可以实现Enum的反射(UEnum)、类反射(UClass)、结构反射(UStruct)、数据成员反射(UProperty/FProperty)、成员函数反射(UFunction),可以在运行时访问到它们,其实反射被称作属性系统应该更合适。

可以根据这些反射信息来获取它们的类型信息,本篇文章以类反射为例子介绍一下UE的反射。

如以下纯C++代码:

1
2
3
4
5
6
class ClassRef
{
public:
int32 ival = 666;
bool func(int32 InIval){ return false;}
};

想要在运行时获取ClassRef类有哪些数据成员、函数,要如何操作?

C++原生并没有提供这样的能力,相同的需求在UE中创建的类是以下形式:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
#pragma once
#include "CoreMinimal.h"
#include "RefObject.generated.h"

UCLASS()
class REF_API URefObject : public UObject
{
GENERATED_BODY()
public:
UPROPERTY()
int32 ival = 666;

UFUNCTION()
bool func(int32 InIval)
{
UE_LOG(LogTemp,Log,TEXT("Function func: %d"),InIval);
return true;
}
};

其中关键需要注意的点:

  1. RefObject.generated.h文件
  2. UCLASS标记
  3. GENERATED_BODY标记
  4. UPROPERTY标记
  5. UFUNCTION标记

本文不对它们的具体含义做过多的介绍,后续的文章会做详细的分析。

UCLASS/USTRUCT/UFUNCTION/UPROPERTY等可以在()中添加很多的标记值以及meta参数,用于指导UHT来生成对应的反射代码,它们支持的参数可以在UE的文档中查看:

这种通过添加的代码标记来告诉UE的构建系统,由UHT来生成反射的代码,反射的代码保存在gen.cpp中,注意这些反射标记只是用来告诉UHT来生成代码的,在经过C++的预处理阶段后它们大多都是空宏(有些是真的C++宏),这也导致UE的反射标记有一个缺点:无法使用C++的宏来包裹UE的反射标记,因为它们先于预处理执行。

而且,UHT只是简单粗暴的关键字匹配硬扫描,限制很大。

对于继承自UObject的类而言,它的反射信息被创建出了一个UClass对象,可以通过这个对象在运行时获取对象类型的信息。并且,类内部的反射数据成员反射成员函数,都会给生成对应的FPropertyUFunction对象,用来运行时访问到它们。

UClass的继承关系:

UObjectBase
  UObjectBaseUtility
    UObject
      UField
        UStruct
          UClass

针对继承自UObject的类,可以通过GetClass()来获取UClass实例,但是如果想直接获取某个类型的UClass,则可以通过StaticClass<UObject>或者UObject::StaticClass()来获取。

UClass中记录这类的继承关系、实现的接口、各种Flag等等,具体可以直接查阅UClass的类定义,通过它可以访问到该UObject的C++类型中的信息。

而且,在运行时可以通过TFieldIterator来遍历UClass中的反射属性:

1
2
3
4
5
6
7
8
9
10
11
12
13
URefObject::URefObject(const FObjectInitializer& Initializer):Super(Initializer)
{
for(TFieldIterator<FProperty> PropertyIter(GetClass());PropertyIter;++PropertyIter)
{
FProperty* PropertyIns = *PropertyIter;
UE_LOG(LogTemp,Log,TEXT("Property Name: %s"),*PropertyIns->GetName());
}
for(TFieldIterator<UFunction> PropertyIter(GetClass());PropertyIter;++PropertyIter)
{
UFunction* PropertyIns = *PropertyIter;
UE_LOG(LogTemp,Log,TEXT("Function Name: %s"),*PropertyIns->GetName());
}
}

执行结果:

1
2
3
LogTemp: Property Name: ival
LogTemp: Function Name: func
LogTemp: Function Name: ExecuteUbergraph

那么如何通过属性和成员函数的反射信息来访问到它们呢?

访问数据成员

首先,在C++中类内存布局中是编译时固定的,所以一个数据成员在类中的位置是固定的,C++有一个特性叫做指向类成员的指针,本质上就是描述了当前数据成员在类布局内的偏移值。这部分内容在我之前的文章中有介绍:C++中指向类成员的指针并非指针

FProperty做的就是类似的事情,记录反射数据成员的类内偏移信息,UE中的实现也是通过指向成员的指针来实现的,这部分后面的文章会着重介绍,这里只介绍使用方法。

通过FProperty获取对象中值的方式,需要通过调用FPropertyContainerPtrToValuePtr来实现:

1
2
3
4
5
6
7
8
9
for(TFieldIterator<FProperty> PropertyIter(GetClass());PropertyIter;++PropertyIter)
{
FProperty* PropertyIns = *PropertyIter;
if(PropertyIns->GetName().Equals(TEXT("ival")))
{
int32* i32 = PropertyIns->ContainerPtrToValuePtr<int32>(this);
UE_LOG(LogTemp,Log,TEXT("Property %s value is %d"),*PropertyIns->GetName(),*i32);
}
}

这样就实现了通过FProperty来访问数据成员的目的,因为获取到的是数据成员的指针,所以修改它也是没问题的。

访问成员函数

通过反射访问函数则要复杂一些,因为要处理参数传递和返回值的接收问题。

前面已经提到了,UE的反射成员函数会生成UFunction对象,函数的反射信息就在它里面,因为UFUNCTION是只能标记在继承自UObject的类中,所以UE封装了一套基于UObject的反射函数调用方式:

1
2
3
4
5
6
// defined in UObject
virtual void ProcessEvent
(
UFunction * Function,
void * Parms
)

只有两个参数,第一个是传入UFunction的指针,第二个是void*指针,作为通用的方式来传递参数和接收返回值。
对于标记为UFUNCTION的函数,UHT会为该函数生成一个Thunk函数,形如以下形式:

1
2
3
4
5
6
7
8
DEFINE_FUNCTION(URefObject::execfunc)
{
P_GET_PROPERTY(FIntProperty,Z_Param_InIval);
P_FINISH;
P_NATIVE_BEGIN;
*(bool*)Z_Param__Result=P_THIS->func(Z_Param_InIval);
P_NATIVE_END;
}

DEFINE_FUNCTION宏展开之后就是固定的原型了:

1
2
#define DEFINE_FUNCTION(func) void func( UObject* Context, FFrame& Stack, RESULT_DECL )
void URefObject::execfunc( UObject* Context, FFrame& Stack, RESULT_DECL )

ProcessEvent会按照这个统一的原型去调用反射函数的Thunk函数,在从Thunk函数转发给真正的C++函数,实现基于反射调用到真正C++函数的目的,当然也可以通过在UFUNCTION中添加CustomThunk标记,不让UHT生成函数的Thunk函数,自己手动提供,可以用来解决特殊的需求(如unlua中的覆写)。

现在回到ProcessEvent,它的函数原型中有三个关键点:

  1. ProcessEventUObject的成员函数
  2. ProcessEvent的第一个参数要是当前类中的UFunction
  3. void* Parms必须要是连续内存结构

着重要讲的就是void* Parms怎么用。

首先,对于示例函数:

1
bool func(int32 InIval);

它接收一个参数,并返回一个bool值,如何通过一个参数来同时做这两件事情呢?

把他们封装为一个结构!如同下面这种形式:

1
2
3
4
5
struct RefObject_eventFunc_Parms
{
int32 ival;
bool ReturnValue;
};

要传递给函数的参数排在前面,返回值为最后一个数据成员(如果有的话)。而且,也是可以通过UFunction::ParmsSize得到当前函数所有参数+返回值结构的大小,在运行时动态分配,并且可以通过UFunction的各个参数FProperty来访问到每个参数的内存,这部分内容暂时按下不表,后面的文章会详细的介绍。

所以,当我们通过UClass拿到指定的UFunction之后就可以做这个事情了:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
for(TFieldIterator<UFunction> PropertyIter(GetClass());PropertyIter;++PropertyIter)
{
UFunction* FuncIns = *PropertyIter;
if(FuncIns->GetName().Equals(TEXT("func")))
{
struct RefObject_eventFunc_Parms
{
int32 ival;
bool ReturnValue;
}func_params;
func_params.ival = 111;
this->ProcessEvent(FuncIns,&func_params);
UE_LOG(LogTemp,Log,TEXT("call func return: %s"),func_params.ReturnValue?TEXT("true"):TEXT("false"));
}
UE_LOG(LogTemp,Log,TEXT("Function Name: %s"),*FuncIns->GetName());
}

但是这样基于反射机制的函数调用有一个问题:无法处理参数和返回值为引用的情况。如:

1
2
3
4
UFUNCTION()
int32& Add(int32 R, int32& L);
UFUNCTION()
int32 Add(int32 R, int32 L);

这两个函数生成的反射信息一摸一样!(对 L 参数生成的 FProperty 的 Flag 会多一个 CPF_OutParm,返回值的 FProperty 还具有 CPF_ReturnParm)。

因为C++的引用必须要在初始化时进行绑定的:

1
2
3
4
5
6
7
8
9
class ClassRef
{
public:
ClassRef(int& InIval):ival(InIval){}
int& ival;
};
// instance
int ival =666;
ClassRef obj(ival);

这就对UHT的参数结构造成了限制,因为默认情况下UHT给每个反射函数生成了参数结构,是通过先实例化,再把真正的参数赋值给该实例中成员的,就跟上面的例子一样,做了一次值拷贝。而引用无法修改,只能在初始化时设置。在调用ProcessEvent时,真正调用到C++函数的时候,如果参数有引用则是绑定到的该UHT生成的结构中数据成员,而不是我们真实传递的参数。

基于反射的函数调用实际上进行了两步操作:

  1. 把参数赋值给ProcessEvent的函数参数结构
  2. ProcessEvent再把参数结构传递给真正的C++函数

这会造成通过 UFunction* 调用ProcessEvent传递的参数和想要获取的返回值也都只是原始参数的一份拷贝,而不是真正绑定的引用关系(因为本来调用时的参数传递到 ProcessEvent 之前都会被赋值到 UHT 创建出来的参数结构),在真正的C++函数中对引用参数的修改也只能改动UHT生产的参数结构实例,而非我们传递的真正的参数。

本篇文章对UE的反射做了一个简单的介绍,并示例了通过UClass获取反射信息来访问数据成员和成员函数的方式,UE中反射的实现会在后续的文章中详细介绍,下一篇会介绍UE反射实现所依赖的C++特性,目前文章已发表:UE4 反射实现分析:C++ 特性

参考资料:

全文完,若有不足之处请评论指正。

微信扫描二维码,关注我的公众号。

本文标题:UE反射实现分析:基础概念
文章作者:查利鹏
发布时间:2020年12月12日 23时56分
本文字数:本文一共有3.4k字
原始链接:https://imzlp.com/posts/12624/
许可协议: CC BY-NC-SA 4.0
文章禁止全文转载,摘要转发请保留原文链接及作者信息,谢谢!
您的捐赠将鼓励我继续创作!