(C++) 一个可注销的通用多路回调列表


建议在这里阅读原文 (这个永久链接在发布时生成)。这里不仅文中的错误和疏漏会被随时修正 (更少的错别字),在文末还有机会看到有趣的补充内容 (神回复和神吐槽) :P


背景

回调列表是个很常见的东东,经常被用在 Observer 这样的订阅/发布模式里。当系统触发一个事件时,会遍历所有已经注册的回调列表,挨个调用,通知到相关的对象。

我们知道,为了保持对 C 尽可能的兼容,一直以来,C++ 中的函数并非是所谓的“一级对象” (first-class objects)。而在函数指针的帮助下,我们可以在 C/C++ 中模拟一些 First-class function 才有的特性,比如把函数像值一样以参数传递和保存。到了 C++11 的出现,有了语言和标准库级别的 lambda / closure / std::function 之后,对函数的操作才变得真正灵活和丰富起来。


常见的 C/C++ 回调列表有以下这几种实现方式:

  1. 基类指针 (形如 std::vector<IListener*>,当回调发生时,以虚函数的形式通知到不同的派生类的对象。这个方案的问题在于,凡是想加入这个列表,必须从 IListener 派生,而且所有的虚函数要求签名严格一致,耦合太高,灵活性较差。
  2. 函数指针 (形如 std::vector<fnCallback>,当回调发生时,挨个调用容器中的函数指针。这个方案避免了继承的强耦合,但仍需要保证所有的响应函数签名一致,而且每一种类型的响应函数都要定义不同的回调列表,多了之后非常啰嗦,再一个函数指针本身可读性也欠佳。
  3. 函数对象 (形如std::vector<std::function< ... >>,这种回调列表相对于上面两个更加灵活一些,不仅不需要继承,在 std::bind 的帮助下,连函数签名也不需要一致。但问题是,由于 std::function 无法使用 ==!= 来比较(见参考一(第1条)参考二),注销比较麻烦,不像上面两个可以直接指针比较。

好处

那么这里介绍的所谓通用回调列表有何好处呢?

  1. (以所谓“完美转发”的形式)支持任意个数和类型的参数调用
  2. 在上面第三点 std::function<> 的基础上,可以使用 std::string 作为 tag, 标记那些后面需要被注销的函数,也同时支持不打 tag 的函数
  3. 在需要时,支持批量地收集这些回调函数的返回值

接口

说完了好处,接下来看一下 BtMulticast 这个类的对外接口和基本的使用吧:

template <typename TRet, class... TArgs>
class BtMulticast 
{
public:
    using TFunc = std::function < TRet(TArgs...) > ;
    using TElem = std::pair < TFunc, std::string > ;
    using TRetVect = std::vector < std::pair < TRet, std::string > >;

    bool AddFunc(TFunc func);
    bool AddFunc(const std::string& tag, TFunc func);
    void RemoveFunc(const std::string& tag);

    template<class... U> void Invoke(U&&... u);
    template<class... U> TRetVect InvokeR(U&&... u);

private:
    std::vector < TElem > m_funcList;
};

这个类很简短,

  • AddFunc / RemoveFunc 是添加和删除回调函数
  • Invoke / InvokeR 分别触发无返回值和普通返回值的回调。

需要注意的是,

  1. AddFunc() 可以选择指明 tag, 在这种情况下可通过指明 tag 来 RemoveFunc
  2. InvokeR() 实际上返回的是一个返回值列表,采集了每一个回调的结果
  3. TFunc 这个类型定义了最终存储在 BtMulticast 类中的回调函数对象,利用了 C++11 的所谓“完美转发”来把任意类型和个数的参数转发给回调函数
  4. 考虑到 add/remove 通常只发生一次,而每次触发事件都会遍历,内部的存储选择 std::vector,牺牲了一点 add/remove 时的查找速度,换得更快更紧凑的遍历。而看一下实现代码就可以知道,牺牲的那点 add/remove 速度也只有在有 tag 的情况下会发生。

用法

使用方面,基本用法如下:

// testing multicast: simplest
{
    BtMulticast<void> test;
    test.AddFunc([]() { BT_LOG("Multicast (simplest): func 1 called. "); });
    test.AddFunc([]() { BT_LOG("Multicast (simplest): func 2 called. "); });
    test.AddFunc([]() { BT_LOG("Multicast (simplest): func 3 called. "); });
    test.Invoke();
}

三个匿名函数被添加进 test 对象,然后在 test.Invoke() 的时候被依次调用。

// testing multicast: tagged & single parameter
{
    BtMulticast<void, int> test;
    test.AddFunc("a", [](int p) { BT_LOG("Multicast (tagged): func a called (param: %d). ", p); });
    test.AddFunc("b", [](int p) { BT_LOG("Multicast (tagged): func b called (param: %d). ", p); });
    test.AddFunc("c", [](int p) { BT_LOG("Multicast (tagged): func c called (param: %d). ", p); });
    test.RemoveFunc("b");
    test.Invoke(15);
}

三个 tag 分别为 "a", "b", "c" 的匿名函数 (参数为 int,注意实例化 BtMulticast 时的类型参数列表变化) 被注册进来,然后 tag 为 "b" 的匿名函数被移除,最后以 15 作为参数依次调用剩下的回调函数 ("a" 和 "c")。

// testing multicast with multiple parameters and return value list
{
    BtMulticast<int, int, int> testRet;
    testRet.AddFunc("a", [](int p1, int p2) -> int { BT_LOG("Multicast (with RetVal): func a called (p1: %d, p2: %d). ", p1, p2); return p1 + 1 * p2; });
    testRet.AddFunc("b", [](int p1, int p2) -> int { BT_LOG("Multicast (with RetVal): func b called (p1: %d, p2: %d). ", p1, p2); return p1 + 2 * p2; });
    testRet.AddFunc("c", [](int p1, int p2) -> int { BT_LOG("Multicast (with RetVal): func c called (p1: %d, p2: %d). ", p1, p2); return p1 + 3 * p2; });
    testRet.RemoveFunc("b");

    for (auto& p : testRet.InvokeR(20, 2))
        BT_LOG("Multicast (with RetVal): func %s returned %d. ", p.second, p.first);
}

最后这个用例测试了多个参数和返回值的情况,可以看到 "a", "b", "c" 做了不同的操作后,返回的值被采集到了一个返回值列表里面,这个列表被就地 (即所谓的 move 语意) 遍历,内部的值可以根据需要再进行处理。这个用例的运行结果如下:

Multicast (with RetVal): func a called (p1: 20, p2: 2). 
Multicast (with RetVal): func c called (p1: 20, p2: 2). 
Multicast (with RetVal): func a returned 22. 
Multicast (with RetVal): func c returned 26. 

可以看到 BtMulticast 能够适配任意个数和类型的参数,因此可认为具有一定的通用性。


实现

最后我们简单看一下实现。先看看 BtMulticast::AddFunc()

template <typename TRet, class... TArgs>
bool BtMulticast<TRet, TArgs...>::AddFunc(const std::string& tag, TFunc func)
{
    // check if this tag has been used
    if (tag.size())
    {
        auto it = std::find_if(m_funcList.begin(), m_funcList.end(), 
            [&tag](const TElem& elem) { return elem.second == tag; });
        if (it != m_funcList.end())
            return false;
    }

    m_funcList.emplace_back(func, tag);
    return true;
}

当 tag 有效时,先判定是否有 tag 冲突,然后注册一下回调,过程很直白就不多说了。


再看一下具体的调用过程 BtMulticast::InvokeR()

/* ----- Note ----- 
    `BtMulticastRetVect` is an extra alias especially for the returning type for the signature of InvokeR() below,
    since `TRetVect` defined inside `BtMulticast` cannot be used in the signature (outside the function body)
    although `BtMulticastRetVect` is defined separately, it literally equals to `typename BtMulticast::TRetVect`
*/
template <typename TRet>
using BtMulticastRetVect = std::vector < std::pair < TRet, std::string > > ;

template <typename TRet, class... TArgs>
template <class... U>
BtMulticastRetVect<TRet> BtMulticast<TRet, TArgs...>::InvokeR(U&&... u)
{
    BtMulticastRetVect<TRet> ret;
    for (auto& p : m_funcList)
    {
        ret.emplace_back(p.first(std::forward<U>(u)...), p.second);
    }
    return ret;
}

这里可以看到我单独定义了一下返回值的类型,具体原因见注释,大体上是说类内定义的类型 TRetVect 只能在类内使用 (包括类定义及相关的成员函数体的定义,成员函数的签名不算在内)。另外这函数前面的两个 template 声明分别是类的模板和函数的模板。


俺一直觉得 C++ 的模板声明挺啰嗦,很有孔乙己范儿,看了上面这个函数声明,你也一定深有同感罢。应该跟 D 学一下,简化一下。

C++ 的 typedefclass template

typedef double A;

template<class T> struct B
{
    typedef int A;
};

D 的对应语法 alias 和模板的 (T) 语法,简洁到没朋友。

alias A = double;

class B(T)
{
    alias A = int;
}

不过 C++ 已经把 D 的 alias 关键字的用法学来了,翻到前面可以看到 class BtMulticast 的定义中的那一组 using,把 alias 抄了个十足十,啧啧,借鉴得不错。


BtMulticast 类的实现和测试用例代码见这里


[完]
Gu Lu
[2015-07-22]


[2015-07-23] 补:代码中的 .push_back(std::make_pair(func, tag))
已替换为更紧凑的 .emplace_back(func, tag)。段落标题也为了清晰起见重新做了划分。


[2015-07-24] 补:有同学反映

函数名不够 fasion,至少把 Invoke 调用弄成 operator() 也行吧。

这个原因很简单,就像 C++ 把 C 语言的强制转型的语法 (Foo *)pObject 给扩展成 dynamic_cast<Foo *>(pObject) 一样,仅仅是为了可读性和易搜索性,InvokeInvokeR 不仅给了这两个函数独特的符号,而且也很容易在批量查找时区分。嗯,朴素一点也挺好。


另:本文遵循 Creative Commons BY-NC-ND 4.0 许可协议。

知识共享许可协议
本作品由Gu Lu创作,采用知识共享Attribution-NonCommercial-NoDerivatives 4.0 国际许可协议进行许可。