C++ 反射:探索
The following article is from CPP编程客 Author 里缪
动态反射 & 静态反射
动态反射,就是类型元信息在运行期产生的反射,而静态反射则是在编译期产生。
诸多反射库,都借助了各种办法在运行期或是编译期来保存类型的信息,从而实现反射的能力。
这些实现又有「侵入式」和「非侵入式」之分。
侵入式会在你的类中添加一些保存类型信息的组件,这往往会采用宏来自动产生,隐藏实现细节。
这种方式可以得到类型中的私有信息,因为往往会把保存信息的组件置为友元。但毫无疑问,此法会破坏原有类型的结构,一般这种库没人会用。
非侵入式则是在类外声明一些组件来收集类型元信息,这也是大多数反射库采取的实现方式。
同样,具体实现也会被隐藏起来,使用宏来自动产生相关代码,主要原因是为了使用方便。然而,此法无法得到类型中的私有信息,这是一处限制。另外,有些库利用一些技巧,从而无需手动注册类型信息,不过限制亦多。
总的来说,不可避免地,大多数库都需要用户手动地填写类型信息,这是反射机制缺少所致,因此使用起来多少都会有些限制。
以下各节,分别来介绍一些C++的反射库,探索一下基本用法。
Boost Describe Library
第一,来看Boost中包含的一个C++14反射库:Describe。
该库支持枚举、结构体和类的反射,先来看个官方提供的小例子:
#include <iostream>
#include <boost/describe.hpp>
#include <boost/mp11.hpp>
enum E
{
v1 = 1,
v2 = 2,
v3 = 3
};
BOOST_DESCRIBE_ENUM(E, v1, v2, v3)
int main()
{
using L1 = boost::describe::describe_enumerators<E>;
boost::mp11::mp_for_each<L1>([](auto D) {
std::cout << D.name << ": " << static_cast<int>(D.value) << std::endl;
});
return 0;
}
这个例子同样是用于输出枚举类型的值。
那么它是从哪得到枚举类型信息的呢?注意这里的BOOST_DESCRIBE_ENUM宏,此宏便是用来自动产生收集「枚举类型的元信息」的代码。
这个宏支持可变参数,第一个参数就是枚举类型,其后的参数则是枚举中的字段。
通过这种手动注册的方式,该库就可以提供类型的元信息。
对于以上例子,这个宏将自动展开成如下代码:
static_assert(std::is_enum < E > ::value, "BOOST_DESCRIBE_ENUM should only be used with enums");
inline auto boost_enum_descriptor_fn(E*) {
return boost::describe::detail::enum_descriptor_fn_impl(0, [] {
struct _boost_desc {
static constexpr auto value() noexcept {
return E::v1;
}
static constexpr auto name() noexcept {
return "v1";
}
};
return _boost_desc();
} (), [] {
struct _boost_desc {
static constexpr auto value() noexcept {
return E::v2;
}
static constexpr auto name() noexcept {
return "v2";
}
};
return _boost_desc();
} (), [] {
struct _boost_desc {
static constexpr auto value() noexcept {
return E::v3;
}
static constexpr auto name() noexcept {
return "v3";
}
};
return _boost_desc();
} ());
}
可以看到,这里自动产生了一系列代码来保存枚举类型的信息。
根据自动产生的这个boost_enum_descriptor_fn函数,它就能构建类型的描述信息,
template<class E> using describe_enumerators = decltype( boost_enum_descriptor_fn( static_cast<E*>(0) ) );
也因如此,主函数中的describe_enumerators才有了定义:
using L1 = boost::describe::describe_enumerators<E>;
boost::mp11::mp_for_each<L1>([](auto D) {
std::cout << D.name << ": " << static_cast<int>(D.value) << std::endl;
});
然后,通过mp11的mp_for_each便可以迭代类型的所有信息。
该库使用起来还算方便,但是也有一些限制,后面再来看。
RTTR Library
第二,来看一个使用人数较多的C++动态反射库:rttr。
该库的文档比较充足,有专门的网站www.rttr.org,因此遇到问题的话比较好解决。
同样,先来看一个简单的例子:
#include <rttr/registration>
#include <iostream>
using namespace rttr;
struct Point {
int x;
int y;
void print() {}
};
RTTR_REGISTRATION
{
registration::class_<Point>("Point")
.constructor<>()
.property("x", &Point::x)
.property("y", &Point::y)
.method("print", &Point::print);
}
int main()
{
type t = type::get<Point>();
for (auto& prop : t.get_properties())
std::cout << "name: " << prop.get_name() << std::endl;
return 0;
}
这个例子表示了如何使用rttr库来获取struct的类型信息。
它又是如何收集类型信息的呢?
注意其中的RTTR_REGISTRATION宏,这个宏用于产生初始化「注册类型元信息」的代码,注册类型元信息的代码应该写在该宏的下方,通过registration:class_来添加你要保存的类型信息。
若将代码展开,则相当于:
static void rttr_auto_register_reflection_function_();
namespace {
struct rttr__auto__register__ {
rttr__auto__register__() {
rttr_auto_register_reflection_function_();
}
}
;
}
static const rttr__auto__register__ auto_register__12;
static void rttr_auto_register_reflection_function_()
{
registration::class_<Point>("Point")
.constructor<>()
.property("x", &Point::x)
.property("y", &Point::y)
.method("print", &Point::print);
}
这里自动生成了rttr_auto_register_reflection_function_()函数的声明,用于注册反射信息,宏下方所写的注册类型元信息的代码其实就是该函数的定义。
通过定义一个静态rttr__auto__register__ 对象,在其构造函数中调用该注册函数,从而完成初始化类型元信息的收集工作。
之后,便可通过type t = type::get<Point>();获取保存的类型元信息,获取想要的属性。
该库注册类型信息稍显麻烦,使用倒是比较简单。
Cista Library
第三,介绍一个C++17序列化库:Cista,它也提供一些反射能力。
该库只有一个头文件,直接拷贝到项目中就能用,非常方便。它也有专门的网站https://cista.rocks/。
来看一个简单的例子:
#include <iostream>
#include "cista.h"
struct a {
int i_ = 1;
int j_ = 2;
double d_ = 100.0;
std::string s_ = "hello";
};
int main() {
a i;
cista::for_each_field(
i, [](auto&& m) {
std::cout << m << std::endl;
});
return 0;
}
咦!这里怎么不需要注册类型信息呢?
它的实现借助了structed bindings,把struct的所有成员组成了一个tuple,再从tuple来遍历出所有的成员。
由于使用了这种技术,所以它只能得到成员的值,而无法得到成员的名称。
这种实作法源自magic_get(https://github.com/apolukhin/magic_get),该库提供了一种著名的无手动注册反射实现手法,CppCon 2016的演讲"C++14 Reflections Without Macros, Markup nor External Tooling.."专门介绍了该实作法。
该库主要支持数据序列化功能,看个官方的简洁例子:
#include <cassert>
#include <iostream>
#include "cista.h"
int main() {
namespace data = cista::raw;
struct my_struct { // Define your struct.
int a_{ 0 };
struct inner {
data::string b_;
} j;
};
std::vector<unsigned char> buf;
{ // Serialize.
my_struct obj{ 1, {data::string{"test"}} };
buf = cista::serialize(obj);
}
// Deserialize.
auto deserialized = cista::deserialize<my_struct>(buf);
assert(deserialized->j.b_ == data::string{ "test" });
return 0;
}
由于不需要手动注册类型信息,你可以轻松将类型的数据保存起来,再进行恢复,性能很高。
总而言之,这个库设计所用到的技巧比较精妙,跟其他反射库的设计思路完全不一样。代价就是反射能力不足,比如无法获取类型和成员的名字。
iguana Library
最后,介绍下国人开发的一个C++17序列化库:iguana。其中包含有静态反射,地址为https://github.com/qicosmos/iguana。
使用该库,可以轻松将对象序列化为xml,json等常用形式,举个自带的小例子:
#include <iguana/json.hpp>
struct person
{
std::string name;
int age;
};
REFLECTION(person, name, age) //define meta data
int main()
{
person p = { "tom", 28 };
iguana::string_stream ss;
iguana::json::to_json(ss, p);
std::cout << ss.str() << std::endl;
return 0;
}
这将把person映射成json格式:
{"name":"tom","age":28}
这里是通过REFLECTION宏来自动产生保存类型信息的代码,上述代码中将展开为:
constexpr inline std::array<std::string_view, 2> arr_person = {
std::string_view("name", sizeof("name") - 1) , std::string_view("age", sizeof("age") - 1)
}
;
static auto iguana_reflect_members(person const&) {
struct reflect_members {
constexpr decltype(auto) static apply_impl() {
return std::make_tuple(&person::name, &person::age);
}
using type = void;
using size_type = std::integral_constant<size_t, 2>;
constexpr static std::string_view name() {
return std::string_view("person", sizeof("person") - 1);
}
constexpr static size_t value() {
return size_type::value;
}
constexpr static std::array<std::string_view, size_type::value> arr() {
return arr_person;
}
};
return reflect_members{};
}
所有的类型信息将保存到reflect_members之中,通过如下代码就能得到类型的反射信息:
using M = decltype(iguana_reflect_members(std::forward<T>(t)));
reflect_members中的name()保存了类型的名称,apply_impl()通过构建tuple保存了成员的类型,arr()则保存了每个成员的名称,size_type保存了成员的数量。
关于反射的使用例子请看下节。
Schema Generation
上面几节介绍了几个反射库的元信息产生手法和基本用法,作为对比,本节使用上述库来实现同一个功能,大家可以感受下其差异与便捷程度。
我们将使用这些库提供的反射能力,来为一个类型自动生成SQL语句,测试的结构体如下:
struct Account {
int id{ 100 };
std::string name{"jack"};
};
第一个,来看看如何使用Boost Describe实现该任务。
首先使用宏来注册类型信息,代码很简单:
BOOST_DESCRIBE_STRUCT(Account, (), (id, name))
注册类的宏需要提供三个参数,第一个是类的名称,第二个是继承类列表,第三个是属性列表,包含成员变量和成员函数。
接着,创建产生数据表的函数,代码如下:
template<typename T,
typename Md = boost::describe::describe_members<T, boost::describe::mod_any_access>>
std::string create_table() {
std::stringstream result;
int num = __member_number(T{});
result << "CREATE TABLE " << __type_name(T{}) << "(\n";
boost::mp11::mp_for_each<Md>([&](auto D) {
// create column
result << D.name << " ";
result << to_sql<decltype(T{}.*D.pointer)>();
if (--num != 0)
result << ",\n";
});
result << ");\n";
return result.str();
}
模板参数T就是欲创建SQL语句的类型,通过describe_members来得到该类型的所有成员信息,以Md表示。
在函数内部,开始组装SQL语句,此时创建表需要类型的字符式名称,然而Describe没有提供这个信息。因此,为了简便起见,我们直接修改源码,添加这两个反射能力,修改代码如下:
/*此处修改了源码,方便获取类型名称和成员数量*/
#include <boost/preprocessor/variadic/size.hpp>
#define BOOST_DESCRIBE_STRUCT(C, Bases, Members) \
inline constexpr char const* __type_name(C const&) { return #C; } \
inline constexpr int __member_number(C const&) { \
return BOOST_PP_VARIADIC_SIZE(BOOST_DESCRIBE_PP_UNPACK Members); } \
static_assert(std::is_class<C>::value, "BOOST_DESCRIBE_STRUCT should only be used with class types"); \
BOOST_DESCRIBE_BASES(C, BOOST_DESCRIBE_PP_UNPACK Bases) \
BOOST_DESCRIBE_PUBLIC_MEMBERS(C, BOOST_DESCRIBE_PP_UNPACK Members) \
BOOST_DESCRIBE_PROTECTED_MEMBERS(C) \
BOOST_DESCRIBE_PRIVATE_MEMBERS(C)
同样,它也没有提供直接获取成员个数的能力,因而此处添加了__member_number()来提供该能力,使用__type_name()则可以得到类型的名称。
之后,便可以开始为每个成员创建column,类型使用to_sql()来处理,代码同样简单:
template<typename T>
const char* to_sql() {
static_assert(false, "no translation to SQL");
}
template<>
const char* to_sql<int>() {
return "INTEGER";
}
template<>
const char* to_sql<std::string>() {
return "TEXT";
}
通过为指定类型提供to_sql特化版本,就能返回相应类型的SQL类型。
可以调用create_table看看效果:
std::cout << create_table<Account>();
// output:
CREATE TABLE Account(
id INTEGER,
name TEXT);
Describe实现这个功能如此简单,是因为我们修改了源码,增加了一些反射能力。其本身若只用来处理每个成员,则比较简单。
第二个,来看rttr如何完成该任务。
同样,首先注册类型信息,
RTTR_REGISTRATION
{
rttr::registration::class_<Account>("Account")
.constructor<>()
.property("id", &Account::id)
.property("name", &Account::name);
}
相较而言,rttr的这种实现注册信息稍微麻烦,需要手动填写太多内容。
接着,同样来编写相关函数,代码如下:
template<typename T>
auto create_table() {
rttr::type t = rttr::type::get<Account>();
int num = t.get_properties().size();
std::stringstream result;
result << "CREATE TABLE " << t.get_name() << "(\n";
for (auto& e : t.get_properties()) {
result << e.get_name() << " ";
auto type_name = e.get_type().get_name().to_string();
result << to_sql(type_name);
if (--num != 0)
result << ",\n";
}
result << ");\n";
return result.str();
}
rttr的反射能力提供的比较完整,所有需要的信息都能直接得到,命名亦是一目了然,使用起来体验还不错。
代码逻辑比较清晰,就不过多赘述,主要来看下to_sql()。
Describe的实现使用了偏特化来处理类型,而rttr对属性使用了类型擦除,所以想要直接得到成员类型反而困难。不过获取字符类型的比较简单,因此to_sql()也转换实现方案,实现如下:
const char* to_sql(const std::string& type_name) {
static std::unordered_map<std::string, const char*> types{
{"int", "INTEGER"},
{"std::string", "TEXT"}
};
auto p = types.find(type_name);
if (p == types.end())
return "";
return p->second;
}
这里采用映射的方案来处理类型,为简单起见,不支持的类型直接返回为空。
调用这个rttr的实现将会得到相同的结果。
最后一个,来看iguana如何完成这个任务。
为何跳过cista呢?因为它的实现和其它反射库的思路不一样,虽然不用手动注册类型信息,却也没办法获取成员的名称。
使用iguana来注册类型信息,代码如下:
REFLECTION(Account, id, name)
相对来说,手动填写的信息很少,但是也有舍弃,只能支持成员变量,函数、继承等等都不支持。
接着继续实现create_table函数,代码如下:
template<typename T>
constexpr auto create_table(T&& t) {
using M = decltype(iguana_reflect_members(std::forward<T>(t)));
int num = 0;
std::stringstream result;
result << "CREATE TABLE " << M::name() << "(\n";
iguana::for_each(std::forward<T>(t), [&num, &result, &t](auto &v, auto i) {
auto name = iguana::get_name<decltype(t), decltype(i)::value>();
result << name << " ";
result << to_sql<std::decay_t<decltype(t.*v)>>();
if (++num != M::value())
result << ",\n";
});
result << ");\n";
return result.str();
}
实现起来也相较简单,但是只有阅读了源码,你才能清楚如何获得想要的信息。
这毕竟是个序列化库,只是提供了转换成xml,json等格式的简便接口。反射属于幕后角色,不熟悉实现的话用起来比较麻烦。
代码逻辑与前面相同,故亦不赘述,调用该实现将得到相同的结果。
总结
限于篇幅,本文只是选取了几个实现有所差异的C++反射库进行讨论。
还有许多库,例如ponder、refl-cpp、CPP-Reflection等等,若感兴趣可以去看看。
若追求稳定,那选择rttr没问题,它的文档相当多,源码当中包含有规范的注释,反射能力相对完善,这也是使用人数较多的原因。
若追求效率,iguana和refl-cpp支持静态反射,前者文本已经介绍,后者用法有些繁琐,用哪个须得自己斟酌。
若不需要获取字段名称,cista这种无需手动注册的反射库也比较好用,序列化效率很高。
总之,这些反射库的实现都有取舍,需要根据实际需求进行选择。
- EOF -
关注『CPP开发者』
看精选C/C++技术文章
点赞和在看就是最大的支持❤️