不久之前,一个朋友和谈论关于如何实现将结构(struct)对象序列化为原始字节。他当时的工作是,需要生产含有含有补充(padding)的对象,但是对象在序列化之后不能包含补充(padding)的内容,例如:
struct Foo
{
char data0;
// 3 bytes padding here
int data1;
};
在他所描述的例子中,有大量不同类型的对象需要被序列化,并且目标是:
- 按照对象组织的形式生产(所以不能随意修改对象内部的排列);
- 确保它们是一个整体结构(aggregates)。
熟悉模版元编程的C++开发者,我们可以将这个作为一个有趣的挑战,来通过C++17的功能特性实现这一问题。此外,在这一过程中,我发现一个更为一般化的解决方法,来迭代任何组合类型(aggregate type)。
1.组合类型
在我继续之前,一个很重要的前提是,需要了解一下组合类型到底是什么,以及它的一些相关属性。简单来说,一个组合类型可以看作如下:
- 一个数组;
- 一个结构体或者类,仅具有公共成员和公共基类,没有自定义构造函数。
这里给出的只是简单的类比,实际组合类型复杂的多。
1-1.组合类型有什么特别之处?
组合类型的特别之处在于两个地方。第一个是,组合类型没有自定义的构造函数,它们只能使用默认生成的构造函数(copy/move/default),或者只能被组合初始化。在下面某一阶段,这一特性很重要。第二个是,自从C++17以来,组合类型可以被用作结构绑定表达式,这样就不需要编译器作者的额外工作,例如:
struct Foo{
char a;
int b;
};
...
auto [x,y] = Foo{'X', 42};
同样重要的是要知道,组合类型只能通过结构化绑定分解为组合中具有的确切成员数,因此在使用绑定表达式之前必须知道成员数。
1-2.这些特性有什么帮助?
了解以上关于组合类型的两点实际上是我们开发通用解决方案所需要的。 如果我们可以找到一个组合类型对象包含多少个成员,那么我们将能够使用结构化绑定分解该对象,并对每个成员执行某些操作!
2.在一个组合类型中检测成员
上面提出的第一个问题是,我们如何知道组合类型中到底包含多少个成员?
在C++语言中,并没有为成员的数量提供类似于sizeof这样的函数,所以,我们只能借助一些模版技巧实现这样的功能。这也是组合类型首先起作用的场景:一个组合类型含有的构造函数只能实施组合初始化。这也就意味着对于任何组合类型,我们知道一个表达式T{args...}可以构造一个组合,当中args...可以在0到组合元素总数中的任意位置。
所以,当前情况下,我们需要回答的问题是:我们可以从T类型中最多初始化多少个参数?
2-1.测试T类型是否可以组合初始化
首先,我们需要一种方式来测试T类型来组合初始化所有参数。因为,我们实际上并不知道对于每个成员的参数类型到底是什么,我们需要C++语言中,一些可以临时用在表达式中,替代为初始化类型的表达式:
// A type that can be implicitly converted to *anything*
struct Anything {
template <typename T>
operator T() const; // We don't need to define this function
};
我们不需要实际去定义函数,而是去获取函数类型,这样的话,C++类型系统可以检测到,从而实现隐式类型转换。这里,我们仅需要简单测试表达式T{Anything{}...}对于特定数量的参数来说是合法的。而这项工作非常适合使用std::index_sequence和std::void_t,也就是借助SFINAE(Substitution Failure Is Not An Error)思想来计算表达式(SFINAE见我的另一篇文章https://www.toutiao.com/i6943036668916072990/):
namespace detail {
template <typename T, typename Is, typename=void>
struct is_aggregate_constructible_from_n_impl
: std::false_type{};
template <typename T, std::size_t...Is>
struct is_aggregate_constructible_from_n_impl<
T,
std::index_sequence<Is...>,
std::void_t<decltype(T{(void(Is),Anything{})...})>
> : std::true_type{};
} // namespace detail
template <typename T, std::size_t N>
using is_aggregate_constructible_from_n = detail::is_aggregate_constructible_from_n_impl<T,std::make_index_sequence<N>>;
这样的话,我们现在就可以测试对于使用组合初始化,需要多少参数来构建一个组合:
struct Point{ int x, y; }
// Is constructible from these 3
static_assert(is_aggregate_constructible_from_n<Point,0>::value);
static_assert(is_aggregate_constructible_from_n<Point,1>::value);
static_assert(is_aggregate_constructible_from_n<Point,2>::value);
// Is not constructible for anything above
static_assert(!is_aggregate_constructible_from_n<Point,3>::value);
static_assert(!is_aggregate_constructible_from_n<Point,4>::value);
2-2.测试初始化成员的最大数量
我们现在需要的是测试可用于构造组合的最大参数数量,可以通过下面几种方式实现:
- 从0递归计算到失败;
- 从一个预选设定的数值,向下知道我们找到一个成功的;
- 从两个预定的数值进行二叉搜索,直达找到最大范围。
前两个选项根据组合所具有的成员数在模板迭代深度上增加。 成员数量越大,编译时就需要更多的迭代–这会增加编译时间和复杂性。后者的选项将更难以理解,但也保证了最少的模板实例化次数,因此应降低总体编译时的复杂度。对于这一部分,网上已经提供很好的搜索方案来解决这个问题:
namespace detail {
template <std::size_t Min, std::size_t Range, template <std::size_t N> class target>
struct maximize
: std::conditional_t<
maximize<Min, Range/2, target>{} == (Min+Range/2)-1,
maximize<Min+Range/2, (Range+1)/2, target>,
maximize<Min, Range/2, target>
>{};
template <std::size_t Min, template <std::size_t N> class target>
struct maximize<Min, 1, target>
: std::conditional_t<
target<Min>{},
std::integral_constant<std::size_t,Min>,
std::integral_constant<std::size_t,Min-1>
>{};
template <std::size_t Min, template <std::size_t N> class target>
struct maximize<Min, 0, target>
: std::integral_constant<std::size_t,Min-1>
{};
template <typename T>
struct construct_searcher {
template<std::size_t N>
using result = is_aggregate_constructible_from_n<T, N>;
};
}
template <typename T, std::size_t Cap=32>
using constructor_arity = detail::maximize< 0, Cap, detail::construct_searcher<T>::template result >;
这个方案充分利用了temple的模版参数,也就是重复利用了上面的is_aggregate_constructible_from_n来,寻找最大的成员数量,实现在0和Cap(默认是32)之间构建一个给定的组合。从而,使用上面的Type类型测试我们的解决方案:
static_assert(constructor_arity<Point>::value == 2u);
3.从一个组合中提取成员
现在我们可以知道我们构造的组合类型中有多少成员,进而我们可以使用结构绑定来将元素提取出来,以及在上面进行基础的操作。出于我们这里的目的,让我们简单地以std :: visit对访问者函数相同的方式在其上调用一些函数。 请注意,由于结构化绑定需要静态指定的特定数量的元素,因此我们将需要N个重载来提取N个成员:
namespace detail {
template <typename T, typename Fn>
auto for_each_impl(T&& agg, Fn&& fn, std::integral_constant<std::size_t,0>) -> void
{
// do nothing (0 members)
}
template <typename T, typename Fn>
auto for_each_impl(T& agg, Fn&& fn, std::integral_constant<std::size_t,1>) -> void
{
auto& [m0] = agg;
fn(m0);
}
template <typename T, typename Fn>
auto for_each_impl(T& agg, Fn&& fn, std::integral_constant<std::size_t,2>) -> void
{
auto& [m0, m1] = agg;
fn(m0); fn(m1);
}
template <typename T, typename Fn>
auto for_each_impl(T& agg, Fn&& fn, std::integral_constant<std::size_t,3>) -> void
{
auto& [m0, m1, m2] = agg;
fn(m0); fn(m1); fn(m2);
}
// ...
} // namespace detail
template <typename T, typename Fn>
void for_each_member(T& agg, Fn&& fn)
{
detail::for_each_impl(agg, std::forward<Fn>(fn), constructor_arity<T>{});
}
我们在这里为每个成员简单地对标记派遣使用integrate_constant,并转发在每个成员上调用的函数Fn。 让我们快速测试一下:
int main()
{
const auto p = Point{1,2};
for_each_member(p, [](auto x){
std::cout << x << std::endl;
});
}
4.回到序列化
现在让我们通过序列化将所有这些联系在一起。 现在,我们有了访问结构的每个成员的简便方法,序列化就变成了一个简单的方面,即可以通过简单的回调将所有成员转换为一系列字节。如果我们忽略字节序,那么打包数据的序列化可以很简单地完成:
template <typename T>
auto to_packed_bytes(const T& data) -> std::vector<std::byte>
{
auto result = std::vector<std::byte>{};
// serialize each member!
for_each_member(data, [&result](const auto& v){
const auto* const begin = reinterpret_cast<const std::byte*>(&v);
const auto* const end = begin + sizeof(v);
result.insert(result.end(), begin, end);
});
return result;
}
...
auto data = Foo{'X', 42};
auto result = to_packed_bytes(data);
这比为每个生成的对象定义N个序列化函数要容易得多。 该解决方案所需的全部工作就是增加检测宏中Count的上限,并增加前面提到的for_each_impl重载的Count实例。
5.最后总结
这为我们提供了一种有趣的解决方案,以实现“反射”任何聚合的成员----全部使用完全标准的C ++ 17。最初,当我发现这种解决方案时,我曾以为自己是第一个遇到这种特定方法的人。 但是,在对本文进行研究时,我发现出色的magic_get库击败了我。 但是,该技术仍然可以在任何现代代码库中证明是有用的,并且可以用于许多怪异而奇妙的事情。在提示此发现的序列化示例之外,它还可以与其他元编程实用程序结合使用,例如在编译时获取未修饰的类型名称,以生成operator <<重载,以便即时打印聚合 。
5.1进一步的优化
这只是教程文章,因此仅是可以完成的基本概述。 还有一些可能的改进也值得考虑:
- 我们可以通过在绑定中将T更改为T &&,以及将auto&更改为auto &&来传播类型的CV限定词(这将需要更多的std :: forward-ing);
- 可以检测到std :: get和std :: tuple_size专业化的存在,因此这不仅适用于聚合。