跳到正文
本頁目錄

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() 工廠的類型,都能用同一套代碼處理。

這一頁怎麼讀:

  1. 想把普通結構體存成 JSON —— 看「快速上手」;
  2. 想讓某個類型以字符串形式出現(比如版本號、UUID)—— 看「給一個類型寫最簡單的特化」;
  3. 需要控制必選規則、寫複雜特化、或者"特化明明寫了卻沒生效" —— 看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.hDispatcher 支持
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++
#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. 序列化

C++
ll::Expected<nlohmann::json> toJson() {
    PlayerData const player{
        .name   = "Steve",
        .level  = 10,
        .health = 20.0,
    };
    return ll::reflection::serialize<nlohmann::json>(player);
}

得到的就是按成員名展開的對象:

JSON
{
    "health": 20.0,
    "level": 10,
    "name": "Steve"
}

nlohmann::json 默認按鍵名排序,所以輸出順序和成員聲明順序不同;想保持聲明順序就換 nlohmann::ordered_json(配置系統正是這麼做的)。

3. 反序列化

C++
ll::Expected<PlayerData> fromJson(nlohmann::json const& json) {
    return ll::reflection::deserialize_to<PlayerData>(json);
}

deserialize_to<T> 會讀出一個新的 T;如果想寫進已經存在的對象(例如調用方持有的配置對象),改用 deserialize(out, json)。

默認規則是「成員名 → 字段名」,逐成員遞歸處理;每種類型對應什麼表示見上一節的對照表。注意它是嚴格的:輸入裡少一個非 optional 成員就會報缺失字段錯誤,多出來的鍵會被靜默忽略。

核心 API

序列化

C++
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> 支持的類型。

反序列化

C++
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 一致:

C++
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 為例:

C++
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});
    }
};

寫完之後的差別:

C++
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 用來控制成員名、枚舉名、鍵名在序列化 / 反序列化時如何轉換,例如全部轉成大寫:

C++
#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,失敗時給出帶路徑的可讀信息,例如:

Text Only
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 variantvariant 沒有任何分支能匹配

編譯期報錯主要是兩條,都會帶上具體類型名:

  • 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 生命週期、編譯期報錯對照表。

相關文檔