【问题标题】:std::function/bind like type-erasure without Standard C++ library没有标准 C++ 库的 std::function/bind 类型擦除
【发布时间】:2015-11-11 12:11:39
【问题描述】:

我正在基于发布/订阅模式在 C++11 中开发一个简单的事件驱动应用程序。类有一个或多个由事件循环调用的onWhateverEvent() 方法(控制反转)。由于应用程序实际上是一个固件,其中代码大小很关键,灵活性不是很重要,“订阅”部分是一个简单的表,其中包含事件 ID 和相关的处理程序。

这是一个非常简化的想法代码:

#include <functional>

enum Events {
    EV_TIMER_TICK,
    EV_BUTTON_PRESSED
};

struct Button {
    void onTick(int event) { /* publish EV_BUTTON_PRESSED */ }
};

struct Menu {
    void onButtonPressed(int event) { /* publish EV_SOMETHING_ELSE */ }
};

Button button1;
Button button2;
Menu mainMenu;

std::pair<int, std::function<void(int)>> dispatchTable[] = {
    {EV_TIMER_TICK, std::bind(&Button::onTick, &button1, std::placeholders::_1) },
    {EV_TIMER_TICK, std::bind(&Button::onTick, &button2, std::placeholders::_1) },
    {EV_BUTTON_PRESSED, std::bind(&Menu::onButtonPressed, &mainMenu, std::placeholders::_1) }
};

int main(void) 
{
    while(1) {
        int event = EV_TIMER_TICK; // msgQueue.getEventBlocking();
        for (auto& a : dispatchTable) {
            if (event == a.first) 
                a.second(event);
        }
    }
}

这可以使用桌面编译器进行编译和运行,std:function&lt;void(int)&gt;&gt; fn = std::bind(&amp;SomeClass::onSomething), &amp;someInstance, std::placeholders::_1) 优雅地实现了类型擦除,因此事件调度表可以保存不同类的处理程序,因此可以保存不同的类型。

问题来自支持 C++11 的嵌入式编译器 (AVR-GCC 4.8.3),但没有标准 C++ 库:没有 &lt;functional&gt; 标头。我在想如何仅使用编译器功能重新创建上述行为。我评估了几个选项,但每个选项都有反对意见(编译器或我):

  1. 使用virtual void Handler::onEvent(int event) 方法创建一个接口,并从中派生ButtonMenu。调度表可以保存接口指针,其余的由虚方法调用完成。这是最简单的方法,但我不喜欢将事件处理程序方法的数量限制为每个类一个(通过执行本地 if-else 事件调度),并且每个事件都有虚拟方法调用的开销。

  2. 我的第二个想法仍然包含一个虚方法调用,但对ButtonMenu 类没有限制。这是一个基于虚方法调用的类型擦除,带有仿函数:

    struct FunctBase {
        virtual void operator()(int event) = 0;
    };
    
    template<typename T>
    struct Funct : public FunctBase
    {
        T* pobj;                 //instance ptr
        void (T::*pmfn)(int);    //mem fun ptr
        Funct(T* pobj_, void (T::*pmfn_)(int)) : pobj(pobj_), pmfn(pmfn_) {}
    
        void operator()(int ev) override {
            (pobj->*pmfn)(ev);
        }
    };
    

    Funct可以保存实例和方法指针,调度表可以由FunctBase指针构成。这种方式表与函数/绑定一样灵活:可以容纳任何类(类型)和每个类的任意数量的处理程序。我唯一的问题是它仍然包含每个事件的 1 个虚拟方法调用,它只是移动到了仿函数。

  3. 我的第三个想法是将方法指针转换为函数指针的简单技巧:

    typedef void (*Pfn)(void*, int);
    Pfn pfn1 = reinterpret_cast<Pfn>(&Button::onTick);
    Pfn pfn2 = reinterpret_cast<Pfn>(&Menu::onButtonPressed);
    

    据我所知,这是未定义的行为,确实使编译器发出了一个很大的警告。它基于 C++ 方法具有指向this 的隐式第一个参数的假设。尽管如此,它仍然有效,它是轻量级的(没有虚拟调用),而且很灵活。

所以我的问题是:是否可以以干净的 C++ 方式执行选项 3 之类的操作?我知道有一种基于 void* 的类型擦除技术(与选项 2 中的虚拟方法调用相反),但我不知道如何实现它。使用 std::bind 查看桌面版本也给我的印象是它将第一个隐式参数绑定为实例指针,但也许这只是语法。

【问题讨论】:

  • 可以选择 Boost 吗?里面有boost::functionboost::bind
  • 老实说我没试过。查看 boost::function 和 boost::bind 头文件(大量其他包含),尚不清楚它们在多大程度上依赖于 stdc++ 头文件和 c++ 运行时库。我将快速检查项目是否在包含 boost 标头的情况下完全编译。
  • 请注意,虚函数调用并不比通过std::function 调用慢,如果有的话,我希望它会更快。
  • @Quentin:你可能是对的。我只是想知道是否可以使用选项 3 中的 C 样式 hack 将其简化为直接调用,也许类似的东西在 C++ 中是可行的。但我的印象是至少需要 1 次间接来恢复方法调用的类型信息。

标签: c++ pointers c++11 type-erasure


【解决方案1】:

一个可靠、高效、std::function&lt;R(Args...)&gt; 的替换并不难写。

当我们嵌入时,我们希望避免分配内存。所以我会写一个small_task&lt; Signature, size_t sz, size_t algn &gt;。它会创建一个大小为sz 和对齐方式为algn 的缓冲区,用于存储已擦除的对象。

它还存储了一个移动器、一个销毁器和一个调用函数指针。这些指针可以在small_task(最大位置)内本地,也可以在手动struct vtable { /*...*/ } const* table内。

template<class Sig, size_t sz, size_t algn>
struct small_task;

template<class R, class...Args, size_t sz, size_t algn>
struct small_task<R(Args...), sz, algn>{
  struct vtable_t {
    void(*mover)(void* src, void* dest);
    void(*destroyer)(void*);
    R(*invoke)(void const* t, Args&&...args);
    template<class T>
    static vtable_t const* get() {
      static const vtable_t table = {
        [](void* src, void*dest) {
          new(dest) T(std::move(*static_cast<T*>(src)));
        },
        [](void* t){ static_cast<T*>(t)->~T(); },
        [](void const* t, Args&&...args)->R {
          return (*static_cast<T const*>(t))(std::forward<Args>(args)...);
        }
      };
      return &table;
    }
  };
  vtable_t const* table = nullptr;
  std::aligned_storage_t<sz, algn> data;
  template<class F,
    class dF=std::decay_t<F>,
    // don't use this ctor on own type:
    std::enable_if_t<!std::is_same<dF, small_task>{}>* = nullptr,
    // use this ctor only if the call is legal:
    std::enable_if_t<std::is_convertible<
      std::result_of_t<dF const&(Args...)>, R
    >{}>* = nullptr
  >
  small_task( F&& f ):
    table( vtable_t::template get<dF>() )
  {
    // a higher quality small_task would handle null function pointers
    // and other "nullable" callables, and construct as a null small_task

    static_assert( sizeof(dF) <= sz, "object too large" );
    static_assert( alignof(dF) <= algn, "object too aligned" );
    new(&data) dF(std::forward<F>(f));
  }
  // I find this overload to be useful, as it forces some
  // functions to resolve their overloads nicely:
  // small_task( R(*)(Args...) )
  ~small_task() {
    if (table)
      table->destroyer(&data);
  }
  small_task(small_task&& o):
    table(o.table)
  {
    if (table)
      table->mover(&o.data, &data);
  }
  small_task(){}
  small_task& operator=(small_task&& o){
    // this is a bit rude and not very exception safe
    // you can do better:
    this->~small_task();
    new(this) small_task( std::move(o) );
    return *this;
  }
  explicit operator bool()const{return table;}
  R operator()(Args...args)const{
    return table->invoke(&data, std::forward<Args>(args)...);
  }
};

template<class Sig>
using task = small_task<Sig, sizeof(void*)*4, alignof(void*) >;

live example.

缺少的另一件事是高质量的void(Args...),它不关心传入的可调用对象是否具有返回值。

以上任务支持移动,但不支持复制。添加副本意味着存储的所有内容都必须是可复制的,并且需要 vtable 中的另一个函数(实现类似于move,除了srcconst 而不是std::move)。

使用了少量 C++14,即 enable_if_tdecay_t 别名等。它们可以很容易地用 C++11 编写,或者替换为 typename std::enable_if&lt;?&gt;::type

bind 最好用 lambdas 代替,老实说。即使在非嵌入式系统上我也不使用它。

另一个改进是教它如何处理更小/更少对齐的small_tasks,方法是存储它们的vtable指针,而不是将其复制到data缓冲区中,然后将其包装在另一个vtable中.这将鼓励使用对于您的问题集来说刚刚足够大的 small_tasks


将成员函数转换为函数指针不仅是未定义的行为,而且函数的调用约定通常与成员函数不同。特别是,this 在某些调用约定下在特定寄存器中传递。

这种差异可能很微妙,并且可能会在您更改不相关的代码、编译器版本更改或其他任何情况时突然出现。所以除非你别无选择,否则我会避免这种情况。


如前所述,该平台缺少库。上面std 的每次使用都很小,所以我就写它们吧:

template<class T>struct tag{using type=T;};
template<class Tag>using type_t=typename Tag::type;
using size_t=decltype(sizeof(int));

移动

template<class T>
T&& move(T&t){return static_cast<T&&>(t);}

前进

template<class T>
struct remove_reference:tag<T>{};
template<class T>
struct remove_reference<T&>:tag<T>{};
template<class T>using remove_reference_t=type_t<remove_reference<T>>;

template<class T>
T&& forward( remove_reference_t<T>& t ) {
  return static_cast<T&&>(t);
}
template<class T>
T&& forward( remove_reference_t<T>&& t ) {
  return static_cast<T&&>(t);
}

衰减

template<class T>
struct remove_const:tag<T>{};
template<class T>
struct remove_const<T const>:tag<T>{};

template<class T>
struct remove_volatile:tag<T>{};
template<class T>
struct remove_volatile<T volatile>:tag<T>{};

template<class T>
struct remove_cv:remove_const<type_t<remove_volatile<T>>>{};


template<class T>
struct decay3:remove_cv<T>{};
template<class R, class...Args>
struct decay3<R(Args...)>:tag<R(*)(Args...)>{};
template<class T>
struct decay2:decay3<T>{};
template<class T, size_t N>
struct decay2<T[N]>:tag<T*>{};

template<class T>
struct decay:decay2<remove_reference_t<T>>{};

template<class T>
using decay_t=type_t<decay<T>>;

is_convertible

template<class T>
T declval(); // no implementation

template<class T, T t>
struct integral_constant{
  static constexpr T value=t;
  constexpr integral_constant() {};
  constexpr operator T()const{ return value; }
  constexpr T operator()()const{ return value; }
};
template<bool b>
using bool_t=integral_constant<bool, b>;
using true_type=bool_t<true>;
using false_type=bool_t<false>;

template<class...>struct voider:tag<void>{};
template<class...Ts>using void_t=type_t<voider<Ts...>>;

namespace details {
  template<template<class...>class Z, class, class...Ts>
  struct can_apply:false_type{};
  template<template<class...>class Z, class...Ts>
  struct can_apply<Z, void_t<Z<Ts...>>, Ts...>:true_type{};
}
template<template<class...>class Z, class...Ts>
using can_apply = details::can_apply<Z, void, Ts...>;

namespace details {
  template<class From, class To>
  using try_convert = decltype( To{declval<From>()} );
}
template<class From, class To>
struct is_convertible : can_apply< details::try_convert, From, To > {};
template<>
struct is_convertible<void,void>:true_type{};

enable_if

template<bool, class=void>
struct enable_if {};
template<class T>
struct enable_if<true, T>:tag<T>{};
template<bool b, class T=void>
using enable_if_t=type_t<enable_if<b,T>>;

result_of

namespace details {
  template<class F, class...Args>
  using invoke_t = decltype( declval<F>()(declval<Args>()...) );

  template<class Sig,class=void>
  struct result_of {};
  template<class F, class...Args>
  struct result_of<F(Args...), void_t< invoke_t<F, Args...> > >:
    tag< invoke_t<F, Args...> >
  {};
}
template<class Sig>
using result_of = details::result_of<Sig>;
template<class Sig>
using result_of_t=type_t<result_of<Sig>>;

aligned_storage

template<size_t size, size_t align>
struct alignas(align) aligned_storage_t {
  char buff[size];
};

is_same

template<class A, class B>
struct is_same:false_type{};
template<class A>
struct is_same<A,A>:true_type{};

live example,我需要的每个 std 库模板大约有十几行。

我会将这个“std 库重新实现”放入 namespace notstd 以明确发生了什么。

如果可以,请使用将相同功能折叠在一起的链接器,例如黄金链接器。模板元编程可能会导致二进制膨胀,而没有可靠的链接器来剥离它。

【讨论】:

  • 哇,C++ 很酷,我想我需要更多的模板肌肉来理解它的所有部分。不幸的是,它严重依赖于目标平台上不可用的 stdc++ 头文件,所以我不能直接使用它。 +1 第 2 部分提到可能的 ABI 级别差异。
  • @gyor move、forward、aligned storage、decay、is_convertable、enable_if、result_of——我错过了什么?大多数都是 3-4 行,并且易于编写。 aligned_storage:你们平台的对齐要求是什么?
  • 这是一个 8 位 uC,具有 2kBytes 的 RAM 和 32kBytes 的 Flash -> 没有对齐要求。 :)
  • @GyorgySzekely 好的,完成了——在我的small_task 之前没有使用任何标题,所有内容都是用纯 C++11 编写的。我认为一切都符合 C++14(SFINAE 支持 result_of_t 等)。
  • @Yakk,我想在项目中使用您的代码。它的许可证是什么?像 MIT、CC-0 或 WTFPL 这样的东西会很完美:-)
【解决方案2】:

您的第一个想法是针对问题的典型面向对象解决方案。它非常好,但有点笨拙——不如std::function 好用。您的第三个想法是未定义的行为。不不不。

您的第二个想法 - 现在有一些我们可以使用的东西!这接近于std::function 的实际实现方式。我们可以编写一个类,它可以接受任何可以用int 调用并返回void 的对象:

class IntFunc {
private:
    struct placeholder {
        virtual ~placeholder() = default;
        virtual void call(int ) = 0;
    };

    template <typename F>
    struct holder : placeholder {
        holder(F f) : func(f) { }
        void call(int i) override { func(i); }
        F func;
    };


    // if you wrote your own unique_ptr, use it here
    // otherwise, will have to add rule of 5 stuff
    placeholder* p;
public:
    template <typename F>
    IntFunc(F f)
    : placeholder(new holder<F>(f))
    { }

    template <typename Cls>
    IntFunc(Cls* instance, void (Cls::*method)(int )) {
        auto lambda = [=](int i){ (instance->*method)(i); };
        placeholder = new holder<decltype(lambda)>(lambda);
    }

    void operator()(int i) {
        p->call(i);
    }
};

有了它,您基本上就拥有了std::function&lt;void(int)&gt; 一种可用的通用方式。

现在第 第四个 想法可能是将您的第三个想法扩展到可用的东西。实际使用函数指针:

using Pfn = void (*)(void*, int);

然后使用 lambdas 来做这样的事情:

Pfn buttonOnTick = [](void* ctxt, int i){
    static_cast<Button*>(ctxt)->onTick(i);
};

但是你必须以某种方式抓住上下文 - 这增加了额外的工作。

【讨论】:

  • 您的建议优雅地将与我的想法(在选项 2 中)类似的概念封装到单个函子中。但是将捕获 lambda 放入内部派生仿函数 (struct holder) 而不是将实例和方法指针放入其中有什么附加价值。它增加了一层间接性。第 4 个想法对于微控制器来说听起来也不错。
  • @GyorgySzekely 这实际上不再是间接的。这也支持任何捕获的函子(函子,而不是 lambda),而不仅仅是在对象上调用成员函数。
【解决方案3】:

在尝试手动编写所有 STL 内容之前,我尝试使用编译器本身已经拥有的 STL。因为您使用的大多数 STL 代码只是标头,所以我只是简单地包含它并做一些小技巧来编译它们。事实上 id 做了 10 分钟来准备链接!

我使用了 avr-gcc-5.2.0 版本,任务没有任何问题。我没有旧安装,我相信在几分钟内安装实际版本比修复旧版本的问题更容易。

为 avr 编译您的示例代码后,出现链接错误:

build-check-std-a520-nomemdbg-os-dynamic-noncov/main.o: In function `std::__throw_bad_function_call()':
/home/krud/own_components/avr_stl/avr_stl009/testing/main.cpp:42: undefined reference to `operator delete(void*, unsigned int)'
/home/krud/own_components/avr_stl/avr_stl009/testing/main.cpp:42: undefined reference to `operator delete(void*, unsigned int)'
collect2: error: ld returned 1 exit status

只需编写自己的__throw_bad_function_call 即可摆脱链接问题。

对我来说,编写自己的 STL 实现真的没有意义。这里我只是使用了来自编译器安装(gcc 5.2.0)的头文件。

【讨论】:

  • 很高兴知道! Stdc++ 标头看起来很纠结,我认为它们会拉取很多运行时依赖项。
  • @GyorgySzekely 当然,stl 实现看起来有点难以理解,但几乎没有什么需要任何类型的库代码。如前所述,大部分代码只是标题。对于其余的,如果库实现,通常可以编译单个源。 std::string 和 std::vector 按预期在 avr 上工作。但是,是的,需要额外的闪存。但根本不超过自己编写的代码。如果您需要 STL 功能,请使用 STL。它在 AVR 上也经过测试、智能且功能齐全。无需编写自己的可能容易出错的代码。
  • 对于std::string 和std::vector 是否提供了new 和delete 的实现?这些应该在运行时库中,而 avr-libc 常见问题解答(第 6 点)指出这些是缺失的。
  • @GyorgySzekely 是的,只需转发到 malloc/free。这里没什么特别的!
猜你喜欢
  • 1970-01-01
  • 1970-01-01
  • 2020-01-02
  • 1970-01-01
  • 2016-06-27
  • 1970-01-01
  • 1970-01-01
  • 2016-12-18
  • 2013-04-16
相关资源
最近更新 更多