不相关类型的动态调度解决方案
Solutions for dynamic dispatch on unrelated types
我正在研究现代 C++ (C++11/C++14) 中不相关类型的动态调度的可能实现。
"dynamic dispatch of types" 我的意思是在运行时我们需要通过其整数索引从列表中选择一个类型并对其执行某些操作(调用静态方法,使用类型特征等)。
例如,考虑序列化数据流:有几种数据值,serialized/deserialized不同;有几种编解码器,它们可以 serialization/deserialization;我们的代码从流中读取类型标记,然后决定它应该调用哪个编解码器来读取完整值。
我感兴趣的是有很多操作,可以在类型上调用(几个静态方法,类型特征...),以及从逻辑类型到 C++ 的不同映射 classes 而不仅仅是 1:1(在序列化示例中,这意味着可能有多种数据类型全部由同一编解码器序列化)。
我也希望避免手动代码重复,并使代码更易于维护且不易出错。性能也很重要。
目前我看到了那些可能的实现,我是不是漏掉了什么?可以做得更好吗?
使用 switch-case 手动编写尽可能多的函数,因为类型上有可能的操作调用。
size_t serialize(const Any & any, char * data)
{
switch (any.type) {
case Any::Type::INTEGER:
return IntegerCodec::serialize(any.value, data);
...
}
}
Any deserialize(const char * data, size_t size)
{
Any::Type type = deserialize_type(data, size);
switch (type) {
case Any::Type::INTEGER:
return IntegerCodec::deserialize(data, size);
...
}
}
bool is_trivially_serializable(const Any & any)
{
switch (any.type) {
case Any::Type::INTEGER:
return traits::is_trivially_serializable<IntegerCodec>::value;
...
}
}
优点:简单易懂;编译器可以内联分派的方法。
缺点:需要大量手动重复(或通过外部工具生成代码)。
像这样创建调度table
class AnyDispatcher
{
public:
virtual size_t serialize(const Any & any, char * data) const = 0;
virtual Any deserialize(const char * data, size_t size) const = 0;
virtual bool is_trivially_serializable() const = 0;
...
};
class AnyIntegerDispatcher: public AnyDispatcher
{
public:
size_t serialize(const Any & any, char * data) const override
{
return IntegerCodec::serialize(any, data);
}
Any deserialize(const char * data, size_t size) const override
{
return IntegerCodec::deserialize(data, size);
}
bool is_trivially_serializable() const
{
return traits::is_trivially_serializable<IntegerCodec>::value;
}
...
};
...
// global constant
std::array<AnyDispatcher *, N> dispatch_table = { new AnyIntegerDispatcher(), ... };
size_t serialize(const Any & any, char * data)
{
return dispatch_table[any.type]->serialize(any, data);
}
Any deserialize(const char * data, size_t size)
{
return dispatch_table[any.type]->deserialize(data, size);
}
bool is_trivially_serializable(const Any & any)
{
return dispatch_table[any.type]->is_trivially_serializable();
}
优点:它更灵活一些 - 需要为每种调度类型编写一个调度程序class,但随后可以将它们组合在不同的调度中tables.
缺点:需要编写大量调度代码。由于虚拟调度和无法将编解码器的方法内联到调用者的站点,因此会产生一些开销。
使用模板调度功能
template <typename F, typename... Args>
auto dispatch(Any::Type type, F f, Args && ...args)
{
switch (type) {
case Any::Type::INTEGER:
return f(IntegerCodec(), std::forward<Args>(args)...);
...
}
}
size_t serialize(const Any & any, char * data)
{
return dispatch(
any.type,
[] (const auto codec, const Any & any, char * data) {
return std::decay_t<decltype(codec)>::serialize(any, data);
},
any,
data
);
}
bool is_trivially_serializable(const Any & any)
{
return dispatch(
any.type,
[] (const auto codec) {
return traits::is_trivially_serializable<std::decay_t<decltype(codec)>>::value;
}
);
}
优点:它只需要一个 switch-case 调度函数和每个操作调用中的少量代码(至少手动编写)。编译器可能会内联它认为合适的内容。
缺点:它更复杂,需要 C++14(如此干净和紧凑)并依赖于编译器能力来优化未使用的编解码器实例(仅使用为编解码器选择正确的重载)。
当一组逻辑类型可能有多个映射到实现 classes(本例中的编解码器)时,最好概括解决方案 #3 并编写完全通用的dispatch 函数,它接收类型值和调用类型之间的编译时映射。像这样:
template <typename Mapping, typename F, typename... Args>
auto dispatch(Any::Type type, F f, Args && ...args)
{
switch (type) {
case Any::Type::INTEGER:
return f(mpl::map_find<Mapping, Any::Type::INTEGER>(), std::forward<Args>(args)...);
...
}
}
我倾向于解决方案#3(或#4)。但我确实想知道 - 是否可以避免手动编写 dispatch
函数?我的意思是它的开关盒。这个 switch-case 完全派生自类型值和类型之间的编译时映射 - 有什么方法可以处理它对编译器的生成吗?
通过传递类型来选择重载的标记分派是高效的。 std
库通常将它用于迭代器上的算法,因此不同的迭代器类别有不同的实现。
当我有一个类型 id 列表时,我确保它们是连续的并写一个跳转 table。
这是一个指针数组,指向执行手头任务的函数。
您可以使用 C++11 或更高版本自动编写此代码;我称它为 magic switch,因为它就像一个运行时开关,它调用一个具有基于运行时值的编译时值的函数。我用 lambda 编写函数,并在其中扩展参数包,使它们的主体不同。然后他们分派给传入的函数对象。
写那个,然后你可以把你的 serialization/deserialization 代码移动到 "type safe" 代码中。使用 traits 从编译时索引映射到类型标签,and/or 根据索引分派到重载函数。
这是一个 C++14 魔法开关:
template<std::size_t I>using index=std::integral_constant<std::size_t, I>;
template<class F, std::size_t...Is>
auto magic_switch( std::size_t I, F&& f, std::index_sequence<Is...> ) {
auto* pf = std::addressof(f);
using PF = decltype(pf);
using R = decltype( (*pf)( index<0>{} ) );
using table_entry = R(*)( PF );
static const table_entry table[] = {
[](PF pf)->R {
return (*pf)( index<Is>{} );
}...
};
return table[I](pf);
}
template<std::size_t N, class F>
auto magic_switch( std::size_t I, F&& f ) {
return magic_switch( I, std::forward<F>(f), std::make_index_sequence<N>{} );
}
使用看起来像:
std::size_t r = magic_switch<100>( argc, [](auto I){
return sizeof( char[I+1] ); // I is a compile-time size_t equal to argc
});
std::cout << r << "\n";
如果您可以在编译时将您的类型枚举注册到类型映射(通过类型特征或其他方式),您可以通过一个神奇的开关往返将您的运行时枚举值转换为编译时类型标签。
template<class T> struct tag_t {using type=T;};
然后你可以这样写你的serialize/deserialize:
template<class T>
void serialize( serialize_target t, void const* pdata, tag_t<T> ) {
serialize( t, static_cast<T const*>(pdata) );
}
template<class T>
void deserialize( deserialize_source s, void* pdata, tag_t<T> ) {
deserialize( s, static_cast<T*>(pdata) );
}
如果我们有一个enum DataType
,我们写一个特征:
enum DataType {
Integer,
Real,
VectorOfData,
DataTypeCount, // last
};
template<DataType> struct enum_to_type {};
template<DataType::Integer> struct enum_to_type:tag_t<int> {};
// etc
void serialize( serialize_target t, Any const& any ) {
magic_switch<DataType::DataTypeCount>(
any.type_index,
[&](auto type_index) {
serialize( t, any.pdata, enum_to_type<type_index>{} );
}
};
}
所有繁重的工作现在都由 enum_to_type
特征 class 专业化、DataType
枚举和以下形式的重载完成:
void serialize( serialize_target t, int const* pdata );
类型安全。
请注意,您的 any
实际上不是 any
,而是 variant
。它包含类型的有界列表,而不是任何内容。
这个 magic_switch
最终被用于重新实现 std::visit
函数,这也使您可以类型安全地访问存储在 variant
.
中的类型
如果你想让它包含任何东西,你必须确定你想要支持什么操作,为它编写类型擦除代码,当你将它存储在any
,将类型擦除的操作与数据一起存储,bob 是你的叔叔。
这是介于您的#3 和#4 之间的解决方案。或许能给点启发,不知道有没有用。
您可以将 "codec" 代码放入一些不相关的特征结构中,而不是使用接口基础 class 和虚拟分派:
struct AnyFooCodec
{
static size_t serialize(const Any&, char*)
{
// ...
}
static Any deserialize(const char*, size_t)
{
// ...
}
static bool is_trivially_serializable()
{
// ...
}
};
struct AnyBarCodec
{
static size_t serialize(const Any&, char*)
{
// ...
}
static Any deserialize(const char*, size_t)
{
// ...
}
static bool is_trivially_serializable()
{
// ...
}
};
然后你可以将这些特征类型放入一个类型列表中,这里我只使用了一个std::tuple
:
typedef std::tuple<AnyFooCodec, AnyBarCodec> DispatchTable;
现在我们可以编写一个通用的调度函数,将第 n 个类型特征传递给给定的仿函数:
template <size_t N>
struct DispatchHelper
{
template <class F, class... Args>
static auto dispatch(size_t type, F f, Args&&... args)
{
if (N == type)
return f(typename std::tuple_element<N, DispatchTable>::type(), std::forward<Args>(args)...);
return DispatchHelper<N + 1>::dispatch(type, f, std::forward<Args>(args)...);
}
};
template <>
struct DispatchHelper<std::tuple_size<DispatchTable>::value>
{
template <class F, class... Args>
static auto dispatch(size_t type, F f, Args&&... args)
{
// TODO: error handling (type index out of bounds)
return decltype(DispatchHelper<0>::dispatch(type, f, args...)){};
}
};
template <class F, class... Args>
auto dispatch(size_t type, F f, Args&&... args)
{
return DispatchHelper<0>::dispatch(type, f, std::forward<Args>(args)...);
}
这使用线性搜索来找到合适的特征,但通过一些努力至少可以使其成为二分搜索。此外,编译器应该能够内联所有代码,因为不涉及虚拟分派。也许编译器足够聪明,基本上可以把它变成一个开关。
我正在研究现代 C++ (C++11/C++14) 中不相关类型的动态调度的可能实现。
"dynamic dispatch of types" 我的意思是在运行时我们需要通过其整数索引从列表中选择一个类型并对其执行某些操作(调用静态方法,使用类型特征等)。
例如,考虑序列化数据流:有几种数据值,serialized/deserialized不同;有几种编解码器,它们可以 serialization/deserialization;我们的代码从流中读取类型标记,然后决定它应该调用哪个编解码器来读取完整值。
我感兴趣的是有很多操作,可以在类型上调用(几个静态方法,类型特征...),以及从逻辑类型到 C++ 的不同映射 classes 而不仅仅是 1:1(在序列化示例中,这意味着可能有多种数据类型全部由同一编解码器序列化)。
我也希望避免手动代码重复,并使代码更易于维护且不易出错。性能也很重要。
目前我看到了那些可能的实现,我是不是漏掉了什么?可以做得更好吗?
使用 switch-case 手动编写尽可能多的函数,因为类型上有可能的操作调用。
size_t serialize(const Any & any, char * data) { switch (any.type) { case Any::Type::INTEGER: return IntegerCodec::serialize(any.value, data); ... } } Any deserialize(const char * data, size_t size) { Any::Type type = deserialize_type(data, size); switch (type) { case Any::Type::INTEGER: return IntegerCodec::deserialize(data, size); ... } } bool is_trivially_serializable(const Any & any) { switch (any.type) { case Any::Type::INTEGER: return traits::is_trivially_serializable<IntegerCodec>::value; ... } }
优点:简单易懂;编译器可以内联分派的方法。
缺点:需要大量手动重复(或通过外部工具生成代码)。
像这样创建调度table
class AnyDispatcher { public: virtual size_t serialize(const Any & any, char * data) const = 0; virtual Any deserialize(const char * data, size_t size) const = 0; virtual bool is_trivially_serializable() const = 0; ... }; class AnyIntegerDispatcher: public AnyDispatcher { public: size_t serialize(const Any & any, char * data) const override { return IntegerCodec::serialize(any, data); } Any deserialize(const char * data, size_t size) const override { return IntegerCodec::deserialize(data, size); } bool is_trivially_serializable() const { return traits::is_trivially_serializable<IntegerCodec>::value; } ... }; ... // global constant std::array<AnyDispatcher *, N> dispatch_table = { new AnyIntegerDispatcher(), ... }; size_t serialize(const Any & any, char * data) { return dispatch_table[any.type]->serialize(any, data); } Any deserialize(const char * data, size_t size) { return dispatch_table[any.type]->deserialize(data, size); } bool is_trivially_serializable(const Any & any) { return dispatch_table[any.type]->is_trivially_serializable(); }
优点:它更灵活一些 - 需要为每种调度类型编写一个调度程序class,但随后可以将它们组合在不同的调度中tables.
缺点:需要编写大量调度代码。由于虚拟调度和无法将编解码器的方法内联到调用者的站点,因此会产生一些开销。
使用模板调度功能
template <typename F, typename... Args> auto dispatch(Any::Type type, F f, Args && ...args) { switch (type) { case Any::Type::INTEGER: return f(IntegerCodec(), std::forward<Args>(args)...); ... } } size_t serialize(const Any & any, char * data) { return dispatch( any.type, [] (const auto codec, const Any & any, char * data) { return std::decay_t<decltype(codec)>::serialize(any, data); }, any, data ); } bool is_trivially_serializable(const Any & any) { return dispatch( any.type, [] (const auto codec) { return traits::is_trivially_serializable<std::decay_t<decltype(codec)>>::value; } ); }
优点:它只需要一个 switch-case 调度函数和每个操作调用中的少量代码(至少手动编写)。编译器可能会内联它认为合适的内容。
缺点:它更复杂,需要 C++14(如此干净和紧凑)并依赖于编译器能力来优化未使用的编解码器实例(仅使用为编解码器选择正确的重载)。
当一组逻辑类型可能有多个映射到实现 classes(本例中的编解码器)时,最好概括解决方案 #3 并编写完全通用的dispatch 函数,它接收类型值和调用类型之间的编译时映射。像这样:
template <typename Mapping, typename F, typename... Args> auto dispatch(Any::Type type, F f, Args && ...args) { switch (type) { case Any::Type::INTEGER: return f(mpl::map_find<Mapping, Any::Type::INTEGER>(), std::forward<Args>(args)...); ... } }
我倾向于解决方案#3(或#4)。但我确实想知道 - 是否可以避免手动编写 dispatch
函数?我的意思是它的开关盒。这个 switch-case 完全派生自类型值和类型之间的编译时映射 - 有什么方法可以处理它对编译器的生成吗?
通过传递类型来选择重载的标记分派是高效的。 std
库通常将它用于迭代器上的算法,因此不同的迭代器类别有不同的实现。
当我有一个类型 id 列表时,我确保它们是连续的并写一个跳转 table。
这是一个指针数组,指向执行手头任务的函数。
您可以使用 C++11 或更高版本自动编写此代码;我称它为 magic switch,因为它就像一个运行时开关,它调用一个具有基于运行时值的编译时值的函数。我用 lambda 编写函数,并在其中扩展参数包,使它们的主体不同。然后他们分派给传入的函数对象。
写那个,然后你可以把你的 serialization/deserialization 代码移动到 "type safe" 代码中。使用 traits 从编译时索引映射到类型标签,and/or 根据索引分派到重载函数。
这是一个 C++14 魔法开关:
template<std::size_t I>using index=std::integral_constant<std::size_t, I>;
template<class F, std::size_t...Is>
auto magic_switch( std::size_t I, F&& f, std::index_sequence<Is...> ) {
auto* pf = std::addressof(f);
using PF = decltype(pf);
using R = decltype( (*pf)( index<0>{} ) );
using table_entry = R(*)( PF );
static const table_entry table[] = {
[](PF pf)->R {
return (*pf)( index<Is>{} );
}...
};
return table[I](pf);
}
template<std::size_t N, class F>
auto magic_switch( std::size_t I, F&& f ) {
return magic_switch( I, std::forward<F>(f), std::make_index_sequence<N>{} );
}
使用看起来像:
std::size_t r = magic_switch<100>( argc, [](auto I){
return sizeof( char[I+1] ); // I is a compile-time size_t equal to argc
});
std::cout << r << "\n";
如果您可以在编译时将您的类型枚举注册到类型映射(通过类型特征或其他方式),您可以通过一个神奇的开关往返将您的运行时枚举值转换为编译时类型标签。
template<class T> struct tag_t {using type=T;};
然后你可以这样写你的serialize/deserialize:
template<class T>
void serialize( serialize_target t, void const* pdata, tag_t<T> ) {
serialize( t, static_cast<T const*>(pdata) );
}
template<class T>
void deserialize( deserialize_source s, void* pdata, tag_t<T> ) {
deserialize( s, static_cast<T*>(pdata) );
}
如果我们有一个enum DataType
,我们写一个特征:
enum DataType {
Integer,
Real,
VectorOfData,
DataTypeCount, // last
};
template<DataType> struct enum_to_type {};
template<DataType::Integer> struct enum_to_type:tag_t<int> {};
// etc
void serialize( serialize_target t, Any const& any ) {
magic_switch<DataType::DataTypeCount>(
any.type_index,
[&](auto type_index) {
serialize( t, any.pdata, enum_to_type<type_index>{} );
}
};
}
所有繁重的工作现在都由 enum_to_type
特征 class 专业化、DataType
枚举和以下形式的重载完成:
void serialize( serialize_target t, int const* pdata );
类型安全。
请注意,您的 any
实际上不是 any
,而是 variant
。它包含类型的有界列表,而不是任何内容。
这个 magic_switch
最终被用于重新实现 std::visit
函数,这也使您可以类型安全地访问存储在 variant
.
如果你想让它包含任何东西,你必须确定你想要支持什么操作,为它编写类型擦除代码,当你将它存储在any
,将类型擦除的操作与数据一起存储,bob 是你的叔叔。
这是介于您的#3 和#4 之间的解决方案。或许能给点启发,不知道有没有用。
您可以将 "codec" 代码放入一些不相关的特征结构中,而不是使用接口基础 class 和虚拟分派:
struct AnyFooCodec
{
static size_t serialize(const Any&, char*)
{
// ...
}
static Any deserialize(const char*, size_t)
{
// ...
}
static bool is_trivially_serializable()
{
// ...
}
};
struct AnyBarCodec
{
static size_t serialize(const Any&, char*)
{
// ...
}
static Any deserialize(const char*, size_t)
{
// ...
}
static bool is_trivially_serializable()
{
// ...
}
};
然后你可以将这些特征类型放入一个类型列表中,这里我只使用了一个std::tuple
:
typedef std::tuple<AnyFooCodec, AnyBarCodec> DispatchTable;
现在我们可以编写一个通用的调度函数,将第 n 个类型特征传递给给定的仿函数:
template <size_t N>
struct DispatchHelper
{
template <class F, class... Args>
static auto dispatch(size_t type, F f, Args&&... args)
{
if (N == type)
return f(typename std::tuple_element<N, DispatchTable>::type(), std::forward<Args>(args)...);
return DispatchHelper<N + 1>::dispatch(type, f, std::forward<Args>(args)...);
}
};
template <>
struct DispatchHelper<std::tuple_size<DispatchTable>::value>
{
template <class F, class... Args>
static auto dispatch(size_t type, F f, Args&&... args)
{
// TODO: error handling (type index out of bounds)
return decltype(DispatchHelper<0>::dispatch(type, f, args...)){};
}
};
template <class F, class... Args>
auto dispatch(size_t type, F f, Args&&... args)
{
return DispatchHelper<0>::dispatch(type, f, std::forward<Args>(args)...);
}
这使用线性搜索来找到合适的特征,但通过一些努力至少可以使其成为二分搜索。此外,编译器应该能够内联所有代码,因为不涉及虚拟分派。也许编译器足够聪明,基本上可以把它变成一个开关。