【问题标题】:Solutions for dynamic dispatch on unrelated types不相关类型的动态调度解决方案
【发布时间】:2016-10-07 11:20:57
【问题描述】:

我正在研究现代 C++ (C++11/C++14) 中不相关类型的动态调度的可能实现。

“动态分派类型”是指在运行时我们需要通过其整数索引从列表中选择一个类型并对其进行处理(调用静态方法、使用类型特征等)的情况。

以序列化数据流为例:有多种数据值,它们的序列化/反序列化方式不同;有几个编解码器,可以进行序列化/反序列化;我们的代码从流中读取类型标记,然后决定它应该调用哪个编解码器来读取完整值。

我感兴趣的情况是有许多操作可以在类型上调用(几个静态方法、类型特征......),并且从逻辑类型到 C++ 类的映射可能不同,而不仅仅是 1: 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;
            ...
        }
    }
    

优点:简单易懂;编译器可以内联调度的方法。

缺点:需要大量手动重复(或通过外部工具生成代码)。

  1. 像这样创建调度表

    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();
    }
    

优点:它更灵活一些 - 需要为每种调度类型编写一个调度程序类,然后可以将它们组合到不同的调度表中。

缺点:需要编写大量调度代码。由于虚拟调度和不可能将编解码器的方法内联到调用者的站点,会产生一些开销。

  1. 使用模板调度函数

    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(如此干净和紧凑)并依赖编译器能力来优化未使用的编解码器实例(仅用于为编解码器)。

  1. 当一组逻辑类型可能有多个映射到实现类(本例中为编解码器)时,最好推广解决方案#3并编写完全通用的调度函数,它接收类型值之间的编译时映射和调用的类型。像这样的:

    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 完全是从类型值和类型之间的编译时映射派生的——有什么方法可以处理它到编译器的生成吗?

【问题讨论】:

    标签: c++ c++11 polymorphism runtime c++14


    【解决方案1】:

    标签分派(传递一个类型来选择一个重载)是有效的。 std 库通常将它用于迭代器上的算法,因此不同的迭代器类别得到不同的实现。

    当我有一个类型 ID 列表时,我会确保它们是连续的并编写一个跳转表。

    这是一个指向完成手头任务的函数的指针数组。

    您可以用 C++11 或更好的语言自动编写它;我称它为magic switch,因为它就像一个运行时开关,它调用一个函数,其编译时间值基于运行时值。我用 lambdas 制作函数,并在其中扩展了一个参数包,因此它们的主体不同。然后它们分派给传入的函数对象。

    写下来,然后您可以将您的序列化/反序列化代码移动到“类型安全”代码中。使用特征从编译时索引映射到类型标签,和/或根据索引分派到重载函数。

    这是一个 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";
    

    live example.

    如果您可以在编译时将您的类型枚举注册到类型映射(通过类型特征或其他方式),您可以通过一个神奇的开关来回将您的运行时枚举值转换为编译时类型标记。

    template<class T> struct tag_t {using type=T;};
    

    然后你可以像这样编写你的序列化/反序列化:

    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 特征类专业化、DataType 枚举和表单的重载完成:

    void serialize( serialize_target t, int const* pdata );
    

    类型安全。

    请注意,您的any 实际上不是any,而是variant。它包含有界的类型列表,而不是任何东西。

    这个magic_switch 最终被用来重新实现std::visit 函数,这也让您可以安全地访问variant 中存储的类型。

    如果你希望它包含任何东西,你必须确定你想要支持的操作,为它编写类型擦除代码,当你将它存储在any 时运行它,存储数据旁边的类型擦除操作,鲍勃是你的叔叔。

    【讨论】:

    • 不错的解决方案!虽然,在您的示例中,它实际上并没有分派给类型,而是分派给函数重载。但是编写一个额外的帮助器应该很容易,它将编译时整数常量转换为类型列表中的类型。
    • 还有一个更大的问题:对于函数指针表,就像我的解决方案 #2 一样,编译器无法对类型进行内联操作。对于静态方法来说还算不错,但对于类型特征来说这是一个缺点。考虑我的示例中的is_trivially_serializable - 对类型的每个操作只是对常量的访问,在编译时已知,因此编译器最好能够将解决方案展开为等效于开关的东西。
    • @Yakk - Adam Nevraumont 感谢这个优雅的解决方案。我尝试使用我的 gcc v7.3,但它失败了,请参阅 https://godbolt.org/z/XebTI5,有什么建议吗?
    • @nayte 升级到 gcc 8?痛苦地将 lambda 转换为函数对象并使用它? [](PF pf)-&gt;R { return (*pf)( index&lt;Is&gt;{} ); }... 可以重写为返回函数指针的模板函数。
    • @Yakk-AdamNevraumont 我在嵌入式系统中使用 Yocto,但尚不支持 gcc8。仍然 gcc7.3 对于 C++14 应该足够了,我错了吗?我会尝试找到一个较旧的magic_switch,因为我显然不足以修改您的工作。再次感谢!
    【解决方案2】:

    这是介于 #3 和 #4 之间的解决方案。或许能给点灵感,不知道是不是真的有用。

    您可以将“编解码器”代码放入一些不相关的特征结构中,而不是使用接口基类和虚拟调度:

    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()
        {
            // ...
        }
    };
    

    然后您可以将这些 trait 类型放入类型列表中,这里我只使用 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)...);
    }
    

    这使用线性搜索来找到合适的特征,但通过一些努力,至少可以使其成为二分搜索。此外,编译器应该能够内联所有代码,因为不涉及虚拟调度。也许编译器甚至足够聪明,基本上可以把它变成一个开关。

    现场示例:http://coliru.stacked-crooked.com/a/1c597883896006c4

    【讨论】:

    • 不错!如果我们将tuple 更改为类型列表,那么这个解决方案可以分派到没有任何中间类的类型。真正的问题是:我们能否希望编译器将其从 N 个函数调用优化为仅具有一系列 ifsswitch 的一个?
    • 我当然希望任何体面的编译器至少内联代码,但与往常一样,您必须检查编译器为您的代码生成的汇编输出。
    • 我写了一个测试:gist.github.com/geluspeculum/8c644d35087c66494fcb86b3e3d82668 和 Clang 3.8 为模板递归和手动编写的 switch 发出等效的汇编代码。但是有一个问题 - 模板递归编译器将自身限制为 256 级递归,并在较大的情况下发出错误。因此,此技术仅限于类型列表中的 256 种类型。
    • 我很确定可以编写一个非递归函数,将类型列表的第 n 个类型传递给通用访问者,但这超出了这个问题的范围。
    • 看起来 Clang 4.0 没有递归深度限制并生成“对数”搜索(小开关树)。如果使用一次性的std::initializer_list 来遍历类型列表,Clang 4.0 会生成与手动切换完全相同的代码。另一方面,不幸的是,GCC 会为这两种情况生成ifs 序列。
    猜你喜欢
    • 2012-02-07
    • 1970-01-01
    • 1970-01-01
    • 2020-09-20
    • 2010-11-02
    • 1970-01-01
    • 2021-03-01
    • 2015-11-20
    • 2023-01-09
    相关资源
    最近更新 更多