C++反射原理探究(第一部分)

Cpp in the mirror

Posted by zyl on February 23, 2022

之前在做学期末课程设计时有用到过QT去写界面,被其中的信号槽以及动态反射机制吸引住。再后来在看《游戏引擎开发指南》中又再一次见识到了反射机制的魅力。不过遗憾的是书中并没有详细说明反射机制的实现原理,甚至连个伪代码都没有。在信号槽在我这里以监听者模式去魅之后,又去翻了翻博客园以及知乎上的一些实现思路,最终实现了一套小的反射框架:Cpp in the mirror(没错,就是neta的MJ的歌曲《Man in the Mirror》)之后应该会集成到ShadowPlay上。本篇博文是第一部分,主要描述除序列化部分之外的反射系统构成,序列化以及反序列化会在之后这部分完善后更新,毕竟也是边学习边总结。在开始前需要说明一点,C++本身是不支持反射机制的,但C++的一些语言特性可以让我们写出反射框架。本系列博文的意义在于了解反射机制的原理。不过有一说在最新的草案标准里貌似已经有了对静态反射的支持,不过具体会在哪一个标准中放出就不得而知了。即使各位只是想用新标准中提供的反射也没关系,就当是为之后的使用奠定一下基础。(其实从某种意义上来说也可以算是“C/C++戏法”系列的第二期)

一、变量的类型萃取

最先开始且也是最重要的一部分便是“类型萃取”,说它是反射系统的基石也不过分,这一部分的主要职能正如字面意思所说:在一堆属性说明符和前缀中提取出字段以及方法的主要类型信息。举个例子,有这么一个类Clazz,其成员方法Foo声明如下:

const int& ClazzNameSpace::Clazz::Foo(const int a, int* b, int& c) const noexcept;

相信大部分老炮儿看到这一长串声明可能都得稍微理一下。不过总归还是会知道这个方法应该这么描述:

  1. 返回值是int的引用,加上const限定表明返回出来的有可能的是某个私有成员字段。
  2. Foo来自于ClazzNameSpace命名空间的Clazz类定义。
  3. 参数有三个:a,b,c,分别代表常量整型、整型指针、整型引用。
  4. 最后两个关键字说明符代表方法内不会对成员变量进行修改且同时不会抛出异常。

如果加上virtual或者是override关键字估计凑得字数会更多(笑)

那么,提取出关键信息:返回值为整型引用,共有三个参数,来自Clazz类。这便是我们需要反射框架所具备的提取能力。如何通过C++自带语言特性实现这一部分?如果有幸看过侯捷先生的一些讲座以及书籍的话大概会猜到:模板偏特化。

不知道这个特性也不要紧,其实可以将其理解成模板参数的重载,不过这种理解仅限于初识模板偏特化这种概念。举个例子,现在有这么一个类TestClass,声明如下:

template <class T>
class TestClass;

template <class T, typename... Args>
class TestClass;

template <class T, size_t idx>
class TestClass;

不用管TestClass如何实现,以上声明如果用一般的常识来看那必然是无法通过编译的:因为发生重定义行为,即TestClass被定义多次。但模板偏特化允许这种情况,它允许模板处理不同模板类型参数的情况。也就是说以上定义是完全合法的,完全可以出现在同一个hpp文件的同一个命名空间里。

在明白了模板偏特化这一特性之后,那么接下来从最简单的变量以及成员字段的类型萃取开始。首先可以先对最简单的表达方式进行定义:

template <typename T>
struct variable_traits
{
    using variable_type = T;
};

这样对最简单的变量表达方式进行类型萃取,即如果此时定义了一个变量,那么提取过程如下所示:


template <typename T>
struct variable_traits
{
    using variable_type = T;
};

int main()
{
    // The variable what I want.
    int a = 0;
    variable_traits<decltype(a)>::variable_type b;
    return 0;
}

这里其实如果想要测试一下b的类型是可以直接这么写:

variable_traits<decltype(a)>::variable_type b = a;

由于类型萃取的步骤是编译期间发生的,所以像是功能比较完善的IDE一般在编辑时就会报告非法的错误。当然,这里并不会报错的。因为提取出来的类型正是int类型,否则你该升级你的IDE和Compiler了。不过目前的这个类型萃取看起来颇有点脱裤子放屁的感觉,因为decltype已经做到了类型提取。在外面套一个结构体显然有些多余。所以,我们可以再完善一下:

template <typename T>
struct variable_traits
{
    using variable_type = T;
    using pointer_type = T*;
    constexpr static bool isClassMember = false;
    constexpr static bool isPointer = false;
    constexpr static bool isRef = false;
    // Any atttribute what you want...
    ... 
};

结构体里又多了一些新成员:指针类型、符号标识、指针类型标识、引用类型标识…… 这样信息就全了,如果目前我再把a以及decltype填入,那么我就可以获取到比原先多得多的类型信息:它的指针类型是什么?是否为类成员?是否是一个指针?又是否是一个引用?当然,后续的属性可以根据自己的需求添加。

不过需要注意一点,类型萃取实际上是对类型进行提取,而无论是静态还是动态反射,都仅是针对类型创建对象。所以某一类型的类型萃取结构体有且仅有一个,而且其中的属性均是静态的且不可更改的。所以结构体里添加的属性建议为其加上constexpr关键字,旨在编译期间处理完毕这些属性的注册。不过再说下去就要开始涉及到函数式编程的内容了,目前我们的反射只服务于OOP,所以不再深入,感兴趣的话可以了解一下C++模板元编程的函数式黑魔法。

这下初露端倪了,如果此时再结合模板偏特化就可以得出以下几个定义:

template <typename T>
struct variable_traits<T*>
{
    using variable_type = T;
    using pointer_type = T**;
    constexpr static bool isClassMember = false;
    constexpr static bool isPointer = true;
    constexpr static bool isRef = false;
    // Any atttribute what you want...
    ... 
};

template <typename T>
struct variable_traits<T&>
{
    using variable_type = T;
    using pointer_type = T*;
    constexpr static bool isClassMember = false;
    constexpr static bool isPointer = false;
    constexpr static bool isRef = true;
    // Any atttribute what you want...
    ... 
}

注意,在指针的萃取中,指针类型的定义是一个“边界值”类型的问题,既可以是T*本身,也可以是T**,具体取舍可以根据自己的项目要求去做。

这样,我们就完成了基础变量的类型萃取了。但是还不够,别忘了,类定义里面也是存在成员字段的。那么接下来便对类定义中的成员字段进行提取,和普通的变量萃取不同的是,类成员字段会多出类指针类型。如下:

template <typename T, class ClassT>
struct variable_traits<T(ClassT::*)>
{
    using variable_type = T;
    using pointer_type = T*;
    using class_type = ClassT;
    constexpr static bool isClassMember = true;
    constexpr static bool isPointer = false;
    constexpr static bool isRef = false;
    // Any atttribute what you want...
    ... 
};

唯一不同的就是属性参数那里多出的ClassT以及<T(ClassT::*)>这其实是成员字段在cpp文件展开后的真实样貌。其结构为字段类型+类指针+字段标识名。所以接下来可以把其他几个样式的萃取进行完善:

template <typename T, class ClassT>
struct variable_traits<T*(ClassT::*)>
{
    using variable_type = T;
    using pointer_type = T**;
    using class_type = ClassT;
    constexpr static bool isClassMember = true;
    constexpr static bool isPointer = true;
    constexpr static bool isRef = false;
    // Any atttribute what you want...
    ... 
};

template <typename T, class ClassT>
struct variable_traits<T&(ClassT::*)>
{
    using variable_type = T;
    using pointer_type = T*;
    using class_type = ClassT;
    constexpr static bool isClassMember = true;
    constexpr static bool isPointer = true;
    constexpr static bool isRef = true;
    // Any atttribute what you want...
    ... 
};

当然,其实还有许多类型变量没有说明,比如&&,const限定,双指针等,因为都是使用模板偏特化特性来进行提取所以实现思路都大差不差,这些变量的萃取结构体可以根据自己的项目需求酌情添加即可。

二、函数的类型萃取

在完成了变量的类型萃取后,接下来可以对函数进行类型萃取。在提取之前需要先确定我们主要提取函数的哪几个部分,一般情况下会这么分:返回类型、参数类型、类声明。首先我们便可以这么实现一个基础的函数类型萃取:

template <typename T>
struct function_traits;

template <typename T, typename... Args>
struct function_traits<T(*)(Args ...)>
{
    using return_type = T;
    using func_param_types = std::tuple<Args ...>;
    constexpr static bool isClassMember = false;
    constexpr static bool isConst = true;
    // Any atttribute what you want...
    ... 
};

这里使用了C++ 11标准里的可变参数模板,因为一个无法否认的真实情况是:其实我们是完全不知道目标函数的参数个数的。所以我们使用可变参数模板以及tuple元组来存储函数内的参数变量类型。

唯一和变量的类型萃取不同的是,这里有预先的单模板参数定义,虽然可以将函数的各个类型作为多个参数填入模板,但在实际应用中,这种行为就较为麻烦了,我们更多的是希望在使用中填入的参数只有一个函数指针,然后类型萃取可以自动解析这个函数指针内的相关类型信息。

其他部分的属性以及类型大致和变量中的类型萃取一致,按需所取即可。同时,其他函数类型的萃取这不再赘述。根据需要可以实现自己的定义。

接下来实现类定义中成员方法的类型萃取:

template <typename T, class ClassT, typename... Args>
struct function_traits<T(ClassT::*)(Args ...)>
{
    using return_type = T;
    using func_param_types = std::tuple<Args ...>;
    using func_class_types = ClassT;
    constexpr static bool isClassMember = true;
    constexpr static bool isConst = false;
    // Any atttribute what you want...
    ...
};

与变量类型萃取相同,类成员方法定义在展开后的方式与类成员字段一致。类成员方法的其他类型这里不再赘述。

在使用函数类型的类型萃取时使用方式如下所示:

function_traits<decltype(Func)>

decltype关键字虽然不可或缺,但还是不太优雅,我们希望直接把Func函数指针当作模板参数直接填进去。这时候我们可以使用模板函数帮我们完成:

// 使用尾置返回类型返回指针对应的函数类型
template <typename Ret, typename... Args>
auto get_function_type(Ret(*)(Args ...))->Ret(*)(Args ...);

接下来的事情就简单多了,给这个函数套个壳就可以了:

template <auto Param>
using func_traits_type = decltype(get_function_type(Param));

template <auto Param>
using func_traits_t = funcion_traits<func_traits_type<Param>>;

套壳之后我们可以直接这么写:

func_traits_t<Func>;

这样就优雅多了。

三、域类型萃取与静态反射

在完成了变量以及函数的类型萃取之后。接下来就可以开始真正的静态反射部分了。静态反射又分为两个部分:域类型萃取以及类型注册。这里的域其实就是指代的自定义类型的成员,即类与结构体内的成员,而且C++中的类与结构体说白了其实是同一种类型,只是默认访问权限的区别。所以在这里都可以使用域类型萃取。

接下来就是域类型萃取基类的实现:

template <typename T, bool is_memberFunc>
struct basic_field_traits;

这里是一个偏特化的基本接口,域类型的萃取实现基本思路是:根据成员类型的不同,继承不同的类型萃取结构体。判断这里的成员类型使用布尔值,根据不同的布尔值传入不同的继承基类。那么我们就可以得到如下实现:

// functions
template <typename T> 
struct basic_field_traits<T, true> : public function_traits<T>
{
    using traits = function_traits<T>;
    constexpr bool isMember()
    {
        return traits::is_class_member;
    }
    constexpr bool isConst()
    {
        return traits::is_const;
    }
    constexpr bool isFunction()
    {
        return true;
    }
    constexpr bool isVariable()
    {
        return false;
    }
};

// variables
template <typename T>
struct basic_field_traits<T, false> : public variable_traits<T>
{
    using traits = variable_traits<T>;
    constexpr bool isMember()
    {
        return traits::is_class_member;
    }
    constexpr bool isConst()
    {
        return traits::is_const;
    }
    constexpr bool isFunction()
    {
        return false;
    }
    constexpr bool isVariable()
    {
        return true;
    }
};

需要注意的是,我们传入的布尔值并不会在结构体中储存,也就是说当反射框架拿到域类型后是通过,所以建议不同域类型萃取结构体之间的所有属性尽量保持名称一致。不过在使用的时候如何填入判断用布尔值呢?毕竟和函数类型萃取的传入方法一样,我们希望有一种优雅的方式来帮助自动判断域萃取结构体的类型。当然,C++提供了一些方法:std::is_member_function_pointer_v<T>以及std::is_function_v<T>。从名称也可看出,这是两个判断类型是否为函数指针或函数类型的模板值。说起来有意思的是,C++不支持原生反射,但它的标准库却又处处为反射的实现提供方法(这算不算一种傲娇?)。不知道是不是为了新标准里的原生静态反射提供接口。那么接下来我们就可以实现判断:

static constexpr bool is_function = (
    std::is_member_function_pointer_v<T> || 
    std::is_function_v<std::remove_pointer_t<T>>);

其中,由于我们传入的参数既有可能是函数指针,也有可能是函数类型,所以这里使用std::remove_pointer_t来取消掉指针从而得到真实类型。(看吧,这个方法甚至是一种比萃取更简单的实现)

接下来就可以实现域类型萃取的真实结构了:

struct field_traits : public basic_field_traits<T, is_function<T>>
{
    // constructor
    constexpr field_traits(
        T&& pointer, 
        std::string_view fieldName
    ):Pointer(pointer), FieldName(fieldName){};
    constexpr field_traits(
        T& pointer, 
        std::string_view fieldName
    ):Pointer(pointer), FieldName(fieldName){};

    // variables
    T Pointer;
    std::string_view FieldName;
};

如上所示,我们需要完成复制构造函数与引用构造函数,在需要存储的字段我们只需要保存对应的类型在类里的指针以及类型名字符串即可。其他的一切在基类继承的模板那里就已经帮我们处理好了。

在实现了域类型萃取之后,那么就该实现真正的静态反射了。这里我们可以借鉴一下虚幻引擎中静态反射类型注册方法:宏定义。在宏定义中完成自定义类型信息结构体的创建。先说明一下未使用宏定义创建的自定义类型信息结构体:虽然我们直到目前为止都一直在使用模板元编程,甚至还稍微触及了一点函数式编程的黑魔法。但无论如何,这一切的一切都是为OOP服务的。也就是说我们需要将我们的注册信息转换成C++的风格,即类与对象。那么自定义类型结构体可以如下所示:

template <typename T>
struct reg_static_info;

template <>
struct reg_static_info<T>
{
    static constexpr auto class_functions = std::tuple(/*a lot of functions*/);
    static constexpr auto class_variables = std::tuple(/*a lot of variables*/);
};

接下来就是如何将各个域填入对应的元组,以class_functions为例,现有一个类函数Class::Foo需要我们填入,那么其真正的格式便是:

static constexpr auto class_functions = std::tuple(CppMirror::field_traits{ Foo, "Class::Foo" });

知道如何将这个结构体拆分后,就可以得到虚幻引擎同款的宏定义注册了:

#define STATIC_REG_BEGIN(X) \
    template<> struct reg_static_info<X> {

#define REG_FUNC(...) \
    static constexpr auto class_functions = std::tuple(__VA_ARGS__);

#define FUNC(F) \
    field_traits{ F, #F }

#define REG_VAR(...) \
    static constexpr auto class_variables = std::tuple(__VA_ARGS__);

#define VARS(V) \
    field_traits{ V, #V }

#define END_STATIC_REG(x)  };

比如现在有一个类定义如下所示:


class Temp
{
public:
    void FooA() { std::cout << "FooA\n"; };
    void FooB() { std::cout << "FooB\n"; };
private:
    int a;
    int b;
};

那么我们的注册方法便可以如下所示:

STATIC_REG_BEGIN(Temp)
    REG_FUNC(
        FUNC(Temp::FooA),
        FUNC(Temp::FooB)
    )
    REG_VARS(
        REG_VAR(Temp::a),
        REG_VAR(Temp::b)
    )
END_STATIC_REG(Temp)

定义了以后我们可以用静态模板函数来获取注册信息结构体的对象。

最后且最重要的是:当我们获取到注册信息后,如何通过注册信息以及类的实例去调用对应实例的方法?我们知道,普通时候我们写实例指针去调用方法或变量时的写法应该是这样:instance->Function()。当然,看到这里相信大家应该能明白,这并不是真正展开后的形式。实际上真正展开后的形式是这样:instance->*(Class::Function)(),也就是说实例是通过类方法或字段指针来索引到需要的函数或字段。到了实际应用,尤其是在游戏引擎中,关于游戏开发者所定义的类对于游戏引擎来说是不可知的。如果需要将其像unity的inspector或者是UE的blueprint一样出现在引擎的可视化编辑器内。那么就需要静态反射出场,在引擎没有相应类型的情况下通过实例指针以及字段或方法索引获取相关方法并调用。如下所示:

template <size_t Idx, typename ...Args, typename ...FuncArgs, typename Class>
void get_func_pointer(const std::tuple<Args...>& typeTuple, Class*instance, FuncArgs... funArgs)
{
    using type_tuple = std::tuple<Args...>;
    if constexpr (Idx >= std::tuple_size_v<type_tuple>)
    {
        return;
    }
    else
    {
        auto func_elem = std::get<Idx>(typeTuple);
        (instance->*func_elem.Pointer)(funArgs...);
    }
    
}

这个函数需要三个参数:相应域类型的元组列表、类实例指针、方法所需参数。通过此函数可以实现对通过静态反射类信息调用类实例里的方法。

结语

目前只是实现了类型萃取以及静态反射部分。打一个预告,动态反射以及后续的序列化和反序列化会在之后更新。虽然这一篇博文不长,但相应知识密度相信还是够喝一壶的(笑)。好的,下次见。

知识共享许可协议

本作品采用知识共享署名-非商业性使用-相同方式共享 4.0 国际许可协议进行过许可