跳转至

3 - 宏、模板、反射

相信大多数小伙伴,在找到窍门以后,之后的学习已经没太大困难了,而在这之后的一个阶段,其实都主要围绕着两个字 —— 偷懒

宏,模板,反射都不是开发的必备项,使用它们可以少写很多代码,但也会引入一些新的问题.

宏(Macro)

C++代码在参与编译的之前,有一个预编译的过程,该过程会使用预处理器来处理代码中的 预处理指令 ,不同的编译器有不用的预处理指令,比如Microsoft C/C++的预处理器可以识别以下指令:

使用#define可以定义宏,使用#undef可以取消之前的宏定义,宏的逻辑可以看作是简单的字符替换,基本用法如下:

#define EMPTY_MACRO                         //空宏
#define SRC Dst                             //替换

#define EMPTY_FUNC_MACRO()                  //空函数宏
#define F(Param) Param                      //函数宏
#define F_STR(Param) #Param                 //将函数参数转化为字符串
#define F_MERGE(Param0,Param1) Param0##_##Param1    //拼接函数参数
#define F_VARIADIC(...) __VA_ARGS__                 //可变参数的传递

int main() {
    EMPTY_MACRO
    int SRC;
    EMPTY_FUNC_MACRO()
    F(const char*) F_MERGE(Const, Text) = F_STR(F_VARIADIC(A, B, C, D, E));
    return 0;
}

上面的代码经预处理阶段之后,将变成:

int main() {
    int Dst;
    const char* Const_Text = "ABCDE";
    return 0;
}

一般情况下,C++程序中的宏定义来自于:

  • 编译器的预定义宏
  • 提供构建工具添加的宏
  • 代码中使用#define定义的宏

编译器的预定义宏,以MSVC为例,这里有一个详细的预定义宏列表:

对于构建工具,例如cmake,提供了函数target_compile_definitions用于为构建目标添加宏定义:

target_compile_definitions(<target>
  <INTERFACE|PUBLIC|PRIVATE> [items1...]
  [<INTERFACE|PUBLIC|PRIVATE> [items2...] ...])

宏的用途,主要有以下:

编译分支

  • 可以使用宏作为开关来切换到不同的编译分支
/*跨平台编译分支*/
#ifdef _WIN32
   #ifdef _WIN64
      //define something for Windows (64-bit only)
   #else
      //define something for Windows (32-bit only)
   #endif
#elif __APPLE__
    #include "TargetConditionals.h"
    #if TARGET_IPHONE_SIMULATOR
         // iOS Simulator
    #elif TARGET_OS_IPHONE
        // iOS device
    #elif TARGET_OS_MAC
        // Other kinds of Mac OS
    #else
    #   error "Unknown Apple platform"
    #endif
#elif __ANDROID__
    // android
#elif __linux__
    // linux
#elif __unix__ // all unices not caught above
    // Unix
#elif defined(_POSIX_VERSION)
    // POSIX
#else
#   error "Unknown compiler"
#endif
/*版本编译分支*/
#if _MSC_VER >= 1910
// . . .
#elif _MSC_VER >= 1900
// . . .
#else
// . . .
#endif

代码简化

  • 可以将一些固定步骤的代码替换成宏从而简化代码

比如:

#define MAX(a,b) ((a) > (b) ? (a) : (b))
#define MIN(a,b) ((a) < (b) ? (a) : (b))

#define M_PI 3.1415926535
/*使用这一组宏来生成类的相关操作,例如将类注册到脚本中..*/
#define CLASS_BEGIN(ClassName) ...
#define CLASS_ADD_PROPERTY(PropertyName) ...
#define CLASS_ADD_FUNCTION(FunctionName) ...
#define CLASS_END() ...
#define FOR_EACH_NUMBER_TYPE(FuncBegin,Func)\
  FuncBegin(int) \
  Func(float) \
  Func(double) \
  Func(short) \
  Func(unsigned int)

#define NUMBER_PREPEND_COMMAN(Type) ,Type
#define NUMBER_BEGIN(Type) Type

// FOR_EACH_NUMBER_TYPE(NUMBER_BEGIN, NUMBER_PREPEND_COMMAN)
// 将展开为 int,float,double,unsigned int

还有一些更高级的用法,比如用宏实现递归来处理某些东西:

调试提示

  • 一些预定义宏提供了很多上下文信息
#include <iostream>

int main(){
    std::cout << __DATE__<<"|"
    << __TIME__ << "|"
    << __FILE__ << "|"
    << __LINE__ << "|"
    << __FUNCTION__ << std::endl;
    return 0;
}

上述代码将打印:

Jan 15 2023|21:49:58|C:\Users\Administrator\source\repos\Macro\main.cpp|4|main

缺陷

宏并不是万能的,它也伴随一些严重的问题:

  • 功能简单,仅仅只是字符串替换,对于参数,只有 拼接转字符串 的操作
  • 宏会给程序增加很多非C++标准之外的魔幻语法,过度使用会给开发者增加不少认知负担,使维护变得困难
  • 宏展开的代码,无法使用编译器调试

模板(Template)

大家常见的模板示例应该是 C++ 标准库中的各类容器和算法,诸如 std::vector、std::map、std::sort()...

根据笔者目前的阅历来看,它主要被大量使用在一些跟类型强相关的工具库中,比如:容器、算法、序列化、反射、脚本绑定...

学习目标

对于游戏开发人员而言,并不要求对模板有过多的深入,但可能需要了解特化、偏特化、类型萃取的概念,并掌握以下技能:

  • 通过模板来做一些判断:比如判断某个类型是否符合某种条件,某种结构是否存在...

    class Base {
    };
    
    class Derived : public Base{
    public:
        void Test() {}
    };
    
    template< typename T>
    struct has_test_function
    {
        typedef char                 Yes;
        typedef struct { char d[2]; } No;
    
        template<typename Proxy>
        static Yes test(decltype(&Proxy::Test));
        template<typename Proxy>
        static No test(...);
    
        static const bool value = (sizeof(test<T>(0)) == sizeof(Yes));
    };
    
    int main() {
        //判断是否是浮点类型
        std::cout << std::is_floating_point<int>::value << std::endl;   //0 
        std::cout << std::is_floating_point<float>::value << std::endl; //1
        std::cout << std::is_floating_point<double>::value << std::endl;//1
    
        //判断类的继承关系
        std::cout << std::is_base_of<Base, Derived>::value << std::endl;//1
    
        //判断类中是否存在Test函数
        std::cout << has_test_function<Base>::value << std::endl;       //0
        std::cout << has_test_function<Derived>::value << std::endl;    //1
        return 0;
    }
    

    在标准库中,输入 std::is ,IDE会弹出很多可供使用的模板函数

  • 通过模板特化、偏特化来控制结构分支:很多模板工具库都会通过模板的特化、偏特化来提供一些扩展点,下面是一个不错的示例:

    假如有这样的需求:

    有一个元素是 序列容器 的std::vector,希望通过序列容器的元素尺寸进行排序,就比如std::vector<std::vector<int>>,它的元素类型std::vector<int>就是一个序列容器,最终我们想得到这样的效果:

    std::vector<std::vector<int>> vec = {
        {0,1,2},
        {0,1},
        {0,1,2,3,4},
        {0,1,2,3},
        {0}
    };
    
    /* 排序之后应该如下 */
    std::vector<std::vector<int>> vec = {
        {0},
        {0,1},
        {0,1,2},
        {0,1,2,3},
        {0,1,2,3,4}
    };
    

    我们需要关注的点是:

    • 如何确定类型是否是序列容器?
    • 因为要实现std::sort的排序机制,就需要考虑如何 允许 且 只允许序列容器 通过这个机制来进行排序:

    最终的代码如下:

    #include <iostream>
    #include <algorithm>
    #include <vector>
    #include <list>
    
    template<typename _Ty>
    struct sequential_container {                   //用于判断一个类型是否是序列容器以及得到序列容器的尺寸,默认为false 和 0
        static_assert(false,"Invalid Type")         //使用非序列容器报错
        static int size(const _Ty& containter){ return 0;}
        static const bool isVaild = false;
    };
    
    
    template<typename _Ty>
    struct sequential_container<std::vector<_Ty>>{  //通过偏特化,指定std::vector为序列容器,并实现它的size函数
        static int size(const std::vector<_Ty>& containter) { return containter.size(); }
        static const bool isVaild = true;
    };
    
    //通过std::enable_if限定范围
    template<typename _ItemType, typename std::enable_if<sequential_container<_ItemType>::isVaild>::type* = nullptr>
    void sequential_container_sort(std::vector<_ItemType>& vec) {
        std::sort(vec.begin(),vec.end(),[](const _ItemType& Lhs, const _ItemType& Rhs){
            return sequential_container<_ItemType>::size(Lhs) < sequential_container<_ItemType>::size(Rhs);
        });
    }
    
    int main() {
        std::vector<std::vector<int>> vec = {
            {0,1,2},
            {0,1},
            {0,1,2,3,4},
            {0,1,2,3},
            {0}
        };
        sequential_container_sort(vec);
        return 0;
    }
    

    如果后续要扩展其他序列容器,只需通过模板特化或偏特化:

    template<>                  //扩展std::string
    struct sequential_container<std::string> {      
        static int size(const std::string& containter) { return containter.size(); }
        static const bool isVaild = true;
    };
    
    template<typename _Ty>      //扩展std::list
    struct sequential_container<std::list<_Ty>> {
        static int size(const std::list<_Ty>& containter) { return containter.size(); }
        static const bool isVaild = true;
    };
    

学习方式

对于想要深入学习模板的小伙伴,大家可以到Github上寻找一些模板使用比较的多的仓库进行学习,这里罗列一下笔者学习过的几个库:

  • Rttr:使用模板实现的反射库
  • Sol2:C++绑定到Lua的便捷库
  • Bitsery:二进制序列化库
  • EASTL:追求在游戏中高效的容器和算法库
  • UnLua:Unreal引擎到Lua的绑定库

这里有一本不错的书籍:

image-20230118203730819

还有一个不错的教程:

反射(Reflection)

基础概念

对于 ,它可以在预处理阶段进行代码替换

对于 模板 ,它能使C++中的类、结构、函数能够随类型变化

它们在C++中都有着明确的语法,但反射不同,它不属于C++以及编译器标准,它更像是一种机制 —— 将代码中的枚举、类、结构、函数...作为运行时可访问甚至操作的资产它本质上是C++代码的自省

这其中能完成的操作包括但不限于:

  1. 根据名称 读写 对象的属性
  2. 根据名称 调用 函数
  3. 根据类名称创建实例
  4. 根据名称判断类型间继承关系
  5. 迭代对象的 所有属性、方法和枚举
  6. 不同类型间的隐式适配
  7. 为类型,属性,函数,参数追加元数据

RTTI

C++默认有一个RTTI(运行时类型识别)机制,RTTI提供了以下两个非常有用的操作符:

  • typeid操作符,返回指针和引用所指的实际类型。

  • dynamic_cast操作符,将基类类型的指针或引用安全地转换为派生类型的指针或引用。

虽然使用它也能获取到类型信息,但只要开启了RTTI,对整个程序运行性能的影响都比较大,因此在引擎中,都会禁用RTTI,反射框架也大多实现了自己的 Cast 函数

详见 https://baike.baidu.com/item/RTTI

反射原理

当下相对比较完善的反射框架有:

这些反射框架对上述操作均有支持,每个实现都带有一定"特色",比如:

  • Unreal Engine 实现了反射编译器 UHT(Unreal Header Tool), 支持了编辑器的自动绑定和生成,对象的序列化,蓝图脚本,引用分析,垃圾回收,网络同步...
  • Qt 实现了反射编译器 Moc(Meta Object Compiler) 支持了信号槽机制,可视化UI编辑,QML脚本
  • 在Github上还有一些C++反射库,大多都是使用libclang解析代码,相比UE4的HeaderTool和Qt的MOC,libclang做了太多的解析工作导致其效率极其低下,在大型项目中会严重拖垮编译速度

以上面的操作条目1为例,作为C++的使用者,在你不知道什么是反射的情况下,要根据属性名称对其进行读写,你可能会写出下面的代码:

class Example{
public:
    void setProperty(std::string name, int var){
        if(name == "a")
            a = var;
        else if(name == "b")
            b = var;
    }
private:
    int a;
    int b;
};

上面代码虽然简单,但是它确实可以满足需求,或许我们还能做一些优化:

  • if else 过于缓慢,我们可以通过构建映射来加速:
class Example{
public:
    void setProperty(std::string name, int var) {
        *PropertyMap.at(name) = var;
    }
private:
    int a = 0;
    int b = 0;
    std::unordered_map<std::string, int* > PropertyMap = {
        {"a",&a},
        {"b",&b}
    };
};

上面我们为每个Example实例记录了它的变量地址,但每个Example对象都构造一个PropertyMap似乎有些浪费,我们是否可以改为Example类只有一个Property Map?

很显然是可以的,由于Example的内存结构是确定的,我们只需要使用记录变量在内存中的偏移 Offset, 最后 this 的地址 +Offset 即可得到变量的地址。

class Example {
public:
    void setProperty(std::string name, int var) {
        int offset = PropertyMap[name];
        int* valuePtr = (int*)((char*)this + offset);  //注意指针+的跨度是一个元素的长度,所以这里先将this转char*,+offset即是 + offset个字节
        *valuePtr = var;
    }
private:
    int a = 0;
    int b = 0;
    static std::unordered_map<std::string, int> PropertyMap;;
};

std::unordered_map<std::string, int> Example::PropertyMap = {
    {"a", offsetof(Example, a)},
    {"b", offsetof(Example, b) }
};

上面的代码从结构上来看几乎无可挑剔,但是却很鸡肋——setValue只能设置int类型的变量。那是否能做到不同类型都能通过同一个函数设置呢?大神们第一时间想到的可能是模板,他们或许会写出这样的代码:

template<typename _Ty>
void setProperty(std::string name, _Ty var) {
    int offset = PropertyMap[name];
    _Ty* valuePtr = (_Ty*)((char*)this + offset);  //注意指针+的跨度是一个元素的长度,所以这里先将this转char*,+offset即是 + offset个字节
    memcpy(valuePtr, &var, sizeof(_Ty));
}

现在的代码从功能上来说,已经很完美了,但是它还有一些问题,要求在调用setProperty的时候必须明确属性的类型,另外,如果属性是复合类型,且内部包含指针,使用memcpy只是进行浅拷贝,实际上我们可能需要调用复合结构的深拷贝函数,虽然我们可以通过偏特化来做类型的验证,但大量使用模板将会导致代码的急剧膨胀,所以我们迫切需要一种轻量且可供验证的类型擦除手段。

对于这个问题,大家应该很容易想到解决方案 ——只需要提供一个 void* 记录数据地址,一个TypeID记录类型即可

在反射框架中,一般称这个结构为 Variant

但它并非一个void*加一个TypeID擦除了类型就完事了,还需要注意:

  • Variant存储的数据类型是多样的,类型的擦除和还原,数据的拷贝,构造,析构,往往都有一些差异,反射框架一般会根据这些差异,将其划分为:
  • 基础类型(int、double、char...)
  • Class/Struct
  • 指针
  • 容器(序列,散列)

因此 Variant 往往还带有一个 Flag 用来标识类型的特征

为了能够让Property支持Variant的处理,所以还需要存储属性的类型ID,为了更直观一些,我们使用这样的结构:

struct MetaProperty{  //存储属性的关键信息
    variant read(void* ObjectPtr){
        return variant::readFromProperty(typeId,ObjectPtr,offset);
    }

    void write(void* ObjectPtr,variant var){
        if(var.canConvert(typeID)){
            var.writeToProperty(ObjectPtr,offset);
        }
    }

    std::string name;
    int offset;
    int typeID;
}

struct MetaClass{    //存储类的所有信息
    std::unordered_map<std::string, MetaProperty> properties;
}

MetaClass中除了MetaProperty,往往还有:

MetaFunction:描述函数的信息,函数的参数,ID(或地址)...

MetaEnum:描述枚举的信息

伪代码如下:

class Example {
public:
    void setProperty(std::string name, variant var) {
        StaticMetaClass.Properties[name]->write(this,var);
    }
private:
    int a = 0;
    int b = 0;

    friend class MetaClass;
    static MetaClass StaticMetaClass;
};

MetaClass Example::staticMetaClass = {

    {       //构造properties
    {"a",{"a",variant::GetType<int>(),offsetof(Example, a)}},   
    {"b",{"b",variant::GetType<int>(),offsetof(Example, b)}},
    }

};

对于条目2【根据函数名称调用函数】,这里列一个简单的核心结构:

#include <iostream>
#include <string>

class Example {
public:
    void print(int a) {                 //函数样例1
        std::cout << a << std::endl;
    }
    double add(double a, double b) {     //函数样例2
        return a + b;
    }

    template<typename _TyParam0>
    bool invoke(std::string name, const _TyParam0& param0) {        //适配只有单个参数的函数
        void* params[2] = { nullptr,(void*)&param0 };
        return invoke_internal(name, params);
    }

    template<typename _TyRet, typename _TyParam0,typename _TyParam1>
    bool invoke(std::string name, _TyRet& ret, const _TyParam0& param0, const  _TyParam1& param1) {     //适配带有两个参数且有返回值的函数
        void* params[3] = { (void*)&ret,(void*)&param0,(void*)&param1 };
        return invoke_internal(name, params);
    }
private:
    bool invoke_internal(std::string name, void** params) {         //核心:根据参数堆栈来调用对应的函数,index 0 存返回值的指针
        if (name == "print") {
            print((*reinterpret_cast<int(*)>(params[1])));
            return true;
        }
        else if (name == "add") {
            double ret = add((*reinterpret_cast<double(*)>(params[1])), (*reinterpret_cast<double(*)>(params[2])));
            if (params[0]) {
                *reinterpret_cast<double*>(params[0]) = std::move(ret);
                return true;
            }
        }
        return false;
    }
};

int main() {
    Example ex;
    ex.invoke("print", 5);
    double result;
    ex.invoke("add", result, 10.0, 5.0);
    std::cout << result << std::endl;
    return 0;
}

反射调用的核心是通过一个转接函数 invoke_internal 来根据函数名选择相应的函数,再从 void** params 中读取参数并还原调用,最后通过模板封装一层来适配不同的参数数量

综上,我们实现了两个小功能:

  1. 根据名称 读写 对象的属性
  2. 根据名称 调用 函数

在这两个小功能中,已经不知不觉的实现了反射,上面的实现,使得我们可以将变量 ab,函数printadd,作为了程序运行时可访问甚至操作的资产。

你可能注意到了,为了让一个Class支持反射,我们需要实现很多固定结构的硬编码部分,比如: MetaPropertyMetaFunctionMetaEnum 的信息构造, invoke_internal 的编写,为了简化这个部分,正常情况下我们偷懒的方法无非就两种:

  • 宏:使用宏可以完成固定格式的代码生成

  • 缺点:它最大的痛点就在于它只是做简单的文本替换,所以在使用它做反射时功能非常受限。

  • 模板:模板元是近年来C++最狂战酷炫的编程范式,使用它可以做很多编译期的计算、逻辑分支。相较于宏,它具备足够的编程性和完整的C++环境。其中大名鼎鼎的反射框架(RTTR) ,就是通过模板生成的。

  • 缺点:

    • 模板的使用门槛较高
    • 模板的特性会带来一些问题,比如模板需要放置到头文件,才能传递反射的绑定。
    • 最大的缺点还是需要手写一些绑定函数

尽管结合了上面的两种方法,反射的实现依旧具有一定的局限性,那还有其他办法吗?答案肯定是有的

你可能不敢想象这群丧心病狂的挂壁为了解决这么一点点的局限性,居然打起了C++编译器的主意。

它们的目的也很简单,就是 能够根据代码信息随心所欲地生成代码

说简单点,就是我要做一个程序,既要能够像模板那样,得到所有的代码信息,但不受限于模板语法,又要可以像宏那样,可以对代码进行修改,但又不仅仅只是字符替换

对此,我们需要实现一个反射编译器,它由两部分组成:

  • Header Parser :解析代码中定义的信息(一般是头文件)

  • Code Generator :根据已有信息生成附加代码

采用这种做的有Qt (Moc)和Unreal (UHT),它们的流程基本相似:

  • 约定标记:这里的标记指 宏 ,使用标记的主要目的是:

  • 加快代码的扫描速度,编辑器默认使用粗略扫描,当遇到标记时,在触发对应的细节解析

  • 可以手动指定需要反射的结构

  • 以UE为例:

UCLASS()                          //使用宏标记类
class MyObject: public UObject{
    GENERATED_BODY()              //类定义的入口宏
public: 
    UFUNCTION(BlueprintCall)      //使用宏标记函数,其中的参数会传递给反射编译器
    void Func();
private:
      UPROPERTY(EditAnywhere , Meta = (DisaplayName = "Int"))         //使用宏标记属性
    int Value
}

使用标记的主要目的是为了让代码扫描工具快速搜集周围的有效信息,一般情况下,标记宏的用法主要有三种:

  • 不带参数的“空宏”:只起到标记的作用

    • 举例:Qt里的 Q_INVOKABLE
  • 带参数的"空宏":除了标记之外,还可以向扫描工具中传递参数,从而生成个性化代码

    • 举例:UE里的 UProperty(...)UFunction(..) 等,Qt里的 Q_PROPERTY(...)
  • 入口宏:附带一部分的定义

    • 举例:UE里的 GENERATED_BODY() ,它的定义是由UHT生成在gen.h中,Qt里的 Q_OBJECT 是固定填充一部分定义,示例如下:
    #define Q_OBJECT \
    static const QMetaObject staticMetaObject; \
    virtual const QMetaObject *metaObject() const; \
    ...
    
  • 代码解析&信息搜集

  • 这一过程主要由 Header Parser 完成(UE UHeaderTool | Qt MOC),解析其实只是在扫描关键字并还原类的层次结构,并不涉及到语法相关的内容,QtMOC的Parser轻量且高效,能轻松解析函数,枚举,类型,类,而UE针对其工程提供了许多扩展。

    对于一个完整的C++编译器而言,它需要解析代码中词法,构建语法树等等,这些过程是非常缓慢的,在Qt的MOC,它就采用了一种取巧的方法,它首先对代码进行预处理(比如执行#include,#define)得到真正的代码,然后将代码按分隔符(空格,制表符,换行)划分成一个Symbol数组,比如上方的代码,就会被划分为:

    {"UCLASS()" ,"class", "MyObject", ":", "public", "UObject", "{", "GENERATED_BODY()", ...}

    反射编译器只需快速匹配这些符号,当遇到我们的标记宏,例如GENERATED_BODY(),再逐字符的去解析我们的想要的信息

  • 样例:

    假如约定了下面的标记,

    AxPROPERTY(GET getX SET setX) 
    int x = 0;
    

    其解析过程看上去就是这样的:

    void Moc::parser(){
           //...
           case AX_PROPERTY_TOKEN: //这段代码会在扫描到 AxPROPERTY的symbol时触发
              parseAxProperty(&def);
           break;
           //...
    }
    
    void Moc::parseAxProperty(ClassDef *def)
    {
           PropertyDef axVarDef;         //属性定义
           next(LPAREN);                 //判断下一个是不是左括号
           while (test(IDENTIFIER)) {        //判断是不是标识符(非关键字)
               QByteArray type = lexem();    //获取类型
               next();                       //扫描下一个关键字
               if (type == "GET") {      
                   axVarDef.getter = lexem();//解析Get函数
               }
               else if (type == "SET") {
                   axVarDef.setter = lexem();//解析Set函数
               }
           }
           next(RPAREN);                 //判断下一个是不是右括号
           axVarDef.type = parseType();  //解析类型
           next(IDENTIFIER);             //判断下一个是不是标识符
           axVarDef.name = lexem();      //存储函数名
           until(SEMIC);                 //一直往后扫描,直到分号
           def->propertyList << axVarDef;    //将该属性添加到类中
    }
    
  • 搜集到足够的代码信息,将使用 Code Generator 来生成代码,说白了就是根据Header Parser搜集到的信息 write 代码文件

  • 对于Qt而言,会生成moc_*.cpp,它里面存放了之前那些需要手写的代码,就比如property,function,enum的各类信息,invoke_internal函数等

  • 对于UE而言,它会生成_.generated.h _.gen.cpp:相较于Qt,UE多生成了一个头文件,这个文件的主要目的是为了生成 GENERATED_BODY 的定义,通过这个方法,UE甚至能够自定义地修改类的定义,而Qt就只能在已有的接口上扩展。

  • 样例

    假如现在要用Code Generator利用属性信息生成代码

    c++ for(auto& property:def.propertyList){ fprintf(out," .property(\"%s\"",property.name.constData()); if (property.getter.isEmpty()) { fprintf(out, ",&%s::%s)\n", def.classname.constData(), property.name.constData()); continue; } fprintf(out, ",&%s::%s", def.classname.constData(), property.getter.constData()); if (!property.setter.isEmpty()) { fprintf(out, ",&%s::%s", def.classname.constData(), property.setter.constData()); } fprintf(out, ")\n"); }

    上面的代码可能会生成如下的代码:

    c++ .property("x",&TestClass::getX,&TestClass::setX)

  • 上述的步骤只完成了代码的解析和生成,真正将UHT和MOC实装到项目上还得依靠构建工具:

  • UE通过 UBT(Unreal Build Tool) 去调用UHT

  • Qt通过QMake去调用 MOC

  • 此外,CMake作为现在主流的构建工具,它也提供了相应的指令来支持这些操作,就比如:

    //自定义命令,并指定依赖,当${INPUT_FILE_PATH})变动时,调用${CMD},生成 ${OUTPUT_FILE} 
    add_custom_command(OUTPUT ${OUTPUT_FILE}               
                       COMMAND ${CMD}
                       DEPENDS ${INPUT_FILE_PATH})   
    
    //将生成的代码文件添加到target的sources中
    set_property(TARGET ${PROJECT_TARGET} APPEND PROPERTY SOURCES ${OUTPUT_FILE}) 
    

有了这种 Header Parser + Code Generator 的机制,使得我们可以做更高级别的反射功能(我们可以根据自己的需求魔改C++代码):

  • 编辑器的自动绑定
  • 自动序列化
  • 脚本的自动绑定
  • 引用分析、垃圾回收
  • 网络同步

对于这些功能的实现,有着太多的细节和难点,个人认为去深究它们的实现原理,并没有太多的意义。

  • 对于使用者来说,只需要了解官方所制定的使用方式,底层上,粗略了解它们的工作流程即可。
  • 对于有同样开发需求的人来说,Code Generator一般是跟框架的核心机制强关联的,所以它里面会有非常多的黑话,整体思路上可以借鉴,但在细节上没必要盲目追求一致。

框架对比

笔者对UE,Qt,RTTR都有不少的使用,整体用下来的感受如下:

  • 反射信息的支持程度:UE > RTTR > Qt,UE和RTTR的反射信息通过注册全部存储到了一起,所以可以在全局统一处理所有的MetaClass,MetaFunction,MetaEnum,而Qt就显得有些保守,反射信息存储到Class局部,甚至都不支持MetaData,而UE的反射,就比较夸张了,它甚至可以手动去构造一个MetaClass,用它来创建Object(蓝图的原理)
  • 扩展性:UE > Qt > RTTR,这点主要是因为UE和Qt有反射编译器的加持
  • 易用性:Qt > RTTR > UE,Qt的反射相对比较精炼
  • 对于轻量级的反射需求,RTTR是一个非常不错的选择

这里也有一些笔者对反射的测试项目:

  • XObject : 使用STD库模仿Qt的MOC,通过扫描XObject的标记代码,生成Rttr的注册代码以及序列化方法

  • QDetailWidget:在Qt Moc的基础上,模仿UE的DetailView,支持自定义属性编辑器,撤销重做,允许序列容器,散列容器,共享指针的属性编辑(重构中...)

评论