利用 Boost.PFR 自动将 C++ 聚合结构体序列化为 CSV

背景

最近在工作中遇到了需要将 C++ 结构体序列化为 CSV 的需求,而且需要将不同类型的结构体序列化到同一行,一开始我手动数了不同结构的字段数并为每个结构体写了一个序列化函数,用空字符串填充属于其他类型的列,很快我意识到这十分愚蠢,一旦发生变化要改的地方很多,还很容易改错。于是我想能不能利用现代 C++ 的特性,让编译器自动帮我做这些事。

通过拷打 Gemini 2.5 Pro,我写了个自动序列化结构体为 CSV 的头文件,我觉得很有意思,因此写篇博客记录一下。

需求分析和技术选型

C++ 虽然语言层面还不支持反射,但 awesome-cpp 列出了一些使用各种奇技淫巧实现了有限反射的 C++ 库,我们可以使用这些反射库来获取结构体字段数量、字段名称和字段类型,进一步处理后即可自动生成 CSV 头并获取需要填充空字符串的列。

由于需要在同一个文件内序列化不同类型的结构体,因此需要一种方案将不同类型的结构体聚合为同一类型,这里可能的方案有 std::optionalstd::varient ,因此反射库需要支持处理这类类型。

因此对于反射库,我们的要求有:

  1. 非侵入性。因为这些结构体定义时没有考虑反射支持,而且对内存布局敏感,如果需要侵入性地修改可能需要较大的工作量。
  2. 支持字段计数、字段名称和类型获取。
  3. 支持数组类型。
  4. 支持 std::optionalstd::varient

awesome-cpp 列出的反射库中,Easy Reflectionreflect-cppmeta 应该是满足要求的。进一步调研发现 Boost 有一个模块 PFR 提供了基本的反射支持,其满足上述要求,因此这里选用 Boost.PFR 来实现 CSV 自动序列化。

实现

该实现只需用一句 Prompt 拷打 Gemini 2.5 Pro ,然后稍微修复一下即可写出。

Use boost::pfr to implement a generic CSV serialization library, flatten nested struct with field name as prefix, and recursively expand std::optional and std::array

序列化 CSV 基本可以分为两步:生成 CSV 头部(列名),序列化每条记录为一行。

编译期类型判别

Boost.PFR 使用模版元编程技巧实现有限反射,其所有代码需要在编译期生成和绑定。由于需要对不同的类型进行不同的处理,因此需要一种方案来在编译期实现类型判别,其值需要为 constexpr 。这里我使用 SFINAE 技巧实现类型 trait,用以进行类型判别和一些其他元数据(如数组大小)的获取。

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
// Trait to check if a type is std::optional
template<typename T>
struct is_optional : std::false_type {};
template<typename T>
struct is_optional<std::optional<T>> : std::true_type {};

// Trait to check if a type is reflectable by Boost.PFR (i.e., an aggregate struct)
template<typename T, typename = void>
struct is_boost_pfr_reflectable : std::false_type {};

template<typename T>
struct is_boost_pfr_reflectable<T, std::void_t<decltype(boost::pfr::detail::fields_count<T>())>>
: std::is_aggregate<T> {}; // Boost.PFR primarily works with aggregate types.

// Trait for std::array
template <typename T> struct is_std_array : std::false_type {};
template <typename T, std::size_t N> struct is_std_array<std::array<T, N>> : std::true_type {};

template <typename T> struct is_std_char_array : std::false_type {};
template <std::size_t N> struct is_std_char_array<std::array<char, N>> : std::true_type {};

template <typename T>
concept std_array_type = is_std_array<std::remove_cv_t<T>>::value;

template <typename T>
concept std_char_array_type = is_std_char_array<std::remove_cv_t<T>>::value;

不满足指定类型约束的模版示例化将继承自 std::false_type ,其 value 成员固定为 false ,满足条件的模版示例化将继承自std::true_type ,其 value 固定为 true ,且为 constexpr ,可以在编译期展开并用于 if constexpr (...) 的条件编译。

之后只需要使用 is_optional<T>::value 即可判别 T 是否为 std::optional ,其他类型同理。这里对 std::array<char, N> 进行了特殊处理,因为之后需要将其转化为 std::string

CSV 头部生成

由于不同的结构体可能具有同名字段,且可能存在数组,这里需求是对于每个字段,其列名由序列化的根对象到该字段的路径组成,例如对于下面的结构体定义

1
2
3
4
5
6
7
8
9
struct X {
int y;
};

struct Y {
int y;
std::optional<X> x;
std::array<X, 2> xa;
};

序列化 Y 的对象时应该生成下面的头部

1
y,x_y,xa_0_y,xa_1_y

因此生成头部是一个递归遍历成员字段,并展开 std::optionalstd::array 的过程。该过程仅依赖于将要生成 CSV 的记录类型,因此可以先写下函数签名。

1
2
template<typename T>
void generate_header_recursive(std::vector<std::string> &headers, const std::string &prefix);

对于不同的类型,这里有不同的处理:

  1. 对于基本类型(如 int , double 等)和 std::array<char,N> ,其传入的 prefix 就是该字段对应的列名。
  2. 对于 std::optional<T> ,在 T 上递归调用 generate_header_recursive ,使用传入的 prefix
  3. 对于 std::array<T, N> ,添加下标前缀后在 T 上递归调用。
  4. 对于结构体,分别对其每个成员添加字段名前缀后,在其类型上递归调用。

于是 generate_header_recursive 的代码长这样:

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
28
29
30
31
32
33
34
35
36
37
38
39
40
41
template<typename T>
void generate_header_recursive(std::vector<std::string> &headers, const std::string &prefix) {
// If T is std::optional<U>, generate headers for U with the same prefix.
if constexpr (is_optional<T>::value) {
using U = typename T::value_type;
generate_header_recursive<U>(headers, prefix);
}
else if constexpr (std_char_array_type<T>) {
headers.emplace_back(prefix.empty() ? "value" : prefix);
}
else if constexpr (std_array_type<T>) {
using ElemType = typename T::value_type;
constexpr std::size_t ArraySize = std::tuple_size_v<T>;
if constexpr (ArraySize == 0) return;
for (std::size_t i = 0; i < ArraySize; ++i) {
std::string new_prefix = (prefix.empty() ? "" : prefix + "_") + std::to_string(i);
generate_header_recursive<ElemType>(headers, new_prefix);
}
}
// If T is a PFR-reflectable struct, iterate its fields.
else if constexpr (is_boost_pfr_reflectable<T>::value) {
// If the struct is empty (has no fields), it contributes no headers.
if constexpr (boost::pfr::detail::fields_count<T>() == 0) {
return;
}
// Iterate over fields of T.
boost::pfr::for_each_field_with_name(T{}, [&](std::string_view name, const auto &v) {
using FieldType = std::decay_t<decltype(v)>;
// Get the name of the current field.
std::string field_name_str = std::string(name);
// Construct the new prefix for nested fields.
std::string new_prefix = prefix.empty() ? field_name_str : prefix + "_" + field_name_str;
generate_header_recursive<FieldType>(headers, new_prefix);
});
}
// Base case: T is a fundamental type. The current prefix is its full header name.
else {
headers.emplace_back(
prefix.empty() ? "value" : prefix); // Use "value" if prefix is empty (e.g. top-level basic type)
}
}

这里使用了 if constexpr 来进行编译期分支选择,没有被选中的分支不会报错。此外,boost::pfr::for_each_field_with_name 需要 C++ 20。

编译期字段计数

编译期字段计数主要用于序列化时填充空列,也是个递归过程,其整体框架与头部生成相似:

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
28
29
30
31
32
33
34
35
template<typename T>
consteval size_t count_fields_recursive() {
using DecayedT = std::remove_cv_t<T>;
// If T is std::optional<U>, count fields for U.
if constexpr (is_optional<DecayedT>::value) {
using U = typename DecayedT::value_type; // The type contained in std::optional
return count_fields_recursive<U>();
} else if constexpr (std_char_array_type<DecayedT>) {
return 1;
} else if constexpr (std_array_type<DecayedT>) {
using ElemType = typename DecayedT::value_type;
constexpr std::size_t ArraySize = std::tuple_size_v<DecayedT>;
if constexpr (ArraySize == 0) return 0;
size_t count = 0;
for (std::size_t i = 0; i < ArraySize; ++i) {
count += count_fields_recursive<ElemType>();
}
return count;
}
// If T is a PFR-reflectable struct, sum counts of its fields.
else if constexpr (is_boost_pfr_reflectable<DecayedT>::value) {
size_t count = 0;
// Iterate over fields of T (using a default-constructed T for type deduction).
boost::pfr::for_each_field(DecayedT{}, [&](const auto &v /*field_prototype*/, size_t) {
// Deduce the type of the current field.
using FieldType = std::decay_t<decltype(v)>;
count += count_fields_recursive<FieldType>();
});
return count;
}
// Base case: T is a fundamental type or non-PFR struct, counts as 1 field.
else {
return 1;
}
}
  1. 对于基本类型(如 int , double 等)和 std::array<char,N> ,返回 1。
  2. 对于 std::optional<T> ,返回 T 上的字段计数。
  3. 对于 std::array<T, N> ,返回 count_fields_recursive<T>()*N
  4. 对于结构体,返回其成员的字段计数之和。

这里使用了 consteval 修饰符,以保证该函数在编译期求值。

记录序列化

将每一个对象序列化为 CSV 行的过程与生成头部相似,但此时需要的不再是字段名称和前缀,而是字段值,以及字段数量,用于在遇到空的 std::optional 时填充空字符串。因此使用上面提到的辅助函数进行编译期字段计数。记录序列化也是一个类似的递归过程:

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
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
template<typename T>
void serialize_row_recursive(const T &value, std::vector<std::string> &row_values) {
using ActualT = std::remove_cv_t<std::remove_reference_t<T>>;
// If T is std::optional<U>
if constexpr (is_optional<ActualT>::value) {
if (value) { // If optional has a value
serialize_row_recursive(*value, row_values);
} else { // If optional is empty (std::nullopt)
using U = typename ActualT::value_type;
size_t num_empty_fields = count_fields_recursive<U>();
for (size_t i = 0; i < num_empty_fields; ++i) {
row_values.emplace_back(""); // Add empty strings for missing fields
}
}
} else if constexpr (std_char_array_type<ActualT>) {
row_values.emplace_back(std::string(value.data()));
} else if constexpr (std_array_type<ActualT>) {
constexpr std::size_t ArraySize = std::tuple_size_v<ActualT>;
if constexpr (ArraySize == 0) return;
for (std::size_t i = 0; i < ArraySize; ++i) {
serialize_row_recursive(value[i], row_values);
}
}
// If T is a PFR-reflectable struct
else if constexpr (is_boost_pfr_reflectable<T>::value) {
// If the struct is empty, it contributes no values.
if constexpr (boost::pfr::detail::fields_count<T>() == 0) {
return;
}
// Iterate over fields of the struct instance `value`.
boost::pfr::for_each_field(value, [&](const auto &field_val, size_t /*field_idx*/) {
serialize_row_recursive(field_val, row_values);
});
}
// Base case: T is a fundamental type. Convert to string.
else {
std::ostringstream oss;
if constexpr (std::is_same_v<std::decay_t<T>, bool>) {
oss << (value ? "true" : "false");
} else {
// This handles integers, floats, std::string, etc.
// std::string will be output as-is by oss, escaping happens later.
oss << value;
}
row_values.push_back(oss.str());
}
}
  1. 对于基本类型(如 int , double 等)和 std::array<char,N> ,转换为字符串输出。
  2. 对于 std::optional<T> ,如果有值,则在其值上递归调用 serialize_row_recursive ,否则输出使用辅助函数 count_fields_recursive 计算出的空列。
  3. 对于 std::array<T, N> ,对其每个元素递归调用 serialize_row_recursive
  4. 对于结构体,对其每个成员递归。

Wrapping Up

上述函数已经能够完成自动生成 CSV 头部和序列化对象为 CSV 记录的工作,接下来可以写一个函数自动序列化一个容器的对象到指定的流。

1
2
3
4
5
6
7
8
9
10
11
12
13
template<typename Container>
void serialize_to_csv_stream(std::ostream &out, const Container &data) {
// Deduce the type of objects in the container.
using T = typename Container::value_type;

// Print the header row.
out << generate_csv_header<T>() << "\n";

// Print each data row.
for (const auto &item: data) {
out << serialize_to_csv_row(item) << "\n";
}
}

Limitations

本文所述实现存在一定限制:

  1. 不能处理 C 数组。存在 C 数组的结构体会在 Boost.PFR 编译时报错。但定长 C 数组可以使用 std::array 代替。
  2. 待序列化的类型 T 必须是默认可构造的,即必须存在无参数构造函数。
  3. Boost.PFR 能处理的类型必须是 SimpleAggregate ,即不能存在基类、const 字段、引用和 C 数组。

总结

本文所述实现开源在这里。有需要的可以自取,欢迎学习交流。

C++ 真是博大精深。另外同样的框架和思想应该也能用于构建其他格式的自动序列化库,例如 glaze 等。再次感叹现代 C++ 我只学了个皮毛。