www.adminn.cn
站长正能量分享网!

简单的 C+结构体字段反射

本文不讨论完整的 C++ 反射技术,只讨论结构体(struct)的字段(field)反射,及其在序列化/反序列化代码生成上的应用。

正文开始于§ 静态反射部分,其他部分都是铺垫,可以略读。

打包后的代码可以通过archived.zip下载,每个文件上都有对应的编译、运行脚本,或者可以通过脚本运行所有代码。

1. 背景

很多人喜欢把程序员称为码农,程序员也经常嘲讽自己每天都在搬砖。这时候,大家会想:能否构造出一些更好的工具,代替我们做那些无意义的体力劳动呢?

在实际 C++ 项目中,我们经常需要实现一些与外部系统交互的接口—— 外部系统传入 JSON 参数,我们的程序处理后,再以 JSON 的格式传回外部系统。这个过程就涉及到了两次数据结构的转换:

输入的 JSON 转换为 C++ 数据结构(反序列化deserialization)

C++ 数据结构 转换为 输出的 JSON(序列化serialization)

如果传输的 JSON 数据格式(schema)非常繁多、比较复杂,那么序列化/反序列化的代码也会变得非常复杂 —— 需要处理结构嵌套、可选字段、输入合法性检查等问题。如果为每个 JSON 数据结构都人工手写一套序列化/反序列化代码,那么工作量会特别大。

例如,chromium/headless 的 devtools 相关接口里就定义了 33 个领域模型(domain model),每个模型有自己的格式,其中又包含了许多字段。

懒惰是程序员的天性:

“勤奋” 的程序员选择§ 人工手写 序列化/反序列化 代码

“懒惰” 的程序员选择

构建代码生成器(例如protobuf、chromium/mojo)

或§ 编译器生成 序列化/反序列化 代码

代码生成器虽然功能强大,但依赖复杂,不易于和已有系统集成。所以本文主要讨论如何用 C++ 14 提供的元编程(metaprogramming)技巧(C++ 11 也支持),让编译器帮你写代码。

2. 目标

基于 C++原生语法,不需要引入第三方库

支持非侵入式(nonintrusive)接口,能直接应用到已有代码上

提供声明式(declarative)的方法,只需要声明格式,不需要写逻辑语句

不会带来额外的运行时开销,能达到和手写代码一样的运行时效率

基于nlohmann 的 C++ JSON 库,给定两个 C++ 结构体 和 :

为嵌套对象,为嵌套的对象数组

为可选字段;由于`std::optional`需要 C++ 17 支持,所以我们使用`std::unique_ptr`表示可选字段

针对可选字段的 JSON 序列化/反序列化扩展代码,见`optional_json.h`(参考:How do I convert third-party types? | nlohmann/json)

一般接口的业务处理,往往包括三部分:

解析输入(字符串到 JSON 对象的转换 + JSON 对象到领域模型的反序列化)

处理业务逻辑(实际需要我们写的代码)

转储输出(领域模型到 JSON 对象的序列化+ JSON 对象到字符串的转换)

对于 JSON 对象和字符串之间的转换,主流的JSON 库都实现了:

调用从字符串得到输入 JSON 对象

调用将 JSON 对象转为用于输出的字符串

而 JSON 对象和 C++ 结构体之间的转换,需要我们实现:

通过反序列化,调用得到

通过序列化,使用构造输出 JSON 对象

3. 实现

实现从 C++ 结构体到 JSON 的序列化/反序列化操作,需要用到以下信息:

结构体有哪些字段

////

/

每个字段在结构体中的什么位置

/&///

/

每个字段在JSON 中对应的名称是什么

////

/

每个字段如何从 C++ 到 JSON 进行类型映射

对于很多支持反射(reflection)的语言,JSON 的解析者可以通过反射接口,查询到 / 所有的字段信息。

尽管 C++ 支持运行时类型信息(RTTI, run-time type information),但无法得到所有上述信息,所以需要SimpleStruct 的定义者把这些信息告诉JSON 的解析者。

4. 人工手写 序列化/反序列化 代码

实现序列化/反序列化最简单的方法,就是通过人工编写代码:

在/中包含了所有字段的位置、名称、映射方法:

使用序列化

使用反序列化

针对可选字段检查字段是否存在,不存在则跳过

nlohmann 的 C++ JSON 库能处理结构嵌套:

会调用序列化

会调用反序列化

nlohmann 的 C++ JSON 库基于 C++ 原生的异常处理():

如果字段不存在,函数抛出异常

如果字段实际类型和 JSON 输入类型不匹配,函数抛出异常

手写/需要写 2 份类似的代码:

一方面,需要复制粘贴,导致代码冗余

另一方面,两份代码逻辑不是对称的(需要特殊处理可选字段),不易于统一编写

5. 动态反射

“崇尚偷懒”的 Google 的工程师,为chromium/base::Value构建了一套基于动态反射(dynamic reflection)的反序列化机制,实现统一的 JSON 数据和 C++ 结构体转换。(参考:chromium/base::JSONValueConverter)

核心原理是:利用适配器模式(adapter pattern)和策略模式(strategy pattern),定义接口(interface)抹除具体字段转换操作的类型,通过运行时多态(runtime polymorphism)调用接口进行实际的转换操作。

Talk is cheap, show me the code——代码链接

首先,为不同字段类型定义一个通用的转换接口 ,用于存储实际的 C++ 类型与 JSON 类型的转换操作(仅关联操作的字段类型,抹除具体转换操作的类型):

参数表示字段的值,是字段的名称

原始代码将定义为接口;本文为了化简,直接使用(关于使用接口的讨论,参考:回调 vs 接口)

然后,为不同类型的结构体定义一个通用的转换接口,用于存储结构体内所有字段的转换操作(仅关联结构体的类型,抹除操作的字段类型):

接着,通过 将上边两个接口承接起来,用于存储结构体的字段类型的实际转换操作(类似于double dispatch),同时关联上具体某个字段的位置和名称(实现 FieldConverterBase 接口,调用 ValueConverter 接口):

构造时传递 字段名称

字段的成员指针(member pointer)(即字段位置)

字段的映射方法

在转换时调用 :

,传入当前结构体中字段的值和字段的名称;其中结构体字段的值通过得到

最后,针对结构体定义一个存储所有字段信息(名称、位置、映射方法)的容器 ,并提供注册字段信息的接口(有哪些字段) 和执行所有转换操作的接口 (仅关联结构体的类型,利用 FieldConverterBase 抹除操作的字段信息):

具体使用时,只需要两步:

构造对象,调用动态绑定字段信息(名称、位置、映射方法)

调用对所有注册了的字段进行转换

基于动态反射的开源库:

https://github.com/fnc12/sqlite_orm

https://github.com/billyquith/ponder

https://github.com/rttrorg/rttr

6. 静态反射

实际上,实现序列化/反序列化所需要的信息(有哪些字段,每个字段的位置、名称、映射方法),在编译时(compile-time)就已经确定了 —— 没必要在运行时(runtime)动态构建 对象。所以,我们可以利用静态反射(static reflection)的方法,把这些信息告诉编译器,让它帮我们生成代码。

核心原理是:利用访问者模式(visitor pattern),使用元组记录结构体所有的字段信息,通过编译时多态(compile-time polymorphism)针对具体的字段类型进行转换操作。

Talk is cheap, show me the code——代码链接

首先,定义一个函数模板(function template),返回所有字段信息(默认返回空元组):

然后,提供以下两个宏:

定义结构体字段信息(有哪些、位置、名称),隐藏 和 的实现细节:

返回元组的结构是:

定义了结构体有哪些字段

定义了每个字段的位置、名称

提供了一种宏内数据接力的方法,让下一个宏能获取上一个宏的数据

最后,提供 函数,从对应的 取出记录结构体所有字段信息的元组,然后遍历这个元组,从中取出每个字段的位置、名称,作为参数调用转换函数 :

接受的参数分别为:字段的值和名称

字段的值通过得到,其中是成员指针

的实现中还用到了静态断言(static assert)检查,具体见代码

检查是否定义了字段信息

检查每个字段的信息是否都包含了位置和名称

具体使用时,也是需要两步:

使用下面两个参数静态定义字段信息(名称、位置)

调用并传入映射方法(泛型 functor 或泛型 lambda 表达式),对所有字段调用这个函数

静态反射过程中,最核心的地方:传入 的可调用对象 ,通过编译时多态针对不同字段类型选择不同的转换操作:

针对类型字段,调用

针对类型字段,调用

2019/2/19 补充

2019/1/11 补充(by fredwyan)

C++ 11 不支持泛型 lambda 表达式,可以使用泛型 functor代替传入 的可调用对象,从而实现编译时多态:

基于静态反射的开源库:

https://github.com/qicosmos/iguana

使用编译时静态反射,相对于运行时动态反射,有许多优点:

7. 编译器生成 序列化/反序列化 代码

基于 ,我们可以实现通用的结构体序列化/反序列化函数:

和§ 人工手写 序列化/反序列化 代码的代码类似:

使用序列化

使用反序列化

针对可选字段检查字段是否存在,不存在则跳过(C++ 17 还可以使用实现选择性编译)

关于如何使用扩展自定义类型的序列化/反序列化操作,参考How do I convert third-party types? | nlohmann/json

使用的两个简单的变量模板(variable template),具体见代码

检查是否定义了:

检查字段类型是不是可选参数

对于需要进行序列化/反序列化的自定义结构体,我们只需要使用下面这两个参数声明其字段信息即可 —— 不需要为每个结构体写一遍/逻辑了:

于是,编译器就可以生成和§ 人工手写 序列化/反序列化 代码一致的代码了。

8. 写在最后

不依赖于第三方库,只需要简单的声明,没有额外的运行时开销 —— 这就是现代 C++ 元编程。

掌握 C++ 元编程,自己打造工具,解放生产力,告别搬砖的生活!

赞(0)
本站部分价值文章来源网络,版权归创作者所有,如有侵权或需署名或著出请联系677123@qq.com:站长分享圈 » 简单的 C+结构体字段反射

Adminn.cn

Adminn.Cn VIP会员更精彩腾讯云6.18优惠服务器