使用 C++ 完成一个反射系统(一)

翻译自 preshing.com/20180116/a-...

在本文中,我将使用 C++ 11 的语言特性完成一个轻量,灵活的运行时反射 系统。这是一个为 C++ 类型生成元数据 (metadata) 的系统。元数据采用 TypeDescriptor 对象的形式,在运行时创建,用于描述其他运行时对象的结构。

我将把这些对象称为类型描述符 。我编写反射系统的最初动机是在我自定义的 C++ 游戏引擎中支持序列化,因为我有非常特殊的需求。一旦这个方法奏效,我便开始在其他引擎功能中使用运行时反射:

  • 3D rendering:每次游戏引擎使用 OpenGL ES 绘制内容时,它都会使用反射将统一参数 (uniform parameters) 和描述顶点格式 (vertex formats) 传递给 API。它使图形编程更富有成效!
  • Importing JSON:引擎的资产管道有一个通用例程,用于从 JSON 文件和类型描述符合成 C++ 对象。它用于导入 3D 模型、关卡定义和其他资产。

该反射系统基于预处理宏和模版。在当前的 C++ 版本中,实现运行时反射并不是一件容易的事情。任何编写过反射系统的人都知道,设计一个易于使用,易于扩展且实际有效的反射系统是很困难的。在确定今天的系统之前,我曾多次被模糊的语言规则,初始化顺序和极端情况所困扰。

为了说明它是如何工作的,我发布了一个示例项目在 Github:

github.com/preshing/Fl...

这个示例实际上并没有使用我游戏引擎的反射系统。它使用了自己的一个小反射系统,但最有趣的部分------类型描述符的创建 (created)结构 (structured)查找 (found) ------ 几乎是相同的。这些是我将在本篇文章中重点讨论的部分。在下一篇文章中,我将讨论如何扩展该系统。

这篇文章是为那些对如何开发运行时反射系统感兴趣的程序员写的。它涉及到 C++ 的许多高级特性,但是样例项目只有 242 行代码,所以希望有恒心,有决心的 C++ 程序员都能跟上。如果你对使用现有的解决方案更感兴趣,请查看 RTTR。

展示

在 Main.cpp 中,示例项目定义了一个名为 Node 的结构体。REFLECT() 宏告诉系统为这种类型启用反射。

c++ 复制代码
struct Node {
    std::string key;
    int value;
    std::vector<Node> children;
    
    REFLECT();		// Enable reflection for this type
};

在运行时,示例创建了一个类型为 Node 的对象。

c++ 复制代码
// Create an object of type Node
Node node = {"apple", 3, {{"banana", 7, {}}, {"cherry", 11, {}}}};

在内存中,Node 对象如下所示:

之后,示例查找 Node 的类型描述符。要使其工作,必须将以下宏放在 .cpp 文件的某个位置。我把它们放在 Main.cpp 中,但是它们也可以放在任何可以看到 Node 定义的文件中。

c++ 复制代码
// Define Node's type descriptor
REFLECT_STRUCT_BEGIN(Node)
REFLECT_STRUCT_MEMBER(key)
REFLECT_STRUCT_MEMBER(value)
REFLECT_STRUCT_MEMBER(children)
REFLECT_STRUCT_END()

Node 的成员变量现在被称为反射 (reflected)

一个指向 Node 类型描述符的指针是通过调用 reflect::TypeResolver<Node>::get()

c++ 复制代码
// Find Node's type descriptor
reflect::TypeDescriptor* typeDesc = reflect::TypeResolver<Node>::get();

找到类型描述符后,示例使用它将 Node 对象的描述输出到控制台。

c++ 复制代码
// Dump a description of the Node object to the console
typeDesc->dump(&node);

输出如下:

宏是如何实现的

当你将 REFLECT() 宏添加到结构体或类中时,它声明了两个额外的静态成员:Reflection (结构体的类型描述符) 和 initReflection (初始化类型描述符的函数)。实际上,当宏展开时,完整的 Node 结构看起来像这样:

c++ 复制代码
struct Node {
	std::string key;
    int value;
    std::vector<Node> children;
    
    // Declare the struct's type descriptor:
    static reflect::TypeDescriptor_Struct Reflection;
    
    // Declare a function to initialize it:
    static void initReflection(reflect::TypeDescriptor_Struct*);
};

类似地,Main.cpp 中的 REFLECT_STRUCT_*() 宏在展开时是这样的:

c++ 复制代码
// Definition of the struct's type descriptor:
reflect::TypeDescriptor_Struct Node::Reflection{Node::initReflection};

// Definition of the function that initializes it:
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 类型别名的地方:使用 T = Node。如果没有类型别名,我们必须将标识符 Node 作为额外的参数传递给每个 REFLECT_STRUCT_MEMBER() 宏。宏就不那么容易使用了。

如你所见,在函数内部,有三个额外的函数调用 reflect::TypeResolver<>::get()。每个函数都为 Node 的反射成员找到类型描述符。这些调用使用 C++ 11 的 decltype 说明符自动将正确的类型传递给 TypeResolver 模版。

查找 TypeDescriptors

TypeResolver 是一个类模版。当你为特定类型 T 调用 TypeResolver<T>::get()时,编译器实例化一个函数,该函数为 T 返回相应的TypeDescriptor 。它服务于反射结构以及这些结构的反射成员。

默认情况,如果T是一个包含 REFLECT()宏的结构体(或者类),像Nodeget()将返回一个指向该结构体 Reflection成员的指针。对于其他类型Tget()调用getPrimitiveDescriptor<T>------一个处理intstd::string 等基本类型的函数模版。

c++ 复制代码
// Declare the function template that handles primitive types such as int, std::string, etc.:
template <typename T>
TypeDescriptor* getPrimitiveDescriptor();

// A helper class to find TypeDescriptors in different ways:
struct DefaultResolver {
    ...
    // This version is called if T has a static member variable named "Reflection":
    template <typename T, /* SFINAE stuff here */>
    static TypeDescriptor* get() {
        return &T::Reflection;
    }
    
    // This version is called otherwise:
    template <typename T, /* SFINAE stuff here */>
    static TypeDescriptor* get() {
        return getPrimitiveDescriptor<T>();
    }
};

// This is the primary class template for finding all TypeDescriptors:
template <typename T>
struct TypeResolver {
    static TypeDescriptor* get() {
        return DefaultResolver::get<T>();
    }
};

这有一点编译时逻辑------根据T中是否存在静态成员变量生成不同的代码------使用 SFINAE 实现的。我从上面的代码片段中省略了 SFINAE 代码,因为坦率地说,它很丑。它的一部分可以用if constexpr更优雅地重写,但我的目标是 C++11。即使这样,检测 T 是否具有特定成员变量的部分仍然很难看,至少在 C++ 实现静态反射之前是这样。

TypeDescriptors 的结构

在样例项目中,每个TypeDescriptor都有 name,size,和两个虚函数。

c++ 复制代码
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<>特化的子类。为了返回一个完整的类型名称,例如"std::vector<Node>",它保留了一个指向其元素的类型描述符的指针。你可以在上面的内存图中看到这一点:有一个TypeDescriptor_StdVector对象,它的itemType成员一直指向Node的类型描述符。

当然,类型描述符只描述类型。对于运行时对象的完整描述,我们需要类型描述符和指向对象本身的指针。

注意,TypeDescriptor::dump()接受指向对象的指针,其类型为const void*。这是因为抽象的TypeDescriptor接口旨在处理任何类型的对象。子类化的实现知道期望的类型。例如,下面是TypeDescriptor_StdString::dump()。它将const void*强制转换为const std::string*

c++ 复制代码
virtual void dump(const void* obj, int /*unused*/) const override {
    std::cout << "std::string{\"" << *(const std::string*) obj << "\"}";
}

你可能想知道以这种方式强制转换 void 指针是否安全。显然,如果传入一个无效的指针,程序很可能崩溃。这就是为什么在我的游戏引擎中,由 void 指针表示的对象总是带着它们的类型描述符成对移动。通过以这种方式表示对象,可以编写多种泛型算法。

在样例项目中,将对象打印输出到控制台是唯一的功能,但是你可以想象类型描述符如何作为序列化为二进制格式的框架。

在下一篇文章中,我将解释如何向反射系统添加内置类型,以及上图中的"匿名函数"(anonymous functions) 是干什么用的。我还将讨论扩展该系统的其他方法。

相关推荐
OTWOL33 分钟前
两道数组有关的OJ练习题
c语言·开发语言·数据结构·c++·算法
QQ同步助手1 小时前
C++ 指针进阶:动态内存与复杂应用
开发语言·c++
qq_433554541 小时前
C++ 面向对象编程:递增重载
开发语言·c++·算法
易码智能1 小时前
【EtherCATBasics】- KRTS C++示例精讲(2)
开发语言·c++·kithara·windows 实时套件·krts
ཌ斌赋ད1 小时前
FFTW基本概念与安装使用
c++
薄荷故人_2 小时前
从零开始的C++之旅——红黑树封装map_set
c++
悲伤小伞2 小时前
C++_数据结构_详解二叉搜索树
c语言·数据结构·c++·笔记·算法
m0_675988233 小时前
Leetcode3218. 切蛋糕的最小总开销 I
c++·算法·leetcode·职场和发展
code04号6 小时前
C++练习:图论的两种遍历方式
开发语言·c++·图论
煤泥做不到的!8 小时前
挑战一个月基本掌握C++(第十一天)进阶文件,异常处理,动态内存
开发语言·c++