現代 C++ 編譯時 結構體字段反射


基於 C++ 14 原生語法,不到 100 行代碼:讓編譯器幫你寫 JSON 序列化/反序列化代碼,告別體力勞動。🙃

本文不討論完整的 C++ 反射技術,只討論結構體 (struct) 的字段 (field) 反射,及其在序列化/反序列化代碼生成上的應用。

正文開始於 [sec|靜態反射] 部分,其他部分都是鋪墊。。可以略讀。。。

背景(TL;DR)

很多人喜歡把程序員稱為 碼農,程序員也經常嘲諷自己每天都在 搬磚。這時候,大家會想:能否構造出一些 更好的工具,代替我們做那些無意義的 體力勞動 呢?

在實際 C++ 項目中,我們經常需要實現一些與外部系統交互的 接口 —— 外部系統傳入 JSON 參數,我們的程序處理后,再以 JSON 的格式傳回外部系統。這個過程就涉及到了兩次數據結構的轉換:

  • 輸入的 JSON 轉換為 C++ 數據結構(反序列化 deserialization
  • C++ 數據結構 轉換為 輸出的 JSON(序列化 serialization

如果傳輸的 JSON 數據 格式 (schema) 非常繁多、比較復雜,那么序列化/反序列化的代碼也會變得非常復雜 —— 需要處理 結構嵌套可選字段輸入合法性檢查 等問題。如果為每個 JSON 數據結構都 人工手寫 一套序列化/反序列化代碼,那么 工作量 會特別大。

例如,chromium/headless 的 devtools 相關接口 里就定義了 33 個 領域模型 (domain model),每個模型有自己的格式,其中又包含了許多字段。

懶惰是程序員的天性:

  • “勤奮” 的程序員選擇 [sec|人工手寫 序列化/反序列化 代碼]
  • “懶惰” 的程序員選擇
    • 構建代碼生成器(例如 protobufchromium/mojo
    • 或 [sec|編譯器生成 序列化/反序列化 代碼]

代碼生成器雖然功能強大,但依賴復雜,不易於和已有系統集成。所以本文主要討論如何用 C++ 14 提供的 元編程 (metaprogramming) 技巧,讓編譯器幫你寫代碼。

目標(TL;DR)

  • 基於 C++ 原生語法,不需要引入第三方庫
  • 提供 聲明式 (declarative) 的方法,只需要聲明格式,不需要寫邏輯語句
  • 不會帶來 額外的運行時開銷,能達到和手寫代碼一樣的運行時效率

基於 nlohmann 的 C++ JSON 庫,給定兩個 C++ 結構體 SimpleStructNestedStruct

struct SimpleStruct {
  bool bool_;
  int int_;
  double double_;
  std::string string_;
  std::unique_ptr<bool> optional_;
};

struct NestedStruct {
  SimpleStruct nested_;
  std::vector<SimpleStruct> vector_;
};

一般接口的業務處理,往往包括三部分:

  • 解析輸入(字符串到 JSON 對象的轉換 + JSON 對象到領域模型的 反序列化
  • 處理業務邏輯(實際需要我們寫的代碼)
  • 轉儲輸出(領域模型到 JSON 對象的 序列化 + JSON 對象到字符串的轉換)
// input
json json_input = json::parse(
    "{"
    "  \"_nested\": {"
    "    \"_bool\": false,"
    "    \"_int\": 0,"
    "    \"_double\": 0,"
    "    \"_string\": \"foo\""
    "  },"
    "  \"_vector\": [{"
    "    \"_bool\": true,"
    "    \"_int\": 1,"
    "    \"_double\": 1,"
    "    \"_string\": \"bar\","
    "    \"_optional\": true"
    "  },{"
    "    \"_bool\": true,"
    "    \"_int\": 2,"
    "    \"_double\": 2.0,"
    "    \"_string\": \"baz\","
    "    \"_optional\": false"
    "  }]"
    "}");
NestedStruct nested = json_input.get<NestedStruct>();

// use
nested.nested_.string_ += " in nested struct";

// output
json json_output = json(nested);
std::string string_output = json_output.dump(2);
  • 對於 JSON 對象和字符串之間的轉換,主流的 JSON 庫都實現 了:
    • 調用 json::parse 從字符串得到輸入 JSON 對象
    • 調用 json::dump 將 JSON 對象轉為用於輸出的字符串
  • 而 JSON 對象和 C++ 結構體之間的轉換,需要我們實現
    • 通過反序列化,調用 json::get<NestedStruct>() 得到 NestedStruct nested
    • 通過序列化,使用 nested 構造輸出 JSON 對象

實現

實現從 C++ 結構體到 JSON 的序列化/反序列化操作,需要用到以下信息:

  • 結構體有 哪些字段
    • bool_/int_/double_/string_/optional_
    • nested_/vector_
  • 每個 字段結構體中 的什么 位置
    • &SimpleStruct::bool_/&SimpleStruct::int_/&SimpleStruct::double_/&SimpleStruct::string_/&SimpleStruct::optional_
    • &NestedStruct::nested_/&NestedStruct::vector_
  • 每個 字段JSON 中 對應的 名稱 是什么
    • "_bool"/"_int"/"_double"/"_string"/"_optional"
    • "_nested"/"_vector"
  • 每個 字段 如何從 C++ 到 JSON 進行 類型映射
    • bool 對應 Booleanint 對應 Number(Integer)double 對應 Numberstring 對應 Stringvector 對應 ArraySimpleStruct/NestedStruct 對應 Object
    • 必選字段缺失 或 字段類型與 JSON 數據 類型不匹配,則拋出異常
    • 可選字段(例如 optional_)缺失,則跳過檢查

對於很多支持 反射 (reflection) 的語言,JSON 的解析者 可以通過反射接口,查詢到 SimpleStruct/NestedStruct 所有的 字段信息

盡管 C++ 支持 運行時類型信息 (RTTI, run-time type information),但無法得到所有上述信息,所以需要 SimpleStruct 的定義者 把這些信息告訴 JSON 的解析者

人工手寫 序列化/反序列化 代碼

代碼鏈接

實現序列化/反序列化最簡單的方法,就是通過 人工編寫 代碼:

void to_json(nlohmann::json& j, const SimpleStruct& value) {
  j["_bool"] = value.bool_;
  j["_int"] = value.int_;
  j["_double"] = value.double_;
  j["_string"] = value.string_;
  j["_optional"] = value.optional_;
}

void from_json(const nlohmann::json& j, SimpleStruct& value) {
  j.at("_bool").get_to(value.bool_);
  j.at("_int").get_to(value.int_);
  j.at("_double").get_to(value.double_);
  j.at("_string").get_to(value.string_);
  if (j.find("_optional") != j.cend()) {
    j.at("_optional").get_to(value.optional_);
  }
}

void to_json(nlohmann::json& j, const NestedStruct& value) {
  j["_nested"] = value.nested_;
  j["_vector"] = value.vector_;
}

void from_json(const nlohmann::json& j, NestedStruct& value) {
  j.at("_nested").get_to(value.nested_);
  j.at("_vector").get_to(value.vector_);
}
  • to_json/from_json 包含了 所有字段位置、名稱、映射方法
    • 使用 j[name] = field 序列化
    • 使用 j.at(name).get_to(field) 反序列化
    • 針對可選字段檢查字段是否存在,不存在則跳過
  • nlohmann 的 C++ JSON 庫能處理 結構嵌套
    • j = value.nested_ 會調用 void to_json(json& j, const SimpleStruct& value) 序列化 SimpleStruct
    • j.get_to(value.nested_) 會調用 void from_json(const json& j, SimpleStruct& value) 反序列化 SimpleStruct
  • nlohmann 的 C++ JSON 庫基於 C++ 原生的 異常處理throw-try-catch):
    • 如果字段不存在,函數 json::at 拋出異常
    • 如果字段實際類型和 JSON 輸入類型不匹配,函數 json::get_to 拋出異常

手寫 to_json/from_json 需要寫 2 份類似的代碼:

  • 一方面,需要復制粘貼,導致 代碼冗余
  • 另一方面,兩份代碼邏輯不是對稱的(需要特殊處理 可選字段),不易於統一編寫

動態反射

“崇尚偷懶”的 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 ——
代碼鏈接

首先,為不同 字段類型 定義一個通用的轉換接口 ValueConverter<FieldType>,用於存儲實際的 C++ 類型與 JSON 類型的轉換操作(僅關聯操作的字段類型,抹除具體轉換操作的類型):

template <typename FieldType>
using ValueConverter =
    std::function<void(FieldType* field, const std::string& name)>;
  • 參數 field 表示字段的值,name 是字段的名稱
  • 原始代碼將 ValueConverter 定義為接口;本文為了化簡,直接使用 std::function(關於使用接口的討論,參考:回調 vs 接口

然后,為不同類型的 結構體 定義一個通用的轉換接口 FieldConverterBase<StructType>,用於存儲結構體內所有字段的轉換操作(僅關聯結構體的類型,抹除操作的字段類型):

template <typename StructType>
class FieldConverterBase {
 public:
  virtual ~FieldConverterBase() = default;
  virtual void operator()(StructType* obj) const = 0;
};

接着,通過 FieldConverter<StructType, FieldType> 將上邊兩個接口 承接 起來,用於存儲 結構體字段類型 的實際轉換操作(類似於 double dispatch),同時關聯上具體某個字段的位置和名稱(實現 FieldConverterBase 接口,調用 ValueConverter 接口):

template <typename StructType, typename FieldType>
class FieldConverter : public FieldConverterBase<StructType> {
 public:
  FieldConverter(const std::string& name,
                 FieldType StructType::*pointer,
                 ValueConverter<FieldType> converter)
      : field_name_(name),
        field_pointer_(pointer),
        value_converter_(converter) {}

  void operator()(StructType* obj) const override {
    return value_converter_(&(obj->*field_pointer_), field_name_);
  }

 private:
  std::string field_name_;
  FieldType StructType::*field_pointer_;
  ValueConverter<FieldType> value_converter_;
};
  • 構造時傳遞 字段名稱 field_name_,字段的 成員指針 (member pointer)(即字段位置)field_pointer_,字段的映射方法 value_converter_
  • operator() 轉換時,調用 value_converter_.operator(),傳入 當前結構體中字段的值 和 字段的名稱;其中結構體 obj 字段的值通過 obj->*field_pointer_ 得到

最后,針對 結構體 定義一個存儲 所有字段 信息(名稱、位置、映射方法)的容器 StructValueConverter<StructType>,並提供 注冊 字段信息的接口(有哪些字段)RegisterField 和執行所有轉換操作的接口 operator()僅關聯結構體的類型,利用 FieldConverterBase 抹除操作的字段信息):

template <class StructType>
class StructValueConverter {
 public:
  template <typename FieldType>
  void RegisterField(FieldType StructType::*field_pointer,
                     const std::string& field_name,
                     ValueConverter<FieldType> value_converter) {
    fields_.push_back(std::make_unique<FieldConverter<StructType, FieldType>>(
        field_name, field_pointer, std::move(value_converter)));
  }

  void operator()(StructType* obj) const {
    for (const auto& field_converter : fields_) {
      (*field_converter)(obj);
    }
  }

 private:
  std::vector<std::unique_ptr<FieldConverterBase<StructType>>> fields_;
};

使用樣例代碼鏈接

具體使用時,只需要兩步:

  1. 構造 converter 對象,調用 RegisterField 動態綁定字段信息(名稱、位置、映射方法)
  2. 調用 converter(&simple) 對所有注冊了的字段 進行轉換
// setup converter (partial)
auto int_converter = [](int* field, const std::string& name) {
  std::cout << name << ": " << *field << std::endl;
};
auto string_converter = [](std::string* field, const std::string& name) {
  std::cout << name << ": " << *field << std::endl;
};

StructValueConverter<SimpleStruct> converter;
converter.RegisterField(&SimpleStruct::int_, "int",
                        ValueConverter<int>(int_converter));
converter.RegisterField(&SimpleStruct::string_, "string",
                        ValueConverter<std::string>(string_converter));

// use converter
SimpleStruct simple{2, "hello dynamic reflection"};
converter(&simple);

// output:
//   int: 2
//   string: hello dynamic reflection

基於動態反射的開源庫:

靜態反射

實際上,實現序列化/反序列化所需要的信息(有哪些字段,每個字段的位置、名稱、映射方法),在 編譯時 (compile-time) 就已經確定了 —— 沒必要在 運行時 (runtime) 動態構建 converter 對象。所以,我們可以利用 靜態反射 (static reflection) 的方法,把這些信息告訴 編譯器,讓它幫我們 生成代碼

核心原理 是:利用 訪問者模式 (visitor pattern),使用 元組 std::tuple 記錄結構體所有的字段信息,通過 編譯時多態 (compile-time polymorphism) 針對具體的 字段類型 進行轉換操作。

Talk is cheap, show me the code ——
代碼鏈接

首先,定義一個 StructSchema<StructType> 函數模板 (function template),返回所有字段信息(默認返回空元組):

template <typename T>
inline constexpr auto StructSchema() {
  return std::make_tuple();
}

然后,提供 DEFINE_STRUCT_SCHEMADEFINE_STRUCT_FIELD 兩個 (macro) ,定義結構體 字段信息(有哪些、位置、名稱),隱藏 StructSchemastd::tuple 的實現細節:

#define DEFINE_STRUCT_SCHEMA(Struct, ...)        \
  template <>                                    \
  inline constexpr auto StructSchema<Struct>() { \
    using _Struct = Struct;                      \
    return std::make_tuple(__VA_ARGS__);         \
  }

#define DEFINE_STRUCT_FIELD(StructField, StructName) \
  std::make_tuple(&_Struct::StructField, StructName)
  • StructSchema 返回元組的結構是:((&field1, name1), (&field2, name2), ...)
    • DEFINE_STRUCT_SCHEMA 定義了 結構體 Struct 有哪些字段
    • DEFINE_STRUCT_FIELD 定義了每個 字段位置、名稱
  • using _Struct = Struct 提供了一種宏內數據接力的方法,讓下一個宏能獲取上一個宏的數據

最后,提供 ForEachField<StructType> 函數,從對應的 StructSchema<StructType> 取出記錄結構體 StructType 所有字段信息 的元組,然后遍歷這個元組,從中取出 每個字段的位置、名稱,作為參數調用轉換函數 fn

template <typename T, typename Fn>
inline constexpr void ForEachField(T&& value, Fn&& fn) {
  constexpr auto struct_schema = StructSchema<std::decay_t<T>>();
  detail::ForEachTuple(struct_schema, [&value, &fn](auto&& field_schema) {
    fn(value.*(std::get<0>(std::forward<decltype(field_schema)>(field_schema))),
       std::get<1>(std::forward<decltype(field_schema)>(field_schema)));
  });
}
  • fn 接受的參數分別為:字段的值和名稱 (field_value, field_name)
    • 字段的值通過 value.*field_pointer 得到,其中 field_pointer 是成員指針
  • ForEachTuple 的實現中還用到了 靜態斷言 (static assert) 檢查,具體見 代碼
    • 檢查 StructSchema 是否定義了字段信息
    • 檢查每個字段的信息 是否都包含了位置和名稱

使用樣例代碼鏈接

具體使用時,也是需要兩步:

  1. 使用 DEFINE_STRUCT_SCHEMADEFINE_STRUCT_FIELD 靜態定義字段信息(名稱、位置)
  2. 調用 ForEachField 並傳入 映射方法(函數模板或泛型 lambda 表達式),對所有字段調用這個函數
// define schema (partial)
DEFINE_STRUCT_SCHEMA(
    SimpleStruct,
    DEFINE_STRUCT_FIELD(int_, "int"),
    DEFINE_STRUCT_FIELD(string_, "string"));

// use ForEachTuple
ForEachField(SimpleStruct{1, "hello static reflection"},
             [](auto&& field, auto&& name) {
               std::cout << name << ": "
                         << field << std::endl;
             });

// output:
//   int: 1
//   string: hello static reflection

靜態反射過程中,最核心 的地方:傳入 ForEachField 的函數 fn,通過 編譯時多態 針對不同 字段類型 選擇不同的轉換操作:

  • 針對 int 類型字段,ForEachField 調用 fn(simple.int_, "int")
  • 針對 std::string 類型字段,ForEachField 調用 fn(simple.string_, "string")

最后 ForEachField(SimpleStruct{...}, [](...) { ... }); 經過 內聯 (inline) 后,生成的代碼非常簡單:

{
  SimpleStruct simple{1, "hello static reflection"};
  std::cout << "int" << ": " << simple.int_ << std::endl;
  std::cout << "string" << ": " << simple.string_ << std::endl;
}

基於靜態反射的開源庫:

使用編譯時靜態反射,相對於運行時動態反射,有許多優點:

動態反射 靜態反射
使用難度 (難)需要 編寫注冊代碼,調用 RegisterField 動態綁定字段信息 (易)可以通過 聲明式 的方法,靜態定義字段信息
運行時開銷 (有)需要動態構造 converter 對象,需要通過 虛函數表 (virtual table) 實現面向對象的多態 (無)編譯時 靜態展開代碼,和直接手寫一樣
可復用性 (差)每個 converter 對象綁定了各個 字段類型 的具體 映射方法;如果需要進行不同轉換操作,則需要另外創建 converter 對象 (好)在調用 ForEachField 時,映射方法 作為參數傳入;利用 編譯時多態 的機制,為不同的 字段類型 選擇合適的操作

編譯器生成 序列化/反序列化 代碼

代碼鏈接

基於 ForEachField,我們可以實現 通用 的結構體序列化/反序列化函數:

template <typename T>
struct adl_serializer<T, std::enable_if_t<::has_schema<T>>> {
  template <typename BasicJsonType>
  static void to_json(BasicJsonType& j, const T& value) {
    ForEachField(value, [&j](auto&& field, auto&& name) {
      j[name] = field;
    });
  }

  template <typename BasicJsonType>
  static void from_json(const BasicJsonType& j, T& value) {
    ForEachField(value, [&j](auto&& field, auto&& name) {
      // ignore missing field of optional
      if (::is_optional_v<decltype(field)> &&
          j.find(name) == j.end())
        return;

      j.at(name).get_to(field);
    });
  }
};
  • 和 [sec|人工手寫 序列化/反序列化 代碼] 的代碼類似:
    • 使用 j[name] = field 序列化
    • 使用 j.at(name).get_to(field) 反序列化
    • 針對可選字段檢查字段是否存在,不存在則跳過(C++ 17 還可以使用 if constexpr 實現選擇性編譯)
  • 關於如何使用 nlohmann::adl_serializer 擴展自定義類型的序列化/反序列化操作,參考 How do I convert third-party types? | nlohmann/json
  • 使用的兩個簡單的 變量模板 (variable template),具體見 代碼
    • has_schema<T> 檢查是否定義了 StructSchema<T>
    • is_optional_v<decltype(field)> 檢查字段類型是不是可選參數

對於需要進行序列化/反序列化的自定義結構體,我們只需要使用 DEFINE_STRUCT_SCHEMADEFINE_STRUCT_FIELD 聲明 其字段信息即可 —— 不需要為每個結構體寫一遍 to_json/from_json 邏輯了:

DEFINE_STRUCT_SCHEMA(
    SimpleStruct,
    DEFINE_STRUCT_FIELD(bool_, "_bool"),
    DEFINE_STRUCT_FIELD(int_, "_int"),
    DEFINE_STRUCT_FIELD(double_, "_double"),
    DEFINE_STRUCT_FIELD(string_, "_string"),
    DEFINE_STRUCT_FIELD(optional_, "_optional"));

DEFINE_STRUCT_SCHEMA(
    NestedStruct,
    DEFINE_STRUCT_FIELD(nested_, "_nested"),
    DEFINE_STRUCT_FIELD(vector_, "_vector"));

於是,編譯器就可以生成和 [sec|人工手寫 序列化/反序列化 代碼] 一致的代碼了。

Declarative

圖片來源:Declarative Programming And The Web

寫在最后

不依賴於第三方庫,只需要簡單的聲明,沒有額外的運行時開銷 —— 這就是 現代 C++ 元編程

馬上就 2019 年了,“勤奮” 的程序員還在加班手寫重復代碼的時候,“懶惰” 的程序員都去跨年了。。。

掌握 C++ 元編程,自己打造工具,解放生產力,告別搬磚的生活!

延伸閱讀:

如果有什么問題,歡迎交流 ~

原文:簡單的 C++ 結構體字段反射;歡迎關注個人公眾號 BOTManJL

Delivered under MIT License © 2018, BOT Man


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM