34. 如何实现一个支持任意参数转发的委托/回调系统?

这个问题,说白了就是:你写了一个回调容器,怎么让调用方传任意类型、任意数量的参数进去,还能完美转发到真正的处理函数上?

我在项目中遇到过好几次这种需求。最典型的是事件总线——你注册一个回调,它可能接受 int,也可能接受 string,甚至可能接受一个自定义结构体。如果每个回调都写死参数类型,那代码就没法看了。

核心思路:std::function + 可变参数模板 + 完美转发

实现一个通用的委托系统,需要三样东西:

  • std::function:存储可调用对象
  • 可变参数模板:接受任意数量和类型的参数
  • std::forward:保持参数的左值/右值属性

嗯,这里要注意:std::function 本身已经支持了类型擦除,但它需要你在模板参数里声明签名。我们要做的,就是把这个签名也变成模板参数的一部分。

基础实现:一个简单的委托类

#include <functional>
#include <memory>
#include <vector>

template<typename... Args>
class Delegate {
public:
    using Callback = std::function<void(Args...)>;

    // 注册回调
    template<typename F>
    void connect(F&& f) {
        callbacks_.emplace_back(std::forward<F>(f));
    }

    // 触发所有回调,完美转发参数
    template<typename... Ts>
    void invoke(Ts&&... args) {
        for (auto& cb : callbacks_) {
            cb(std::forward<Ts>(args)...);
        }
    }

    // 清空
    void clear() { callbacks_.clear(); }

private:
    std::vector<Callback> callbacks_;
};

你看,invoke 里用了 std::forward<Ts>(args)...,这就是完美转发。不管调用方传的是左值还是右值,都能原样传递到回调函数里。

关键点Delegate<int, std::string> 表示这个委托接受两个参数:int 和 string。但 invoke 的模板参数 Ts 是独立推导的,所以可以完美转发。

使用示例

void foo(int x, const std::string& s) {
    std::cout << x << " " << s << std::endl;
}

struct Bar {
    void operator()(int x, std::string s) const {
        std::cout << "Bar: " << x << " " << s << std::endl;
    }
};

int main() {
    Delegate<int, std::string> delegate;

    // 注册普通函数
    delegate.connect(foo);

    // 注册 lambda
    delegate.connect([](int a, const std::string& b) {
        std::cout << "lambda: " << a << " " << b << std::endl;
    });

    // 注册函数对象
    Bar bar;
    delegate.connect(bar);

    // 触发所有回调
    delegate.invoke(42, "hello");
}

输出结果:

42 hello
lambda: 42 hello
Bar: 42 hello

进阶:支持返回值

有时候你需要回调返回结果。比如一个事件过滤器,回调返回 true 表示事件已处理,false 表示继续传递。

template<typename R, typename... Args>
class DelegateWithReturn {
public:
    using Callback = std::function<R(Args...)>;

    template<typename F>
    void connect(F&& f) {
        callbacks_.emplace_back(std::forward<F>(f));
    }

    // 返回最后一个回调的结果
    template<typename... Ts>
    R invoke(Ts&&... args) {
        R result{};
        for (auto& cb : callbacks_) {
            result = cb(std::forward<Ts>(args)...);
        }
        return result;
    }

private:
    std::vector<Callback> callbacks_;
};

小技巧:如果你需要收集所有返回值,可以改成 std::vector<R> 返回。我个人习惯用 std::optional<R> 表示「至少有一个回调返回了有效值」。

再进阶:支持任意参数类型的委托容器

上面那个 Delegate<int, std::string> 是固定签名的。如果你想要一个容器,里面可以放不同签名的委托呢?

这就需要用类型擦除了。我常用的做法是:

class AnyDelegate {
public:
    template<typename... Args>
    void connect(std::function<void(Args...)> f) {
        // 用 lambda 捕获函数,擦除参数类型
        callbacks_.emplace_back([f = std::move(f)](const std::any&... args) {
            // 这里需要把 any 转回原始类型
            // 但实际项目中,我建议用 variant 或自定义类型标签
        });
    }

    template<typename... Args>
    void invoke(Args&&... args) {
        for (auto& cb : callbacks_) {
            cb(std::forward<Args>(args)...);
        }
    }

private:
    std::vector<std::function<void(const std::any&...)>> callbacks_;
};

注意:用 std::any 做类型擦除有性能开销,而且类型转换容易出错。我曾经在一个项目里这么干过,结果调试了整整两天,最后发现是 any_cast 的类型对不上。后来我改用 std::variant + 访问者模式,才彻底解决。

实战中的避坑指南

我曾经在写一个游戏引擎的事件系统时,踩过几个坑:

  1. 生命周期问题:回调里捕获了 this 指针,但对象已经析构了。解决方案是用 std::weak_ptr 或智能指针管理回调的上下文。
  2. 递归调用:回调里又触发了同一个委托,导致无限递归。我加了一个递归深度计数器,超过阈值就抛异常。
  3. 移动语义的坑:如果回调参数是 std::unique_ptr,只能被移动一次。多个回调共享同一个 unique_ptr 会编译失败。这时候需要用 std::shared_ptr 或者重新设计接口。

知识体系图

委托/回调系统核心架构 Delegate<Args...> 模板定义 std::function 存储 可变参数模板 std::forward 完美转发 std::vector<Callback> 存储多个回调函数 template<typename... Ts> 参数类型自动推导 std::forward<Ts>(args)... 保持左值/右值属性 invoke(args...) 触发所有回调

总结

实现一个支持任意参数转发的委托系统,核心就是三句话:

  • std::function 做类型擦除,存储可调用对象
  • 用可变参数模板接受任意参数
  • std::forward 保持参数的原始类型

嗯,其实标准库里的 std::bindstd::thread 也是类似的思路。你想想看,std::thread 的构造函数不就是完美转发的典型应用吗?

我个人建议,在实际项目中,如果只是简单的回调,直接用 std::function 就够了。但如果需要复杂的多播委托、生命周期管理、异步调用,那还是得自己封装一层。毕竟,标准库只提供了基础组件,真正的架构设计还是要靠我们自己。

一句话总结:完美转发让委托系统既能保持类型安全,又能零开销地传递任意参数。这是 C++11 之后写回调系统的标准姿势。


公众号:蓝海资料掘金营,微信deep3321