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

Comments
Write a Comment

Tags

游戏开发   随笔   Programming   C/C++   优化   Unity   C++   知乎   游戏设计   比特币   Unity3D   区块链   软件开发   引擎设计   系统架构   Production   idtech   中国文化   加密货币   项目管理   Bitcoin   游戏评论   资源管理   资源流水线   效率   道德经   网络   方法论   模板编程   Lua   Oculus   GDC   渲染   VR   PerfAssist   Unity MemoryProfiler   BCH   经济学   信息过载   行业报告   字体   Productivity   图形   网络编程   Dice   协程   EMC   Premake   测试   中间件   Game Engine   新手引导   Methodology   CI   命令行解析   goroutine   ndk   Ethereum   nanomsg   自动化   Scripting   摘录   Debugging   同步技术   cppcon   C++模板   DOOM3   技术评估   Unity GC   C++11   学习方法   Surface Pro 3   Engine Evaluation   CRT   文化   Blockchain   笔记   golang   图形编程   多线程   ETH   Blockchain Computing   Bitcoin Cash   cppcon14   Bitcoin SV   Visual Studio   Unity Coroutine   跨语言可变参数列表   团队协作   货币   Deployment   Visual Assist   工程改进   Michael Abrash   exp   开放世界   域名   虚拟现实   系统重构   slua   遮挡剔除   完美转发   协作式调度   Modern C++   类型推导   Memory Debugging   个人成长   小故事   BTC   暴雪   产品   历史   错误处理   Unity Profiler   MOD  

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