IE盒子

搜索
查看: 143|回复: 1

【译】一种灵活的C++反射系统(1)

[复制链接]

5

主题

11

帖子

25

积分

新手上路

Rank: 1

积分
25
发表于 2023-1-1 18:18:40 | 显示全部楼层 |阅读模式
原文地址:https://preshing.com/20180116/a-primitive-reflection-system-in-cpp-part-1/
原文作者:Jeff Preshing
在这篇文章中,我将介绍一个小而灵活的系统,它使用了 C++11 语言特性来搭建运行时反射(runtime reflection)机制。这是一个为 C++ 类型生成元数据的系统。元数据采用在运行时创建TypeDescriptor对象的形式,用来描述其它运行时对象的结构。


我将这些对象称为类型描述符(type descriptors)。我编写反射系统的最初目的是想在我的 C++ 游戏引擎中支持序列化。因为游戏引擎非常需要有这样的功能。一旦做成了,我会将运行时反射给其它功能来使用:

  • 3D 渲染:游戏引擎每次在使用 OpenGL ES 绘制内容的时候,它都会利用反射将统一参数和描述顶点格式的数据传递给 API。这样可以让图形编程更加高效!
  • 导入 JSON:在引擎处理资产的管线中有一个通用例程(generic routine),它从 JSON 文件和类型描述符中合成为 C++ 对象。这样我就可以实现导入包括 3D 模型、层级定义以及其它资产等内容。
这个反射系统是基于预处理器宏和模板来设计的。至少对目前的 C++ 来说,由于语言设计上的原因,想用它来实现运行时反射并非是件容易的事情。任何写过反射系统的人都知道,很难设计出一套易于使用、易于扩展而且实际有效的反射系统。在这个系统设计出来之前,我被 C++ 中晦涩的语言规则、初始化顺序错误和各种极端情况折腾了很多次。
为了解释这个反射系统的原理,我在 GitHub 上发布了一个示例项目:


这个示例实际上并没有使用我游戏引擎中的反射系统,而是使用了一个迷你反射系统。不过,它最值得关注的部分——类型描述符的创建、结构化和查找,都几乎和我游戏引擎中实现的方式相同。这就是我将在这篇文章中重点介绍的部分。在下一篇文章中,我将讨论如何扩展系统。
这篇文章是写给那些想知道如何开发运行时反射系统的程序员的。这些开发人员不仅仅是想知道如何使用反射系统。这个系统涉及到许多 C++ 的高级特性,但示例项目只有 242 行代码,目的是希望每个 C++ 程序员都可以看懂并能坚持研究下去。如果你更想使用现成的解决方案来实现反射系统,可以看下 RTTR。
用法示例

在Main.cpp中,定义了一个名为Node的结构。宏REFLECT()告诉系统要启用这个类型的反射。
struct Node {
    std::string key;
    int value;
    std::vector<Node> children;

    REFLECT()      // 开启这个结构体的反射
};
在程序运行时,这段代码创建了一个Node类型的对象。
// 创建一个Node类型的对象
Node node = {"apple", 3, {{"banana", 7, {}}, {"cherry", 11, {}}}};
在内存中,Node对象的结构如下图所示:


接下来,示例代码查找Node的类型描述符。为此,必须将以下宏放在.cpp文件中的某个位置。我把它们放在Main.cpp 中,不过它们其实可以放在任何定义Node的文件中。
// 定义Node的类型描述符(type descriptor)
REFLECT_STRUCT_BEGIN(Node)
REFLECT_STRUCT_MEMBER(key)
REFLECT_STRUCT_MEMBER(value)
REFLECT_STRUCT_MEMBER(children)
REFLECT_STRUCT_END()
这样一来,Node的成员变量就实现了反射。
通过调用reflect::TypeResolver<Node>::get(),就可以获得指向Node类型描述符的指针:
// 查找Node类型描述符
reflect::TypeDescriptor* typeDesc = reflect::TypeResolver<Node>::get();
找到类型描述符后,使用这个指针将Node对象的描述输出到控制台(console)。
// 将Node对象的描述信息输出到控制台
typeDesc->dump(&node);
这是输出的内容:


宏做了什么

当你将宏REFLECT()添加到结构体或类中时,它会声明两个附加的静态成员:Reflection和initReflection。前者相当于是结构体的类型描述符,后者是一个用来对类型描述符进行初始化的函数。实际上,当宏展开时,整个Node结构体的内容如下所示:
struct Node {
    std::string key;
    int value;
    std::vector<Node> children;

    // 声明一个结构体的类型描述符
    static reflect::TypeDescriptor_Struct Reflection;

    // 声明一个函数来对类型描述符初始化
    static void initReflection(reflect::TypeDescriptor_Struct*);
};
同样,在Main.cpp中的REFLECT_STRUCT_*()结构在展开后内容将会是这样的:
// 定义结构体的类型描述符
reflect::TypeDescriptor_Struct Node::Reflection{Node::initReflection};

// 定义类型描述符的初始化函数
void Node::initReflection(reflect::TypeDescriptor_Struct* typeDesc) {
    using T = Node;
    typeDesc->name = "Node";
    typeDesc->size = sizeof(T);
    typeDesc->members = {
        {"key", offsetof(T, key), reflect::TypeResolver<decltype(T::key)>::get()},
        {"value", offsetof(T, value), reflect::TypeResolver<decltype(T::value)>::get()},
        {"children", offsetof(T, children), reflect::TypeResolver<decltype(T::children)>::get()},
    };
}
现在,由于Node::Reflection是一个静态成员变量,因此在程序启动时会自动调用其构造函数(它接受一个指向initReflection()的指针)。你可能想知道:为什么要将函数指针传递给构造函数?为什么不使用成员初始化列表呢?那是因为函数的主体声明了一个 C++11 中的类型别名(type alias):using T = Node。如果没有类型别名,我们必须将标识符Node作为额外的参数传递给每个宏REFLECT_STRUCT_MEMBER(),而宏使用起来不太方便。
正如你所看到的那样,在函数内部,还有三个地方额外调用了reflect::TypeResolver<>::get()。每个都查找Node反射成员的类型描述符。这些调用使用 C++11 中的 decltype类型推导 自动将正确的类型传给TypeResolver模板。
类型描述符(TypeDescriptor)

(请注意,本节中的所有内容都是在命名空间reflect中定义的。)
TypeResolver是一个类模板。当你给一个特定类型T调用TypeResolver<T>::get()时,编译器会实例化一个函数,该函数返回TypeDescriptor对应的类型T。它适用于反射结构体,也适合于反射这些结构中的每个反射成员。默认情况下,需要使用以下所示的模板进行处理。
默认情况下,如果T是一个包含宏REFLECT()的结构体(或类),比如Node,那么调用get()将返回一个指向该结构体中成员Reflection的指针——这就是我们想要的结果。对于所有其他类型T,get()会调用getPrimitiveDescriptor<T>,后者是一个函数模板,用来处理基本类型(比如如int和std::string)。
// 声明一个函数模板,用来处理基本类型数据,比如int、std::stirng等
template <typename T>
TypeDescriptor* getPrimitiveDescriptor();

// 定义一个helper结构体,能够以不同方式查找TypeDescriptors
struct DefaultResolver {
    ...

    // 如果T有静态成员变量“Reflection”,下面函数会被调用
    template <typename T, /* 这里是SFINAE部分 */>
    static TypeDescriptor* get() {
        return &T::Reflection;
    }

    // 其它情况下,下面函数会被调用
    template <typename T, /* 这里是SFINAE部分 */>
    static TypeDescriptor* get() {
        return getPrimitiveDescriptor<T>();
    }
};

// 这是一个基础类模板,用来查找所有TypeDescriptors
template <typename T>
struct TypeResolver {
    static TypeDescriptor* get() {
        return DefaultResolver::get<T>();
    }
};
这段代码的编译时(compile-time)逻辑,是根据T中是否存在静态成员变量来生成对应不同的代码,这其中是通过 SFINAE 来实现的。我在上面的代码中省略了 SFINAE 部分的代码,因为说实话这部分写得有点丑。你可以在我源代码中看到实际的完整部分。这其中的一部分可通过 if constexpr (译者注:C++17标准)来更优雅地改写,但我得使用 C++11。即便如此,判断T是否包含特定成员变量的这部分代码,仍然看上去不够美观。要么改用 C++ 静态反射(static reflection)效果会好些。但不管怎样,我这种写法可以起到作用!
类型描述符的结构

在示例项目中,每个TypeDescriptor都有表示名称、大小的成员变量以及几个虚函数:
struct TypeDescriptor {
    const char* name;
    size_t size;

    TypeDescriptor(const char* name, size_t size) : name{name}, size{size} {}
    virtual ~TypeDescriptor() {}
    virtual std::string getFullName() const { return name; }
    virtual void dump(const void* obj, int indentLevel = 0) const = 0;
};
示例的代码里从不会直接创建TypeDescriptor对象,而是创建TypeDescriptor的派生类对象。这样,每个类型描述符都可以保存额外的信息,具体保存的内容取决于类型描述符的类型。
比如,TypeResolver<Node>::get()返回对象的实际类型是TypeDescriptor_Struct。后者有一个额外的成员变量members,它保存了Node的每个反射成员的信息。每个反射成员都拥有一个指向另一个TypeDescriptor的指针。下面看到的是在内存中的完整数据结构,其中红圈标记表示不同的TypeDescriptor子类:


在运行期时,你可以通过调用类型描述符中的getFullName()来获取任何类型的全名。大多数子类实现了基类中的虚函数getFullName(),这些重写后的函数会返回TypeDescriptor::name 。在这个示例中有个唯一例外是TypeDescriptor_StdVector,它是一个子类,是对std::vector<>的全特化(Specializations)。为了能返回完整的类型名称,例如"std::vector<Node>",它会保留一个指针,这个指针指向该类型的描述符。你可以在上面的内存图中看到这一点:有一个TypeDescriptor_StdVector对象,它的成员itemType总是指向Node的类型描述符。
当然,类型描述符只描述类型。如果需要完整描述一个运行时的对象,除了要有类型描述符,我们还需要一个指向对象本身的指针。
请注意,TypeDescriptor::dump()以const void*作为参数接受一个指向对象的指针 。这是因为抽象接口TypeDescriptor是用来处理任何类型的对象。继承自这个接口的子类能知道预期什么类型。例如,下面这段代码是TypeDescriptor_StdString::dump()的实现。它将const void*转换为const std::string*。
virtual void dump(const void* obj, int /*unused*/) const override {
    std::cout << "std::string{\"" << *(const std::string*) obj << "\"}";
}
你可能想知道以这种方式来转换void指针是不是安全。很明显,如果传入的是无效指针,程序可能会崩溃。这就是为什么在我的游戏引擎中,由void指针表示的对象总是还有其类型描述符跟着它一起。通过这种方式表示对象,可以编写多种通用算法。
在示例项目中,仅仅实现了将对象输出到控制台的功能。但你可以想象到的是,类型描述符作为一种框架的概念存在着,你同样可以想到的是它如何起到将数据序列化为二进制格式的作用的。
在下一篇文章中,我将解释如何向反射系统添加内置类型,以及上图中“匿名函数(anonymous functions)”的用途。另外,我还将讨论关于扩展系统的其他方法。
回复

使用道具 举报

1

主题

5

帖子

6

积分

新手上路

Rank: 1

积分
6
发表于 2023-1-1 18:18:58 | 显示全部楼层
反射的难点在于反射类成员方法,成员对象我已经有好多办法可以解决了。
回复

使用道具 举报

您需要登录后才可以回帖 登录 | 立即注册

本版积分规则

快速回复 返回顶部 返回列表