Reflection(反射)
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)—— 看「給一個類型寫最簡單的特化」;
- 需要控制必選規則、寫複雜特化、或者"特化明明寫了卻沒生效" —— 看Reflection(反射 · 進階)。
頭文件
| 頭文件 | 說明 |
|---|---|
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. 定義結構體
字段是公開的普通數據成員即可:
#include "ll/api/reflection/Deserialization.h"
#include "ll/api/reflection/Serialization.h"
#include "nlohmann/json.hpp"
struct PlayerData {
std::string name{};
int level{};
double health{};
};2. 序列化
ll::Expected<nlohmann::json> toJson() {
PlayerData const player{
.name = "Steve",
.level = 10,
.health = 20.0,
};
return ll::reflection::serialize<nlohmann::json>(player);
}得到的就是按成員名展開的對象:
{
"health": 20.0,
"level": 10,
"name": "Steve"
}nlohmann::json 默認按鍵名排序,所以輸出順序和成員聲明順序不同;想保持聲明順序就換 nlohmann::ordered_json(配置系統正是這麼做的)。
3. 反序列化
ll::Expected<PlayerData> fromJson(nlohmann::json const& json) {
return ll::reflection::deserialize_to<PlayerData>(json);
}deserialize_to<T> 會讀出一個新的 T;如果想寫進已經存在的對象(例如調用方持有的配置對象),改用 deserialize(out, json)。
默認規則是「成員名 → 字段名」,逐成員遞歸處理;每種類型對應什麼表示見上一節的對照表。注意它是嚴格的:輸入裡少一個非 optional 成員就會報缺失字段錯誤,多出來的鍵會被靜默忽略。
核心 API
序列化
template <typename J, typename T, IsKeyFormatter F>
Expected<J> serialize(T&& value, F const& keyFormatter) noexcept;
template <typename J, typename T>
Expected<J> serialize(T&& value) noexcept;
template <class J, class T, IsKeyFormatter F>
Expected<> serialize_to(J& out, T&& value, F const& keyFormatter) noexcept;其中 J 是目標類型,例如 nlohmann::json、CompoundTagVariant,或你自己通過 Serializer<T> 支持的類型。
反序列化
template <class T, class J, IsKeyFormatter F>
Expected<> deserialize(T& out, J&& input, F const& keyFormatter) noexcept;
template <class T, class J>
Expected<> deserialize(T& out, J&& input) noexcept;
template <class T, class J, IsKeyFormatter F>
Expected<T> deserialize_to(J&& input, F const& keyFormatter) noexcept;所有公開入口都返回 ll::Expected,用法和 LeviLamina 其它 API 一致:
auto json = ll::reflection::serialize<nlohmann::json>(value);
if (!json) {
return ll::forwardError(json.error()); // 错误信息自带字段路径,见「错误模型」
}
use(*json); // 成功时解引用取值同一個操作通常有兩個入口,按"手上有沒有現成的目標對象"來選:
| 你想做的事 | 用哪個入口 |
|---|---|
| 把值轉成目標類型,拿到一個新對象 | serialize<J>(value) / deserialize_to<T>(input) |
| 寫進一個已經存在的目標對象 | serialize_to<J>(out, value) / deserialize(out, input) |
每個入口都有帶 key formatter 和不帶的兩個版本;不帶 formatter 時使用默認 formatter(鍵名原樣)。
給一個類型寫最簡單的特化
有些類型天生不適合"按成員展開":UUID、版本號、資源 ID 之類,它們最自然的形態是一個字符串。做法是給 Serializer<T> 寫特化,一共兩層——第一層讓它在當鍵時能轉成字符串,第二層讓它在對象裡也輸出成字符串。
以強類型 ID 為例:
struct PlayerId {
uint64 value{};
std::string toString() const; // 例如 "0000-0000-0001"
static ll::Expected<PlayerId> tryParse(std::string_view text); // 解析失败时返回错误
};
template <>
struct ll::reflection::Serializer<PlayerId> {
// 第一层:字符串形式,关联容器键与 type_to_string / string_to_type 会用
static std::string to_string(PlayerId const& value) { return value.toString(); }
static ll::Expected<PlayerId> from_string(std::string_view text) { return PlayerId::tryParse(text); }
// 第二层:让它在对象里也输出成字符串
template <typename J, typename F>
static ll::Expected<J> serialize(PlayerId const& value, F const&) {
return J{value.toString()};
}
template <typename J, typename F>
static ll::Expected<PlayerId> deserialize(J const& j, F const&) {
if (!j.is_string()) return ll::reflection::makeDeserStringTypeError();
// 拿到 string_t const&,再自己构造成需要的类型
auto const& text = j.template get_ref<typename std::remove_cvref_t<J>::string_t const&>();
return from_string(std::string_view{text});
}
};寫完之後的差別:
struct PlayerProfile {
PlayerId id{};
std::string name{};
};
// serialize<nlohmann::json>(profile) 得到:
// {"id":"0000-0000-0001","name":"Steve"}
//
// std::map<PlayerId, int> 的键同样是 "0000-0000-0001"
// deserialize_to<PlayerId>(json) 从字符串解析回对象幾點提示:
- 兩層要一起寫:只寫
to_string/from_string時,類型當鍵沒問題,但作為普通成員仍然會按成員展開(聚合體)或直接報錯(非聚合體)。 - 簽名要對齊、返回值不要寫
void,否則特化會被靜默忽略——完整清單見進階篇。 - LeviLamina 內部對
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 用來控制成員名、枚舉名、鍵名在序列化 / 反序列化時如何轉換,例如全部轉成大寫:
#include <algorithm>
#include <cctype>
std::string uppercaseAsciiKey(std::string_view key) {
std::string result{key};
std::ranges::transform(result, result.begin(), [](unsigned char ch) {
return static_cast<char>(std::toupper(ch));
});
return result;
}
// 使用:
auto json = ll::reflection::serialize<nlohmann::json>(value, uppercaseAsciiKey);
auto obj = ll::reflection::deserialize_to<MyType>(json, uppercaseAsciiKey);formatter 還可以只針對某種鍵類型定製(例如讓 UUID 鍵大寫、但解析規則不變),這類用法見進階篇。
錯誤模型
所有公開接口都返回 ll::Expected,失敗時給出帶路徑的可讀信息,例如:
Reflection error in field 'values[0].number':
field must be a number or boolean常見錯誤:
| 錯誤信息(片段) | 含義 |
|---|---|
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寫進無符號類型都會報錯。
下一步
需要以下內容時看Reflection(反射 · 進階):
- 完整簽名表、返回值約束,以及"特化沒生效"的自查清單;
- 成員輔助函數(
member/field/required_field/default_field:條件必選、缺省值); - 從測試裡摘出來的複雜示例:判別字段結構、扁平化輸出、不可默認構造的類型、判別式變體、ABI 包裝、JSON 與 NBT 雙形態;
- 把別的數據結構(腳本引擎對象、
Json::Value、自己的Value類型)接到反射上:自定義目標類型; - 調度優先級、
std::variant的匹配規則、Dispatcher、std::string_view生命週期、編譯期報錯對照表。
相關文檔
- Reflection(反射 · 進階)
- Config:配置系統,反射最主要的消費者
- Base:反射依賴的基礎類型工具(概念、容器、
Expected)