反射
ll/api/reflection/ · 通用
概述
Reflection 模块让 C++ 结构体和 JSON / NBT 之间来回映射,而不必给每个类型手写一遍读写代码。典型落点:
- 模组配置(Config 就建立在它之上)
- 模组清单
manifest.json - 存档 / 玩家数据之类的持久化内容
- 自定义 NBT 数据(
CompoundTagVariant) - 网络包或其它 JSON-like / NBT-like 结构
它并不只面向 JSON:nlohmann::json 只是最常见的目标类型之一,凡是提供 is_object / is_array / is_string 这类查询接口、以及 object() / array() 工厂的类型,都能用同一套代码处理。
这一页怎么读:
- 想把普通结构体存成 JSON —— 看「快速上手」;
- 想让某个类型以字符串形式出现(比如版本号、UUID)—— 看「给一个类型写最简单的特化」;
- 需要控制必选规则、写复杂特化、或者"特化明明写了却没生效" —— 看反射(进阶篇)。
头文件
| 头文件 | 说明 |
|---|---|
ll/api/reflection/Reflection.h |
核心反射工具、成员遍历、字符串转换 |
ll/api/reflection/Serialization.h |
通用序列化 |
ll/api/reflection/Deserialization.h |
通用反序列化 |
ll/api/reflection/Serializer.h |
自定义扩展点 |
ll/api/reflection/Dispatcher.h |
Dispatcher 支持 |
ll/api/reflection/ReflectionError.h |
常见反射错误 |
ll/api/reflection/TypeName.h |
编译期类型名与类型名裁剪工具 |
什么类型可以直接反射
当一个类型是聚合体时,不用写任何特化就能直接序列化。聚合体的要求是:没有用户声明的构造函数、没有私有或受保护的非静态数据成员、没有私有或受保护的基类。典型例子:
- 只包含公开数据成员的普通结构体
- 嵌套聚合结构体
- 成员里包含容器、
std::optional、std::variant
默认映射规则是「成员名 → 字段名」,逐成员递归处理。每种类型对应什么表示:
| C++ 类型 | 输出 | 备注 |
|---|---|---|
算术类型 int / float / double … |
数字 | bool 输出布尔值;数值转换是严格的,见下 |
std::string 等字符串类型 |
字符串 | |
std::string_view |
字符串 | 只能从可安全借用的左值源反序列化,见进阶篇 |
| 枚举 | 字符串(枚举名) | 名字不可得时退回底层整数 |
std::optional<T> |
T 的表示 |
空值作为对象成员时整个键被省略,不是 null |
std::variant<...> |
当前分支的表示 | |
std::pair / std::tuple / std::array |
数组 | 反序列化时元素个数必须一致 |
std::vector / std::list / std::set … |
数组 | |
std::map / std::unordered_map … |
对象 | 键类型必须能转成字符串 |
| 嵌套聚合体 | 对象 | 递归应用同一套规则 |
两个容易踩的点:
- 默认反序列化要求所有非 optional 成员都出现在输入里。成员的默认初始化值(
int level = 0;)不参与补齐,缺字段会直接报缺失字段错误;输入里多出来的键会被忽略。 - 数值转换是严格的:越界(例如把
300写进int8)、带小数的值写进整数类型、负数写进无符号类型都会报错,而不是静默截断或回绕;布尔值写进算术类型会变成 0 / 1。
不适用的类型(有私有成员、有自定义构造函数、需要专门的构造逻辑、要序列化成字符串或带判别字段的对象……)请写 Serializer<T> 特化,做法见下文。
快速上手:基础聚合类型
第一次用,照这三步走。
1. 定义结构体
字段是公开的普通数据成员即可:
| C++ | |
|---|---|
1 2 3 4 5 6 7 8 9 10 | |
2. 序列化
| C++ | |
|---|---|
1 2 3 4 5 6 7 8 | |
得到的就是按成员名展开的对象:
| JSON | |
|---|---|
1 2 3 4 5 | |
nlohmann::json 默认按键名排序,所以输出顺序和成员声明顺序不同;想保持声明顺序就换 nlohmann::ordered_json(配置系统正是这么做的)。
3. 反序列化
| C++ | |
|---|---|
1 2 3 | |
deserialize_to<T> 会读出一个新的 T;如果想写进已经存在的对象(例如调用方持有的配置对象),改用 deserialize(out, json)。
默认规则是「成员名 → 字段名」,逐成员递归处理;每种类型对应什么表示见上一节的对照表。注意它是严格的:输入里少一个非 optional 成员就会报缺失字段错误,多出来的键会被静默忽略。
核心 API
序列化
| C++ | |
|---|---|
1 2 3 4 5 6 7 8 | |
其中 J 是目标类型,例如 nlohmann::json、CompoundTagVariant,或你自己通过 Serializer<T> 支持的类型。
反序列化
| C++ | |
|---|---|
1 2 3 4 5 6 7 8 | |
所有公开入口都返回 ll::Expected,用法和 LL 其它 API 一致:
| C++ | |
|---|---|
1 2 3 4 5 | |
同一个操作通常有两个入口,按"手上有没有现成的目标对象"来选:
| 你想做的事 | 用哪个入口 |
|---|---|
| 把值转成目标类型,拿到一个新对象 | serialize<J>(value) / deserialize_to<T>(input) |
| 写进一个已经存在的目标对象 | serialize_to<J>(out, value) / deserialize(out, input) |
每个入口都有带 key formatter 和不带的两个版本;不带 formatter 时使用默认 formatter(键名原样)。
给一个类型写最简单的特化
有些类型天生不适合"按成员展开":UUID、版本号、资源 ID 之类,它们最自然的形态是一个字符串。做法是给 Serializer<T> 写特化,一共两层——第一层让它在当键时能转成字符串,第二层让它在对象里也输出成字符串。
以强类型 ID 为例:
| C++ | |
|---|---|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 | |
写完之后的差别:
| C++ | |
|---|---|
1 2 3 4 5 6 7 8 9 10 | |
几点提示:
- 两层要一起写:只写
to_string/from_string时,类型当键没问题,但作为普通成员仍然会按成员展开(聚合体)或直接报错(非聚合体)。 - 签名要对齐、返回值不要写
void,否则特化会被静默忽略——完整清单见进阶篇。 - LL 内部对
mce::UUID、ll::data::Version就是这套做法;它们用的是「原地式序列化 + 值式反序列化」的组合(serialize(t, j)把字符串写进目标),框架两种写法都接受。 - 只想改变"键的写法"(例如 UUID 键全大写)而不想让类型本身变成字符串,请用 key formatter,见下一节。
可选与必选字段
默认反序列化是严格的,规则只有三条:
| 成员声明 | 输入里缺这个键时 |
|---|---|
std::optional<T> m{}; |
成功,m 变成 std::nullopt |
普通成员 T m{}; |
报 missing required field "m" when deserializing |
写出方向与之对应:成员是空的 std::optional<T> 时,整个键会被省略,而不是写成 null。也就是说"缺键"和 null 在输出里无法区分,读取方要按可选字段处理。
需要"缺字段时保留旧值"或"缺字段时用默认值"这类语义,请使用 default_member / default_field(见进阶篇的成员辅助函数一节),或者干脆把成员改成 std::optional<T>。
Key Formatter
Key formatter 用来控制成员名、枚举名、键名在序列化 / 反序列化时如何转换,例如全部转成大写:
| C++ | |
|---|---|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 | |
formatter 还可以只针对某种键类型定制(例如让 UUID 键大写、但解析规则不变),这类用法见进阶篇。
错误模型
所有公开接口都返回 ll::Expected,失败时给出带路径的可读信息,例如:
| Text Only | |
|---|---|
1 2 | |
常见错误:
| 错误信息(片段) | 含义 |
|---|---|
field must be a string / field must be an object / field must be an array |
类型不匹配 |
missing required field "x" when deserializing |
缺必选字段 |
field must be a number or boolean |
应该给数字或布尔 |
number 300 is out of range for signed char |
数值越界 |
number 1.5 is not an integer |
带小数的值写进整数类型 |
invalid enum value: x |
枚举值非法 |
invalid key: x |
关联容器的键非法 |
could not cast target object (...) to any type in the variant |
variant 没有任何分支能匹配 |
编译期报错主要是两条,都会带上具体类型名:
cannot serialize this type: .../cannot deserialize this type: ...:该类型没有任何可用的路径,需要写Serializer<T>特化。完整的编译期报错对照表和排查手法见进阶篇。
数值转换是严格的:越界、带小数、负数写进无符号类型都会报错,而不是静默截断或回绕,JSON 与 NBT 两个后端行为一致;布尔值写进算术类型会变成 0 / 1。
常见误区
- 只写了
to_string/from_string,却期望对象里有字符串——第二层也得写。 - 特化签名和文档不一致(多一个
const、少一个typename F)——会被静默忽略,聚合体下连报错都没有。 - 返回值写了
void——不支持。 - 以为成员的默认初始化值会补上缺字段——不会,缺非 optional 成员直接报错。
- 以为空的
std::optional会写成null——作为对象成员时整个键都会被省略。 - 依赖静默的数值转换——
1.5写进int、300写进int8、-1写进无符号类型都会报错。
下一步
需要以下内容时看反射(进阶篇):
- 完整签名表、返回值约束,以及"特化没生效"的自查清单;
- 成员辅助函数(
member/field/required_field/default_field:条件必选、缺省值); - 从测试里摘出来的复杂示例:判别字段结构、扁平化输出、不可默认构造的类型、判别式变体、ABI 包装、JSON 与 NBT 双形态;
- 把别的数据结构(脚本引擎对象、
Json::Value、自己的Value类型)接到反射上:自定义目标类型; - 调度优先级、
std::variant的匹配规则、Dispatcher、std::string_view生命周期、编译期报错对照表。