中国电子科技集团公司第二十九研究所 611730
一、概述
反射,又叫自省,也即运行时类型信息RTTI,在运行时获取到与类型密切相关的TypeInfo对象,包含类型类型名称、字段信息、成员函数信息、信息接口等重要信息。通俗来说,反射就是在程序运行过程中,对于一个类能够获取该类的所有属性和方法,对于一个对象能够调用该对象的属性和方法。Java、C#等语言可以在程序编译期将变量的反射信息整合到可执行文件中,并给程序提供接口访问反射信息,这样就可以在程序运行期获取类型的反射信息,并且有能力修改它们。追求零开销抽象的C++,在语言设计之初并没有在语言层面提供反射的支持,但是可以通过模板和宏实现编译期反射能力。本文给出了一种C++静态反射的实现方法,并分析了静态反射的一些典型应用。
二、基础
元编程是通过操作程序实体,在编译时计算出运行时所需要的常数、类型、代码的方法。一般的编程是通过直接编写程序,通过编译器编译,产生目标代码,并用于运行时执行。与普通的编程不同,元编程则是借助语言提供的模板机制,通过编译器推导,在编译时生成程序。元编程经过编译器推导得到的程序,再进一步通过编译器编译,产生最终的目标代码。因此元编程又被称为生成式编程或模板元编程。
C++的模板机制仅仅提供了纯函数的方法,即不支持变量,且所有的推导必须在编译时完成。但是C++中提供的模板是图灵完备的,所以可以使用模板实现完整的元编程。
元编程基本演算规则有两种:编译时测试和编译时迭代,分别实现了控制结构中的选择和迭代。基于这两种基本的演算方法,可以完成更复杂的演算。
二、实现原理
C++的静态反射,要求仅使用标准语法提供的模板和宏机制,提供声明式写法,只需要声明格式,不需要编写逻辑语句,就能生成并获取结构体/类的字段定义,并且不会带来额外的运行时开销,能达到极高的运行效率。
反射不是目的,只是手段而已,正主是元数据。C++里面可以定义一些ID或者枚举值,然后把相关联的元数据放到哈希表/链表/树结构等数据结构中,需要的时候就去查询出来使用。因此实现反射的步骤有以下几点:
1、制作生成元数据
由于C++坚持零开销的语言哲学,编译期几乎抹掉了所有的类型信息,C++要附加元数据,只能自己手工来制作,制作好之后可以放在静态变量中。
2、查询元数据
比如说根据类名或者方法名去反射元数据的时候,本质上就是查询,就是对字符串的搜索和匹配。在C++层面,自己定义一些整形ID作为标识符来替代类名和方法名的搜索,可以提高检索速读。
3、根据元数据执行对应的操作
对C++来说,调用元数据中的方法,直接通过函数指针/仿函数/类型转换就可以直接调用。
三、实现方法
1)制作生成元数据
反射所需的元数据一般包括类名称、属性名称、属性值等,为了制作结构体对应的元数据,可以在结构体中定义。为了在C++中实现反射,处理器需要在类内部生成关于类属性的反射元数据。可以通过宏的匹配机制,在声明结构体成员时,展开生成成员的属性名称、属性值等元数据。
反射所需要的元数据一般包括类型名称、属性名称、属性值等,同时还需要每个字段在结构体的位置、映射方法,这些信息需要在编译器编译时传递给编译器,由编译器帮我们生成代码。可以通过宏的匹配机制,在声明结构体成员时,展开生成成员的所有元数据信息。
#define FIELD_EACH(i, arg) \
PAIR(arg); \
template \
struct FIELD { \
T& obj; \
FIELD(T& obj): obj(obj) {} \
auto value() -> decltype(auto) { \
return (obj.STRIP(arg)); \
} \
static constexpr const char* name() { \
return STRING(STRIP(arg)); \
} \
};
FIELD_EACH宏根据字段id和声明信息,展开生成了PAIR属和struct FIELD。PAIR展开就是一般的结构体内部声明属性。内部模板类struct FIELD则作为元数据的容器,包含了该属性的元数据,包括结构体实例的引用(用于获取&修改该属性值)、获取属性字段名称和获取字段值的方法。使用FIELD_EACH宏,就可以在结构体内部声明属性变量的同时,制作并生成该属性的元数据,并存储在结构体类定义中。
结构体定义的其他属性部分可以通过宏的可变长参数匹配,重复性替换为FIELD_EACH宏,并逐一生成所有属性的元数据。
2)查询元数据
有了结构体属性的元数据,就可以通过FIELD获取结构体中所有属性的属性名和属性值。首先需要定义一个高阶函数forEach,实现Visitor模式,其接受两个参数,一个传递反射的对象,一个函数f,并遍历函数的所有属性,并调用函数f。
template // (1)
inline constexpr void forEach(T&& obj, F&& f, std::index_sequence) {
using TDECAY = std::decay_t;
(void(f(typename TDECAY::template FIELD(obj).name(),
typename TDECAY::template FIELD(obj).value())), ...);
}
template // (2)
inline constexpr void forEach(T&& obj, F&& f) {
forEach(std::forward(obj),
std::forward(f),
std::make_index_sequence::_field_count_>{});
}
forEach是一个重载函数。重载函数2参数仅有结构体实例obj和函数f,在函数实现部分,通过make_index_sequence生成编译期静态常量列表(即0,1,2..N),然后调用重载函数1。重载函数1多了一个变长参数Is,在函数实现部分,对每一个变长参数调用f函数,该部分省略模板和变长参数部分,可简化为 f(FIELD(obj).name, FIELD(obj).value)。后面的...可自动对所有变长参数Is自动展开并调用函数f。由此实现了对结构体实例obj的遍历访问。
3)根据元数据执行对应的操作
在查询元数据部分,我们定义了高阶函数forEach,并可以传递结构体实例obj和可调用函数fn,实现了对结构体所有属性的遍历,因此只需要定义不同的调用函数fn,通过编译时多态就可实现对结构体实例属性的操作。比如,为了调试需要,遍历输出结构体的所有属性和属性值,可以编写通用的输出函数,并将函数作为参数传递给forEach,实现通用的打印输出。输出函数如下所示:
template
void dumpObj(T&& obj, const char* fieldName = "", int depth = 0) {
auto indent = [depth] {
for (int i = 0; i < depth; ++i) {
std::cout << " ";
}
};
if constexpr(std::is_class_v>) { // (1)
indent();
std::cout << fieldName << (*fieldName ? ": {" : "{") << std::endl;
forEach(obj, [depth](auto&& fieldName, auto&& value) {
dumpObj(value, fieldName, depth + 1);
});
indent();
std::cout << "}" << (depth == 0 ? "" : ",") << std::endl;
} else { // (2)
indent();
std::cout << fieldName << ": " << obj << "," << std::endl;
}
}
dumpObj是一个递归函数,通过检查属性是否为基本类型,来判断是否需要递归打印。如果是基本类型,进入分支2,直接将其打印,如果是结构体,进入分支1,进一步,递归遍历结构体各个字段,直到遍历所有结构体为止。
四、静态反射的应用
1)序列化/反序列化
反射功能最典型的应用就是序列化和反序列化。在各个模块之间进行通信交互,不管是跨进程、跨机器,都需要对结构体进行序列化/反序列化操作。如果使用人工手写代码,不仅代码丑陋、工作量大、容易出错,而且后期维护的成本高昂,且极容易出错。如果利用反射手段,只需要写一次,就能够给所有对象自动生成相关函数代码。iguana
【1】是一个轻量的C++序列化库,只需引用头文件并添加REFLECTION宏,就可实现结构体的序列化和反序列化。
3)格式转换
XML和JSON是最常用的两种数据格式,常常用于数据交换,具有人类可读性和机器可读性等优点,常常用于数据存储和数据交换。C++结构体与JSON/XML的相互转换,可以使用静态反射,自动匹配参数个数并进行参数类型校验,避免了大量人工手写代码。struct2x【2】是一个开源的C++结构体与JSON快速超高效互转库,它采用反射和代码生成等方式,可以快速实现结构体对象与JSON对象之间序列化与反序列化要求。
3)日志输出
长时间稳定运行的程序一般都需要日志输出,用于追踪数据的变化,显示程序运行状态,快速定位问题。使用反射手段,可以使用通用输出函数,编写一次即可自适应不同类型数据,即使数据结构发生变化,也只需要重新编译即可,大大降低了工作量和出错的情况。
4)字节序转换
大端和小端表示多字节值的哪一端存储在该值的起始地址出,网络传输中一般规定使用大端字节序,而实际计算机的字节序则没有规定。对于经网络传输获取到的字节流,在完成反序列化后还需要对各个数值进行字节序转换。使用反射和代码可以生成根据结构体定义,遍历各属性字段进行字节序转换,避免手工重复性编写转换函数。
5)ORM框架
ORM全称是:Object Relational Mapping(对象关系映射),其主要作用是在编程中,把面向对象的概念跟数据库中表的概念对应起来。通过定义一个对象,对应这数据库中的一张表,这个对象的实例对应着表中的一条记录,不需要编写SQL语句,通过操作对象实现数据库表的操作。ormpp【3】是一个基于C++静态反射实现的ORM库,提供了统一的接口,支持多种数据库。
五、展望
目前C++语言未能提供反射信息,只能手动描述对应的元信息,通过宏展开生成代码,结合模板元编程,就能够为任意结构体生成对应的序列化、反序列化代码,减少程序员重复劳动、容易出错的问题。C++静态反射不仅能提高程序的灵活性和扩展性,降低程序耦合,提高自适应能力,还不会带来额外的运行时开销,具有十分宽广的应用前景。
参考文献:
【1】https://github.com/qicosmos/iguana
【2】https://github.com/yksten/struct2x
【3】https://github.com/qicosmos/ormpp