跳到正文
本頁目錄

Reflection(反射 · 進階)

ll/api/reflection/ · 通用 · 進階

這篇文檔給誰看

如果你只是想把普通結構體存成 JSON、或者給一個類型寫個"字符串形式"的特化,先看 Reflection(反射)。這一篇面向需要:

  • 精確控制字段名、必選規則、輸出形態的自定義特化;
  • 弄清框架到底按什麼規則挑特化、為什麼"明明寫了卻沒生效";
  • 邊寫邊查:完整簽名表、返回值約束、編譯期報錯對照表。

文中所有複雜示例都來自測試 src-test/server/ReflectionTest.cpp,每個示例後面都標了對應的 gtest 用例名,可以直接搜來跑。

調度模型與術語

優先級標籤

框架不靠嵌套 if constexpr 排查,而是用「標籤繼承 + 重載決議」做編譯期優先級派發:所有候選函數都叫 serialize_impl / deserialize_impl,區別只在最後一個參數 meta::PriorityTag<N>(PriorityTag<N> 繼承自 PriorityTag<N-1>,編號越大越"特化")。傳入 PriorityTag<11> 時,重載決議會挑當前類型能滿足的最高優先級分支。

優先級命中條件(以反序列化方向為例)
11算術類型
10有 Serializer<T> 自定義特化(值式或原地式)
9std::optional、Dispatcher
8VectorBase(向量類型)
7枚舉
6std::variant
5字符串類型、std::string_view
4tuple-like(std::pair / std::tuple / std::array)
3array-like(std::vector / std::set …)
2associative(std::map …)
1可反射聚合體
0兜底:能轉換就轉換,否則給出 cannot deserialize this type 斷言

這一切都是編譯期決定的,和運行時值無關;同一處換個類型,命中的分支就可能完全不同。

探測 trait

框架用一組變量模板判斷"某種形態的特化到底存不存在",它們都在 ll::reflection::detail:

trait判斷的內容
has_value_serializer_v<T, J, F>Serializer<T>(或 Serializer<T, J>)有沒有值式 serialize
has_inplace_serializer_v<T, J, F>有沒有原地式 serialize(T const&, J&[, F const&]),返回值必須是 ll::Expected<>
has_custom_serializer_v上面兩者之一
has_value_deserializer_v<T, J, F>有沒有值式 deserialize
has_inplace_deserializer_v<T, J, F>有沒有原地式 deserialize(T&, J const&[, F const&])
has_custom_key_serializer_v<T, F>有沒有 Serializer<T>::to_string(t[, f])
has_custom_key_deserializer_v<T, F>有沒有 Serializer<T>::from_string(sv[, f])
has_typed_key_formatter_v<T, F>formatter 有沒有 operator()<T>(value) 這種按類型定製的重載
is_key_stringifiable_v<T>能不能當關聯容器的鍵(字符串 / 枚舉 / 有 to_string + from_string)

一個容易忽略的細節:探測值式特化時用的是不帶返回類型約束的複合要求 { expr };——這樣你就能直接返回 J 而不必包 Expected<J>,但副作用是 void 也算"滿足"。所以返回 void 的值式特化會被選中,然後在框架內部編譯失敗。void 從來不是合法返回值。

三種特化形態

形態位置語義
字符串轉換to_string / from_string隻影響關聯容器鍵與 type_to_string / string_to_type,不改變對象序列化
值式serialize / deserialize 返回值整個類型當成一個值
原地式serialize / deserialize 收 J& / T&逐字段填充,可複用 field / member 系列

命中順序

  1. 當前值類型 + 當前目標類型能命中的自定義特化(優先級 10);
  2. 內建邏輯:optional / dispatcher / enum / variant / 容器(優先級 9 ~ 2);
  3. 默認聚合反射(優先級 1);
  4. 兜底轉換(優先級 0)。

兩個方向的內部次序略有不同,同時實現兩種形態時會各自命中:

  • 序列化:優先值式特化,沒有值式才看原地式;
  • 反序列化:優先原地式特化,沒有才走值式 / 內建邏輯。

特化簽名與返回值

框架按簽名識別特化,簽名不匹配等於特化不存在。各形態允許的寫法:

形態簽名允許的返回類型
字符串轉換static std::string to_string(T const&) / static ll::Expected<T> from_string(std::string_view)std::string(或可轉 std::string_view);ll::Expected<T>
值式(帶 formatter)template <typename J, typename F> static ll::Expected<J> serialize(T const&, F const&)J、可隱式轉成 J 的類型、ll::Expected<J>
值式(不帶 formatter)template <typename J> static ll::Expected<J> serialize(T const&)同上,但框架不會把 key formatter 傳進來
原地式template <typename J, typename F> static ll::Expected<> serialize(T const&, J& out, F const&)必須是 ll::Expected<>

deserialize 與之對稱:值式收輸入、返回 T 或 ll::Expected<T>;原地式收 T&、返回 ll::Expected<>。

簽名自查清單

  1. 和上表逐字符對齊:多一個 const、少一個 typename F、參數順序寫反,都會被靜默忽略。
  2. 聚合體下沒有報錯:特化被忽略時會直接走默認聚合反射——編譯通過,行為卻不是你以為的。
  3. 非聚合體才會報錯:這時才會落到 cannot serialize this type / cannot deserialize this type。
  4. 不要返回 void:探測會通過,實現會編譯失敗或被忽略。
  5. 需要"只對某種目標生效"時用 Serializer<T, J>(見下文 NBT / JSON 雙形態示例)。

鍵的字符串化

關聯容器、枚舉、以及顯式調用都會用到這一層:

C++
template <typename T, typename F>
std::string type_to_string(T const& value, F const& keyFormatter);

template <typename T, typename F>
std::optional<T> string_to_type(std::string_view text, F const& keyFormatter);

對應的概念是 IsLeastStringifiableType:字符串類、枚舉、以及提供 to_string + from_string 的類型。

按類型定製的 key formatter

formatter 除了處理鍵名(operator()(std::string_view)),還可以提供按類型定製的重載 operator()<T>(value);它隻影響寫出,不影響讀入:

C++
struct UuidKeyFormatter {
    std::string operator()(std::string_view key) const { return std::string{key}; }

    template <typename T>
        requires std::same_as<T, mce::UUID>
    std::string operator()(T const& value) const {
        auto text = value.asString();
        std::ranges::transform(text, text.begin(), [](unsigned char ch) {
            return static_cast<char>(std::toupper(ch));
        });
        return text;
    }
};

這樣 std::map<mce::UUID, int> 會寫出大寫 UUID 鍵,而讀入仍按普通 UUID 字符串解析。測試:AssociativeKeysCanCustomizeSerializationStyleWithoutAffectingParsing。

formatter 的傳播邊界

formatter 會傳給成員鍵名和關聯鍵,但不會漏進值式特化內部:值式特化不帶 formatter 形參時,框架不會把 formatter 傳進去,特化裡的 to_string 也就不受外部風格影響。測試:MemberHelperDoesNotPropagateOuterKeyFormatterIntoValueSerializer。

成員與字段輔助函數

這類 API 是給 Serializer<T> 特化內部用的簡寫。member / field 的方向由 json 實參的 const 性決定——可變左值 = 寫出,const 左值 / 右值 = 讀入;在特化之外請直接調用 serialize / deserialize,或使用方向明確的名字。

語義成員指針版固定鍵名版
缺字段時重置為 nullopt(成員是 optional)member<&T::m>(t, j, f)field<"m">(t, j, f)
字段必須存在,即使成員是 optionalrequired_member<&T::m>(t, j, f)required_field<"m">(t, j, f)
缺字段時保留目標對象當前值default_member<&T::m>(t, j, f)default_field<"m">(t, j, f)
缺字段時寫入提供的默認值default_member<&T::m>(t, j, d, f)default_field<"m">(t, j, d, f)
序列化單個成員 / 字段member_serialize<&T::m>(j, t, f)field_serialize<"m">(j, t, f)
反序列化單個成員 / 字段member_deserialize<&T::m>(t, j, f)field_deserialize<"m">(t, j, f)

補充語義:

  • 寫出方向:成員是空的 std::optional<T> 時不會寫出這個鍵,而不是寫 null;
  • 參數順序:序列化是"先 json 後值",反序列化是"先值後 json",容易寫反;
  • 成員指針版自動取反射成員名,字段名與成員名不一致時用固定鍵名版。

複雜示例(摘自測試)

1. 判別字段決定必選字段

ItemInfo 用 type 字段決定 name / snbt 誰必選。只有原地式才能"先讀判別字段、再決定後續字段":

C++
template <>
struct ll::reflection::Serializer<ItemInfo> {
    template <typename J, typename F>
    static ll::Expected<> deserialize(ItemInfo& out, J const& j, F const& keyFormatter) {
        using ll::reflection::field;
        using ll::reflection::required_field;

        if (!j.is_object()) return ll::reflection::makeDeserObjectTypeError();
        if (auto res = field<"type">(out.type, j, keyFormatter); !res) return ll::forwardError(res.error());

        using enum ItemInfoType;
        switch (out.type) {
        case Name:
            if (auto res = required_field<"name">(out.name, j, keyFormatter); !res) return ll::forwardError(res.error());
            if (auto res = field<"aux">(out.aux, j, keyFormatter); !res) return ll::forwardError(res.error());
            out.snbt.reset();
            return {};

        case Snbt:
            if (auto res = required_field<"snbt">(out.snbt, j, keyFormatter); !res) return ll::forwardError(res.error());
            out.name.reset();
            out.aux.reset();
            return {};
        }
        return {};
    }
};

測試:ItemInfoUsesTypeFieldToSelectRequiredFields、ItemInfoReportsMissingFieldsRequiredByType。

2. 把多個子對象壓平成同一個對象

類型繼承 std::tuple 並特化 std::tuple_size / std::tuple_element / std::get,就能走 tuple-like 分支;再寫值式序列化器把各段的鍵合併進同一個對象:

C++
template <typename... Types>
struct FlatReflection : std::tuple<Types...> {
    using Base = std::tuple<Types...>;
    using Base::Base;
    constexpr FlatReflection() = default;
    constexpr FlatReflection(Types... values) : Base(std::move(values)...) {}
};

template <typename... Types>
struct ll::reflection::Serializer<FlatReflection<Types...>> {
    template <typename J, typename F, size_t... I>
    static ll::Expected<J> serializeElements(FlatReflection<Types...> const& input, F const& keyFormatter,
                                             std::index_sequence<I...>) {
        ll::Expected<J> output{J::object()};
        auto            append = [&]<size_t Index>() {
            if (!output) return;
            auto value = ll::reflection::serialize<J>(std::get<Index>(input), keyFormatter);
            if (!value) {
                output = ll::forwardError(value.error());
                return;
            }
            for (auto const& [key, child] : value->items()) (*output)[key] = child;
        };
        (append.template operator()<I>(), ...);
        return output;
    }
};

測試:FlatReflectionMergesObjectSerializersAndRoundTrips。

3. 不可默認構造的類型

只能寫值式:deserialize 返回 T,由特化自己決定怎麼構造。放進容器、std::optional、std::variant 裡同樣生效(框架內部走 deserialize_construct):

C++
template <>
struct ll::reflection::Serializer<NonDefaultConstructibleItem> {
    template <typename J, typename F>
    static ll::Expected<NonDefaultConstructibleItem> deserialize(J const& j, F const& keyFormatter) {
        using ll::reflection::required_field;
        if (!j.is_object()) return ll::reflection::makeDeserObjectTypeError();

        int         id = 0;
        std::string name;
        if (auto res = required_field<"id">(id, j, keyFormatter); !res) return ll::forwardError(res.error());
        if (auto res = required_field<"name">(name, j, keyFormatter); !res) return ll::forwardError(res.error());
        return NonDefaultConstructibleItem{id, std::move(name)};
    }
};

測試:DeserializeToSupportsNonDefaultConstructibleTypesWithValueDeserializer、ContainersSupportNonDefaultConstructibleValuesWithValueDeserializer。

4. 帶判別字段的變體

普通 std::variant 只能按值形狀猜分支。要顯式 discriminator,就包一層類型並給它寫特化:

C++
template <typename Enum, typename Variant>
struct VariantWithEnumIndex : Variant {
    static constexpr std::string_view typeKey = "type";
};

template <typename Enum, typename Variant>
struct ll::reflection::Serializer<VariantWithEnumIndex<Enum, Variant>> {
    // serialize:   写 {"type": <枚举名>, "value": <当前分支>}
    // deserialize: 先读 type,再按枚举索引派发到对应分支
};

測試:VariantWithEnumIndexSerializesAndDeserializesUsingEnumDiscriminator。

5. ABI 包裝類型

ll::TypedStorage<Align, Size, T> 這類包裝不會被默認反射看穿,需要顯式放行:

C++
template <size_t Align, size_t Size, typename T>
struct ll::reflection::Serializer<ll::TypedStorageImpl<Align, Size, T>> {
    using storage_type = ll::TypedStorageImpl<Align, Size, T>;

    template <typename J, typename F>
    static ll::Expected<J> serialize(storage_type const& value, F const& keyFormatter) {
        return ll::reflection::serialize<J>(value.get(), keyFormatter);
    }

    template <typename J, typename F>
    static ll::Expected<storage_type> deserialize(J&& j, F const& keyFormatter) {
        auto value = ll::reflection::deserialize_to<T>(std::forward<J>(j), keyFormatter);
        if (!value) return ll::forwardError(value.error());
        return storage_type{std::move(*value)};
    }
};

測試:SerializedSkinImplSpecializationSupportsTypedStorageFields。

6. 同一類型對 JSON / NBT 輸出不同形態

對 J 做 if constexpr 判斷即可。測試裡給 CompoundTagVariant 寫特化:JSON 目標輸出 SNBT 字符串,NBT 目標原樣返回節點。

C++
template <>
struct ll::reflection::Serializer<CompoundTagVariant> {
    template <typename J, typename F>
    static ll::Expected<J> serialize(CompoundTagVariant const& value, F const&) {
        if constexpr (std::same_as<std::remove_cvref_t<J>, CompoundTagVariant>) {
            return value;
        } else {
            return value.toSnbt(SnbtFormat::Minimize, 0);
        }
    }
};

測試:NbtValueSpecializationCanSerializeDifferentlyForJsonAndNbt。

7. 只反序列化、不序列化(向後兼容)

繼承包一層,讓序列化輸出空對象、反序列化照常工作:

C++
template <typename T>
struct OnlyDeserialize : T {
    using Base = T;
    using Base::Base;
    OnlyDeserialize() = default;
    explicit OnlyDeserialize(T value) : T(std::move(value)) {}
};

測試:CustomDeserializerCanHideATypeDuringSerialization。

8. 其它值得一看的用例

場景測試名
同一類型同時支持字符串形式與值式ValueSerializerAndStringSerializerCanCoexistOnSameType
路徑 / 時長 / ordered_json 的值式特化OrderedJsonAndFilesystemPathUseValueSpecializations、DurationSpecializationSerializesTheUnderlyingCount
string_view 只接受可借用的左值源StringViewDeserializationRequiresBorrowableLvalueSource
普通 variant 的精確匹配與失敗路徑PlainVariantPrefersExactArithmeticAlternativeMatches、PlainVariantReportsCastFailureWhenNoAlternativeMatches
數值越界 / 非整數的報錯NumericDeserializationRejectsOutOfRangeAndFractionalValues
原地特化被所有入口使用InPlaceDeserializerIsUsedByDeserializeAndDeserializeTo

自定義目標類型(把新的數據結構接到反射上)

框架對目標類型 J 是完全泛型的:沒有基類、沒有 trait、也沒有適配接口,唯一契約就是——你調 serialize<J> / deserialize_to<T>(J) 時,J 恰好有那些成員函數。所以"支持一種新的數據結構"(腳本引擎對象、Json::Value、自己的 Value 類型)就等於讓那個類型滿足下面這份契約。

契約

分類需要的成員什麼時候需要
構造static J object()、static J array()只要序列化對象 / 數組
構造可由 nullptr_t 構造序列化空 std::optional(框架會 return t ? … : nullptr;)
構造標量 / 字符串可直接構造(見下文"兩步轉換")序列化算術類型、字符串、枚舉
構造可默認構造、可賦值只在原地式特化分支和 serialize_to 用
查詢is_object / is_array / is_string / is_null / is_boolean / is_number / is_number_float / is_number_integer / is_number_unsigned、type_name()、size()按實際用到的分支裁剪即可
對象contains(key)、operator[](key)、items()(能 for (auto&& [k, v] : …))讀 / 寫對象
數組operator[](index)、push_back(value)讀 / 寫數組與 tuple
標量能 static_cast 成算術類型、能隱式轉成 std::string 與 std::string_view讀標量;後者是 LeviLamina 內置的字符串型特化(mce::UUID、ll::data::Version 等)會用的寫法
variant 專用get<bool>() / get<double>() / get<int64>() / get<uint64>()、dump()只有當類型裡有 std::variant
string_view 專用成員別名 using string_t = ...; + template <class T> T const& get_ref() const(即 BorrowableStringSource)只有當類型裡有 std::string_view;見下文"借用字符串"

三條硬約束

  1. operator[] 必須交出"同一個 J"。框架對普通 J 直接 return static_cast<J&&>(j[key]);(見 object_child_at / array_child_at),只有 CompoundTagVariant / UniqueTagPtr 有專門分支把子節點包一層。所以"返回裸句柄"的類型(JSValue、lua_State* 之類)不能直接當 J,要包成"子節點也是自己"的節點類型。
  2. 左值來源要求引用,右值來源走拷貝。同一個函數里,J 是左值引用時要求 j[key] 給出真實引用;是右值時會 std::remove_cvref_t<J>(j[key]) 拷貝一份。所以 operator[] 返回臨時對象的適配類型,只能以右值形式傳入(deserialize_to<T>(makeNode())),否則要麼編譯不過、要麼懸垂。
  3. 寫入口的名字是固定的:寫對象是 (*res)[key] = value,寫數組是 res->push_back(value),因此 operator[] 要返回可賦值引用,並且要有 push_back。

三個實測會踩到的坑

  • 不要只提供"從中間類型構造"的構造函數。框架會寫 return t; 這種隱式轉換(例如序列化一個 int 得到 Expected<J>),而 int → Json::Value → J 屬於兩步用戶定義轉換,在隱式語境裡不成立。要給標量和字符串提供直達構造函數(is_arithmetic_v<T> 的模板 + std::string + char const*)。
  • 算術轉換運算符會和內置下標運算符打架。適配類型為了讀數值通常帶一個 template <class T> operator T(),於是 node["a"] 會被解釋成"把 node 轉成 __ptrdiff_t 再用內置 operator[](__ptrdiff_t, const char*)",和你的 operator[](std::string_view) 形成歧義。補一個 operator[](char const* key) 讓字符串字面量精確匹配即可消歧義。
  • 別給字符類型提供轉換。std::string{j} 是花括號初始化:只要適配類型存在到 char 的隱式轉換,就會走 initializer_list<char> 那條構造函數,把 static_cast<char>(j) 變成一個 1 字節的字符串(讀出來就是 \0,字符串成員會靜默變成垃圾,枚舉成員才會報 invalid enum value)。轉換運算符的約束要寫成 std::is_arithmetic_v<T> && !ll::traits::is_char_v<T>——CompoundTagVariant 一直就是這麼寫的。

借用字符串(string_t + get_ref)

目標類型裡有 std::string_view 成員時,框架需要兩樣東西:成員別名 string_t,以及

C++
// 返回指向自己存储里那个字符串的引用;引用有效期由实现者负责。
// 框架只会用 T = string_t const& 这一个类型调用它。
template <class T>
[[nodiscard]] T const& get_ref() const;

框架拿到 string_t const& 之後自己構造成需要的類型(這裡是 std::string_view)。CompoundTagVariant(string_t = std::string)和 nlohmann::json 都滿足。

底層不是 std::string 的適配類型也能滿足它:Json::Value 只能按值交出字符串(asString()),於是 JsonValueNode 用 using string_t = std::string;,把這份文本緩存進一個 mutable 成員,再由 get_ref 返回它的引用。

兩點語義要記住:

  • 借出的引用 / 視圖指向源自己擁有的那份字符串(CompoundTagVariant、nlohmann::json 是它們的內部存儲;Json::Value 適配類型是節點緩存的副本),所以那份字符串必須活得比視圖久;
  • 框架只在來源是左值時借用。傳臨時節點(右值)依然會報 field cannot be safely deserialized into string_view from a temporary or non-borrowing source——命名一個 node 再傳進去就能用。

get_ref 返回的引用必須指向存儲,不能返回指向臨時對象的引用:T const& 的返回類型下,return somePointerValue; 這種寫法綁的是棧上的臨時對象,函數一返回就懸空。

完整實現與測試

src-test/server/ReflectionTest.cpp 裡的 JsonValueNode 就是照這份契約給 Bedrock 的 Json::Value 寫的適配類型。它同時是個可寫視圖:拷貝節點是淺拷貝(共享同一份 Json::Value),給節點賦值會寫穿到底層 Json::Value,元素訪問返回子節點緩存裡的引用——所以框架的左值路徑也能走通。配套測試:

測試覆蓋
JsonValueNodeSerializesAggregatesIntoJsonValue聚合體 → Json::Value:算術 / 字符串 / 布爾 / 空 optional 省略鍵 / 枚舉 / vector / map / 嵌套結構 / variant / 自定義 Serializer<T>,並做往返比較
JsonValueNodeDeserializesFromAConstJsonValue從只讀 Json::Value 反序列化(含缺 optional 鍵 → nullopt)
JsonValueNodeReportsPathsRangesAndUnsupportedTargets嵌套路徑錯誤、缺必選字段、帶小數 / 越界 / 負數進無符號、布爾轉數字、variant 無法匹配(錯誤裡帶 dump())、string_view 的右值來源報生命週期錯誤
JsonValueNodeBorrowsStringViewsFromAnLvalueSource左值來源下 std::string_view 成員成功借用,並斷言同一個子節點重複借出拿到的是同一份緩存文本
JsonValueNodeSupportsTheBuiltInStringifiedTypesmce::UUID 與 ll::data::Version 經適配類型往返(它們的特化用 std::string_view{j})
JsonValueNodeFeedsEveryScalarAccessorOfAVariantvariant 的四個標量訪問器(bool / int64 / uint64 / double)都經適配類型取值
PlainVariantDeserializesFromNbtstd::variant 從 NBT 反序列化
JsonValueNodeWritesThroughAndSharesItsValue寫穿語義、淺拷貝共享、push_back、空 optional 走的 nullptr 構造

自己實現時有三個細節值得照抄:子節點緩存要用指針間接(std::map<Json::Value*, std::unique_ptr<Node>>)而不是直接放 std::deque<Node>,否則類自引用會因為類型不完整而編譯失敗,並且要按槽位地址去重(Json::Value 的子節點是堆分配的,地址穩定),不然每訪問一次就多存一份視圖;拷貝構造顯式寫成淺拷貝、賦值運算符顯式寫成寫穿(兩者語義刻意不同)。

什麼時候別這麼做

  • 只想在兩個 JSON 表示之間轉一次(例如 Bedrock 的 Json::Value ↔ nlohmann::json):寫值式互轉特化更省事,測試裡的 Serializer<Json::Value, nlohmann::json> 就是現成例子。
  • 數據源是腳本引擎對象:JSON.stringify / JSON.parse 配 serialize<nlohmann::json> / deserialize_to<T> 通常比包一層適配便宜得多。
  • 只想讓某個 C++ 類型按特定形狀寫進某個目標:那是 Serializer<T, J> 的活(它只改變 T 自己怎麼變成 J,成員仍然走內建分支,所以底層協議依舊要滿足)。

變體的匹配規則

std::variant 的反序列化分兩遍:

  1. 精確匹配:布爾只接受布爾,浮點只接受浮點,有符號只接受有符號整數,無符號只接受無符號整數,且值必須能放進目標類型;多個候選都精確匹配時取聲明順序靠前的。
  2. 回退匹配:按聲明順序逐個嘗試各分支的類型,此時算術分支走嚴格的數值轉換(越界、帶小數同樣報錯)。

兩遍都失敗時,錯誤裡會帶上輸入類型名和文本,例如 could not cast target object ({"unexpected":true}) to any type in the variant。需要顯式判別字段請按示例 4 自己寫特化。

std::string_view 生命週期

反序列化到 std::string_view 只在輸入可安全借用時成立:

  • 從穩定的左值 JSON / NBT 對象讀取,視圖指向其內部存儲;
  • 從臨時對象或非借用型後端讀取,返回 field cannot be safely deserialized into string_view from a temporary or non-borrowing source。

這是為了避免懸垂視圖;需要長期持有就改用 std::string。

Dispatcher<T, Listener>

Dispatcher 把一份數據和它的監聽者綁在一起。反射對它的約定是:序列化只寫 storage,反序列化讀回 storage 之後再調用一次 call()。

C++
struct LevelNotifier {
    void call(int const& storage) { /* 更新界面、重算缓存、发事件…… */ }
};

using LevelDispatcher = ll::reflection::Dispatcher<int, LevelNotifier>;

struct GameOptions {
    LevelDispatcher level{0};   // 构造时不会调用 call()
    std::string     name{};
};

要點:

  • Listener 必須可默認構造、可移動,並提供 call(Storage const&);
  • 只有通過 Dispatcher 賦值(disp = value)或反序列化才會調用 call(),直接改 disp.storage 不會觸發;
  • 第三個模板參數 CallInit(默認 false)為 true 時,構造也會調用一次;
  • 可隱式轉換回 Storage,能直接當底層值使用;
  • 監聽者可能持有狀態,因此只可移動、不可拷貝,移動時連同監聽者一起搬走。

測試:SerializeAndDeserializeRespectKeyFormatterAndDispatcher。

診斷與自查

編譯期報錯對照表

報錯信息(片段)含義怎麼辦
cannot serialize this type: ...沒有任何可用的序列化路徑寫 Serializer<T> 特化;或把類型改成聚合體、改用受支持的成員類型
cannot deserialize this type: ...同上,反序列化方向同上
an associative container key has to be a string, ...關聯容器鍵類型不能轉成字符串給鍵類型補 to_string / from_string,或用按類型定製的 formatter
the key type has to be a string, ...type_to_string / string_to_type 拿到的類型不支持同上
Boost.PFR: ... Please report this issue ...Boost.PFR 數不清該聚合體的字段(帶基類、成員的轉換構造函數被刪除等)給該類型寫 Serializer<T>,不要指望默認反射

定位手法

  1. 先確認是不是"被靜默忽略":臨時在特化里加一句 static_assert(ll::traits::always_false<std::remove_cvref_t<T>>, "");,如果編譯仍然通過,說明框架根本沒選中它。
  2. 縮小到單個成員:直接用 deserialize_to<MemberType>(json) / serialize<J>(member) 單獨驗證,比在大結構體裡試錯快得多。
  3. 看報的是哪一層:報枚舉值非法、缺字段之類,說明命中的是內建分支而不是你的特化;報 cannot serialize 說明所有分支都沒命中。
  4. 聚合體卻報 PFR 斷言:那是 PFR 自己的字段計數失敗,只能顯式寫特化繞開。

實現註記

  • 數值轉換是嚴格的:越界、帶小數、負值寫入無符號類型都會報錯,JSON 與 NBT 行為一致;範圍檢查在 ll::reflection 內部實現(MSVC STL 的 std::in_range 不接受 char 系列類型)。
  • 失敗原子性:值式路徑先在臨時對象裡構造、成功後才寫回目標,失敗時目標保持不變;原地式路徑逐個字段寫入,中途失敗會留下"半寫"狀態。需要強一致就選值式。
  • 變體錯誤的構造是懶的:只有所有分支都失敗時才生成帶完整 dump 的錯誤,命中分支不必為此序列化整棵子樹。
  • 入口一致性:deserialize_to 與 deserialize 都會優先使用原地式特化(deserialize_to 會先把目標默認構造出來),所以"只寫原地式"的類型必須可默認構造;不可默認構造就寫值式。

相關文檔

  • Reflection(反射):第一次使用的用法與最簡單的特化
  • Config:配置系統,反射最主要的消費者
  • Base:反射依賴的基礎類型工具(概念、容器、Expected)