C++11可变参数模板、Lambda表达式与包装器:现代泛型编程实战解析
1. 从“硬编码”到“柔性设计”:C++11新特性的范式转变
如果你写过一些C++项目,尤其是需要处理多种数据类型、实现回调或者构建泛型库的时候,大概率会怀念过其他语言里的一些“语法糖”。比如,Java里可以轻松地写个
void func(Object... args)
来处理任意个数和类型的参数;Python里一个
lambda x: x*2
就能随手定义一个匿名函数,用起来行云流水。但在C++11之前,这些场景往往意味着要写一堆重载函数、仿函数(Functor)类,或者借助不那么安全的宏,代码显得臃肿且不够直观。
C++11带来的
可变参数模板(Variadic Templates)
、
lambda表达式
和
包装器(如
std::function
,
std::bind
)
,正是为了解决这些“不优雅”的问题。它们不是孤立的语法点,而是一套组合拳,共同推动C++从“面向对象+模板元编程”的混合范式,向更现代、更声明式的“泛型+函数式”风格演进。简单来说,它们让C++在保持零开销抽象和高性能的同时,写起来更接近程序员直觉,极大地提升了代码的表达力和可维护性。
这篇文章不会仅仅罗列语法规则,那是手册干的事。我会结合我这些年做基础库、游戏引擎和网络框架的实际经验,带你深入这三个特性的核心应用场景,拆解它们如何协同工作,以及在实际编码中那些手册里不会写的“坑”和“最佳实践”。你会发现,用好它们,你的代码会从“能跑”变得“优雅且高效”。
2. 可变参数模板:解锁泛型编程的终极形态
在C++98/03时代,模板已经很强大,但它有个硬伤:模板参数的数量必须是固定的。如果你想写一个能打印任意数量参数的函数,要么写N个重载(
print(a)
,
print(a,b)
,
print(a,b,c)...
),要么求助于C风格的可变参数
va_list
,后者类型不安全,对非POD类型更是灾难。
可变参数模板的出现,彻底打破了这一限制。它的核心思想是“参数包”(Parameter Pack),允许模板接受任意数量、任意类型(需满足模板约束)的参数。
2.1 基础语法与递归展开:理解“包”的运作机制
一个可变参数模板的声明看起来是这样的:
template<typename... Args>
void print(Args... args) {
// ... 如何访问args?
}
这里的
typename... Args
定义了一个模板参数包,
Args... args
定义了一个函数参数包。但你不能直接操作这个“包”,必须通过“展开”(Pack Expansion)来使用它。
最经典、也是最易于理解的使用模式是 递归展开 。我们通常需要一个递归终止函数(边界情况)和一个递归处理函数。
// 1. 递归终止函数:当参数包为空时调用
void print() {
std::cout << std::endl;
}
// 2. 递归处理函数:每次处理一个参数,剩下的继续递归
template<typename T, typename... Rest>
void print(T first, Rest... rest) {
std::cout << first << " ";
print(rest...); // 递归展开参数包 `rest`
}
// 使用
print(1, 3.14, "hello", 'A'); // 输出:1 3.14 hello A
它是如何工作的?
-
调用
print(1, 3.14, "hello", 'A'),匹配到第二个模板函数。T被推导为int,first是1,Rest...是<double, const char*, char>,rest...是(3.14, "hello", 'A')。 -
输出
1和一个空格。 -
执行
print(rest...),即print(3.14, "hello", 'A')。这是一个新的函数调用,T被推导为double,以此类推。 -
当最后调用
print('A')时,Rest...为空包,rest...为空。函数体内执行print(),这会匹配到第一个无参数的print()函数,输出换行并结束递归。
注意: 递归终止函数 必须 在递归处理函数之前声明或定义,否则编译器在尝试匹配空参数调用时可能找不到合适的函数,导致编译错误。这是新手常踩的坑。
2.2 折叠表达式:更简洁的现代展开方式
C++17引入了 折叠表达式(Fold Expressions) ,它让很多可变参数模板的操作变得异常简洁,无需再写递归。虽然标题是C++11,但了解这个进化形态至关重要。
// C++17 折叠表达式实现print
template<typename... Args>
void print(Args... args) {
(std::cout << ... << args) << std::endl; // 二元左折叠
}
这一行代码就完成了之前递归版本的所有功能。
(std::cout << ... << args)
是一个左折叠表达式,它等价于
((((std::cout << arg1) << arg2) << arg3) ...)
。
折叠表达式不仅限于输出,它可以用于任何二元运算符。
// 计算所有参数的和
template<typename... Args>
auto sum(Args... args) {
return (... + args); // 二元左折叠,等价于 (((arg1 + arg2) + arg3) + ...)
}
auto total = sum(1, 2, 3, 4, 5); // total = 15
// 判断所有参数是否都为true (逻辑与)
template<typename... Args>
bool all_true(Args... args) {
return (... && args); // 二元左折叠
}
bool ok = all_true(true, true, false, true); // ok = false
为什么折叠表达式更好?
- 代码极简 :省去了递归终止函数和复杂的模板推导。
- 编译期优化更友好 :对于编译器来说,折叠表达式通常能生成更直接、更高效的代码。
- 意图更清晰 :一眼就能看出这是在做一个“累积”操作。
实操心得: 如果你的项目支持C++17或更高标准,对于求和、求积、逻辑运算、逗号操作等场景,应优先使用折叠表达式。对于更复杂的、非线性的参数包处理(例如需要根据参数类型进行差异化处理),递归展开仍然是必要的。
2.3 实战场景:完美转发与
emplace
系列函数
可变参数模板最强大的应用场景之一是
完美转发(Perfect Forwarding)
,它与右值引用结合,构成了现代C++高效资源管理的基石。典型代表就是标准库容器
emplace_back
、
emplace
等方法。
假设我们有一个
Person
类:
class Person {
public:
Person(const std::string& name, int age) : name_(name), age_(age) {
std::cout << "拷贝字符串构造\n";
}
Person(std::string&& name, int age) : name_(std::move(name)), age_(age) {
std::cout << "移动字符串构造\n";
}
private:
std::string name_;
int age_;
};
在C++11之前,向
vector<Person>
添加对象,通常需要先构造一个临时对象,再拷贝或移动到容器中:
std::vector<Person> persons;
persons.push_back(Person("Alice", 30)); // 1. 构造临时Person,2. 移动(或拷贝)到vector
emplace_back
利用可变参数模板和完美转发,
直接在容器内存中构造对象
,避免了临时对象的创建和一次额外的移动/拷贝操作。
persons.emplace_back("Alice", 30); // 直接在vector分配的内存中,用("Alice", 30)构造Person
它的实现原理简化如下:
template<class... Args>
void emplace_back(Args&&... args) { // 通用引用(Universal Reference)接收任意参数
// ... 检查容量,必要时扩容
// 在预先分配好的内存地址`new_element_ptr`处,直接构造对象
::new (new_element_ptr) Person(std::forward<Args>(args)...);
}
-
Args&&... args:这是一个“转发引用包”,它能捕获传入的所有参数,并保持其值类别(左值/右值)。 -
std::forward<Args>(args)...:这是参数包展开与完美转发的结合。它对包中的每个参数arg_i执行std::forward<Arg_i>(arg_i),将参数原封不动地(保持值类别)传递给Person的构造函数。
这就是可变参数模板的威力所在
:
emplace_back
的签名可以接受与
Person
构造函数完全一致的任意数量、任意类型的参数,并高效地将它们转发给构造函数。你几乎可以为任何需要灵活构造的场景设计类似的接口。
踩坑提醒:
emplace系列函数虽然高效,但需谨慎使用。对于像vector<int>这样的简单类型,push_back和emplace_back性能差异微乎其微。更重要的是,emplace函数可能会引发一些意想不到的行为,例如,v.emplace_back(v[0])如果导致扩容,v[0]可能会成为一个悬空引用,引发未定义行为。而push_back(v[0])则先进行拷贝,相对安全。所以,规则是:对于构造开销大的对象,使用emplace;对于简单类型或可能涉及容器内元素引用的场景,使用push_back更稳妥。
3. Lambda表达式:让函数“就地”定义
在lambda出现之前,如果你想在算法中定制一个简单的比较或操作逻辑,要么写一个全局函数(污染命名空间),要么定义一个仿函数类(代码冗长)。比如用
std::sort
对自定义结构体排序:
struct Item { int id; std::string name; };
bool compareById(const Item& a, const Item& b) { return a.id < b.id; }
std::vector<Item> items;
std::sort(items.begin(), items.end(), compareById);
或者用仿函数:
struct CompareByName {
bool operator()(const Item& a, const Item& b) const {
return a.name < b.name;
}
};
std::sort(items.begin(), items.end(), CompareByName());
lambda表达式让你可以像定义变量一样,在需要的地方直接定义一个匿名函数对象,代码紧凑,意图清晰。
3.1 语法全解与捕获列表的奥秘
Lambda表达式的基本语法是:
[捕获列表] (参数列表) -> 返回类型 { 函数体 }
。其中,返回类型和参数列表在某些情况下可以省略。
// 一个简单的lambda,接受两个int,返回它们的和
auto add = [](int a, int b) -> int { return a + b; };
std::cout << add(3, 4) << std::endl; // 输出 7
// 返回类型可省略(编译器可推导)
auto multiply = [](int a, int b) { return a * b; };
// 参数列表为空可省略
auto sayHello = [] { std::cout << "Hello, Lambda!\n"; };
核心在于捕获列表
[ ]
,它决定了lambda函数体内部如何访问外部作用域的变量。
-
值捕获
[=]:捕获所有外部变量的 副本 。在lambda内部修改这些副本不影响外部变量。int x = 10; auto func = [=]() { std::cout << x << std::endl; }; // 捕获x的副本 x = 20; func(); // 输出 10,因为捕获的是之前的副本 -
引用捕获
[&]:捕获所有外部变量的 引用 。在lambda内部修改会影响外部变量。int x = 10; auto func = [&]() { x = 30; }; func(); std::cout << x << std::endl; // 输出 30 -
混合捕获与显式捕获
:可以指定捕获哪些变量,以及以何种方式捕获。
int a = 1, b = 2, c = 3; auto f1 = [a, &b]() { /* 值捕获a,引用捕获b,不能访问c */ }; auto f2 = [=, &c]() { /* 值捕获所有(a,b),但c是引用捕获 */ }; auto f3 = [&, a]() { /* 引用捕获所有(b,c),但a是值捕获 */ }; -
初始化捕获(C++14)
:允许在捕获时对变量进行移动或初始化,非常强大。
std::unique_ptr<Resource> ptr = std::make_unique<Resource>(); // 将ptr移动捕获到lambda内部的`my_ptr`中,外部ptr变为nullptr auto task = [my_ptr = std::move(ptr)]() { my_ptr->doSomething(); };
重要经验:默认捕获的陷阱 。
[=]和[&]虽然方便,但容易导致问题。[&]可能导致悬空引用(如果lambda被传递到比捕获变量生命周期更长的上下文中)。[=]在C++11中对于成员变量有误导性:它实际上捕获的是this指针(隐式值捕获this),而非成员变量的副本。这意味着通过[=]捕获的成员变量,其修改仍然会影响原对象,并且如果this指针失效,访问成员变量就是未定义行为。 最佳实践是:尽量避免使用默认捕获,总是显式列出需要捕获的变量及其方式。
3.2 Lambda的本质:编译器生成的匿名类
理解lambda的关键在于, 它不是一个魔法函数,而是一个语法糖,编译器会为它生成一个独一无二的匿名类(闭包类型) 。
对于lambda
auto f = [x](int y) { return x + y; };
,编译器大致会生成如下代码:
class __SomeUniqueName {
private:
int x; // 值捕获的变量成为成员变量
public:
__SomeUniqueName(int captured_x) : x(captured_x) {} // 构造函数初始化捕获的变量
// 重载函数调用运算符,这就是lambda的函数体
int operator()(int y) const { // 注意:默认是const的!
return x + y;
}
};
auto f = __SomeUniqueName(x); // 实例化这个匿名类对象
这解释了:
- 为什么lambda可以像对象一样传递和存储 :因为它本来就是个对象。
- 捕获列表的作用 :决定了这个匿名类有哪些成员变量,以及如何初始化它们。
-
mutable关键字的作用 :默认情况下,operator()是const的,这意味着你不能修改值捕获的变量。加上mutable后,operator()变为非const,你就可以修改那些副本了。int counter = 0; auto f = [counter]() mutable { ++counter; std::cout << counter; }; f(); // 输出1,修改的是内部的副本 f(); // 输出2 std::cout << counter; // 输出0,外部的counter没变
3.3 在STL算法与异步编程中的典型应用
Lambda与STL算法是天作之合,极大地提升了代码的简洁性和表现力。
std::vector<int> nums = {1, 5, 3, 4, 2};
// 1. 排序:降序排列
std::sort(nums.begin(), nums.end(), [](int a, int b) { return a > b; });
// 2. 查找:找到第一个大于3的元素
auto it = std::find_if(nums.begin(), nums.end(), [](int n) { return n > 3; });
// 3. 变换:将所有元素平方
std::vector<int> squares;
std::transform(nums.begin(), nums.end(), std::back_inserter(squares),
[](int n) { return n * n; });
// 4. 删除-擦除惯用法:删除所有偶数
nums.erase(std::remove_if(nums.begin(), nums.end(),
[](int n) { return n % 2 == 0; }),
nums.end());
在异步编程中,lambda常用于定义任务(Task)或回调函数(Callback),特别是在
std::thread
、
std::async
或事件驱动框架中。
#include <thread>
#include <future>
void processData(const std::vector<int>& data) {
// 使用async异步执行一个计算密集任务,lambda定义任务内容
std::future<int> result = std::async(std::launch::async, [&data]() {
int sum = 0;
for (int num : data) sum += num;
return sum; // 返回值会被future获取
});
// ... 主线程可以同时做其他事情 ...
int total = result.get(); // 等待并获取异步任务结果
std::cout << "Sum: " << total << std::endl;
}
注意异步编程中的捕获 :上例中使用了引用捕获
[&data]。这要求确保data的生命周期至少持续到异步任务完成。如果data是局部变量且可能在任务执行前销毁,就会导致悬空引用。在这种情况下,使用值捕获[data](拷贝一份数据)通常是更安全的选择,尽管可能有拷贝开销。
4. 包装器:统一可调用对象的“外交官”
C++中有多种可调用对象:普通函数、函数指针、成员函数指针、lambda表达式、仿函数。它们的类型各不相同,导致我们很难用一种统一的类型来存储或传递它们。例如,你想设计一个事件系统,允许用户注册任意类型的回调函数,你怎么定义这个回调容器的类型?
这就是
std::function
和
std::bind
出场的时候。它们扮演了“包装器”和“适配器”的角色。
4.1
std::function
:类型擦除的通用容器
std::function
是一个类模板,它可以存储、复制和调用任何满足其签名要求的可调用对象。这是一种“类型擦除”技术。
#include <functional>
#include <iostream>
#include <vector>
int add(int a, int b) { return a + b; }
struct Multiply {
int operator()(int a, int b) const { return a * b; }
};
int main() {
// 1. 包装普通函数
std::function<int(int, int)> func1 = add;
std::cout << func1(2, 3) << std::endl; // 5
// 2. 包装lambda表达式
auto lambda = [](int a, int b) { return a - b; };
std::function<int(int, int)> func2 = lambda;
std::cout << func2(5, 3) << std::endl; // 2
// 3. 包装仿函数对象
Multiply mult;
std::function<int(int, int)> func3 = mult;
std::cout << func3(2, 3) << std::endl; // 6
// 4. 统一存储与调用:事件回调系统的雏形
std::vector<std::function<void()>> tasks;
tasks.push_back([]() { std::cout << "Task 1\n"; });
tasks.push_back([]() { std::cout << "Task 2\n"; });
for (const auto& task : tasks) {
task(); // 统一调用,无需关心具体类型
}
return 0;
}
std::function
的签名
:
std::function<int(int, int)>
表示一个可调用对象,它接受两个
int
参数,并返回一个
int
。只要可调用对象的调用形式与之匹配,就可以被包装进来。
性能与开销
:
std::function
使用了小对象优化和类型擦除,通常会有一些运行时开销(虚函数调用、动态内存分配的可能性)。在极度性能敏感的循环内部,直接使用函数指针或lambda可能更快。但对于回调注册、事件处理、策略模式等场景,其带来的抽象和便利性远大于微小的性能损失。
4.2
std::bind
:参数绑定与函数适配器
std::bind
用于生成一个新的可调用对象,它通过“绑定”部分参数,或者调整参数顺序,将一个已有的可调用对象适配成新的调用形式。在C++11时代,它非常有用,尽管在C++14之后,lambda的捕获初始化很多时候可以替代它。
基本用法:参数绑定
#include <functional>
using namespace std::placeholders; // 对于 _1, _2, ...
void printSum(int a, int b, int c) {
std::cout << a + b + c << std::endl;
}
int main() {
// 将printSum的第三个参数绑定为10,生成一个新的二元函数对象
auto f = std::bind(printSum, _1, _2, 10);
f(1, 2); // 等价于 printSum(1, 2, 10),输出13
// 调整参数顺序:将第二个和第一个参数交换
auto g = std::bind(printSum, _2, _1, 100);
g(5, 10); // 等价于 printSum(10, 5, 100),输出115
return 0;
}
这里的
_1
、
_2
是占位符,分别表示新生成的可调用对象的第一、第二个参数。
绑定成员函数
std::bind
一个非常重要的用途是绑定非静态成员函数,因为成员函数需要一个对象来调用。
class Worker {
public:
void doWork(int intensity, const std::string& task) {
std::cout << "Working on " << task << " with intensity " << intensity << std::endl;
}
};
int main() {
Worker w;
// 绑定成员函数,需要传递一个对象(或指针/引用)作为第一个参数
auto task = std::bind(&Worker::doWork, &w, 5, _1); // 将intensity绑定为5
task("coding"); // 等价于 w.doWork(5, "coding")
return 0;
}
std::bind
vs Lambda
随着lambda表达式的完善,很多
std::bind
的场景可以用lambda更清晰地表达。
// 用std::bind
auto f_bind = std::bind(printSum, _1, _2, 10);
// 用lambda实现相同功能
auto f_lambda = [](int a, int b) { return printSum(a, b, 10); };
lambda的优势在于:
- 语法更直观 ,一眼就能看出参数和操作。
- 内联定义 ,逻辑更集中。
-
对于捕获变量,lambda的初始化捕获(C++14)比
std::bind更强大和清晰。
因此,
现代C++(C++14/17/20)的通用建议是:优先使用lambda,除非你需要
std::bind
提供的特定功能,比如在编译时(模板元编程中)进行复杂的参数重排和组合,或者在一些旧的、尚未升级的代码库中维护兼容性。
4.3 组合使用:构建灵活的回调机制
在实际项目中,这三者常常结合使用,构建出非常灵活和强大的抽象。例如,一个简单的线程池任务队列:
#include <functional>
#include <queue>
#include <thread>
#include <vector>
#include <iostream>
class ThreadPool {
public:
using Task = std::function<void()>; // 使用std::function定义统一的任务类型
ThreadPool(size_t numThreads) {
for (size_t i = 0; i < numThreads; ++i) {
workers_.emplace_back([this] { // Lambda作为线程执行体
while (true) {
Task task;
{
std::unique_lock<std::mutex> lock(queueMutex_);
condition_.wait(lock, [this] { return !tasks_.empty() || stop_; });
if (stop_ && tasks_.empty()) return;
task = std::move(tasks_.front());
tasks_.pop();
}
task(); // 执行从队列中取出的任务
}
});
}
}
template<typename F, typename... Args>
void enqueue(F&& f, Args&&... args) {
// 使用std::bind和完美转发,将任意可调用对象和其参数打包成一个无参的Task
auto task = std::bind(std::forward<F>(f), std::forward<Args>(args)...);
{
std::lock_guard<std::mutex> lock(queueMutex_);
tasks_.emplace(std::move(task));
}
condition_.notify_one();
}
~ThreadPool() { /* ... 清理逻辑 ... */ }
private:
std::vector<std::thread> workers_;
std::queue<Task> tasks_;
std::mutex queueMutex_;
std::condition_variable condition_;
bool stop_ = false;
};
// 使用示例
void printMessage(const std::string& msg, int times) {
for (int i = 0; i < times; ++i) {
std::cout << msg << std::endl;
}
}
int main() {
ThreadPool pool(4);
// 提交一个任务:使用lambda
pool.enqueue([]() { std::cout << "Hello from lambda!\n"; });
// 提交一个任务:使用普通函数和参数
pool.enqueue(printMessage, "Hello from function!", 3);
// 主线程等待一段时间,让线程池完成任务
std::this_thread::sleep_for(std::chrono::seconds(1));
return 0;
}
在这个例子中:
-
std::function<void()>定义了统一的任务接口。 -
Lambda
被用作工作线程的执行体,并捕获了
this指针以访问任务队列。 -
std::bind在enqueue方法中,将用户传入的任意可调用对象f和其参数args...打包(完美转发)成一个符合Task签名(无参无返回值)的可调用对象。这里用std::bind是合适的,因为它需要在编译时处理泛型的参数包Args...,并将其适配到固定的签名上。
5. 深入陷阱与性能考量
掌握了基本用法后,要写出稳健高效的代码,还必须了解这些特性背后的陷阱。
5.1 可变参数模板的编译期递归深度限制
递归展开可变参数模板会在编译期生成大量的函数实例。大多数编译器都有一个默认的递归实例化深度限制(例如,MSVC默认约500,GCC/Clang默认约900)。如果你的参数包非常大,可能会触发这个限制,导致编译错误。
解决方案:
- 使用折叠表达式(C++17) :这是最根本的解决之道,它避免了递归。
-
增加编译器限制
:可以通过编译选项提高限制(如GCC的
-ftemplate-depth=N),但这只是权宜之计。 - 手动分批处理 :设计算法,将大的参数包分成小块处理。
5.2 Lambda的悬空引用与
this
指针陷阱
这是Lambda捕获中最危险的部分。
class BadExample {
public:
std::function<void()> getCallback() {
int localVar = 42;
// 危险!捕获了局部变量localVar的引用
return [&localVar]() { std::cout << localVar; };
// localVar在函数返回后销毁,回调里的引用悬空!
}
void registerHandler() {
// 危险!默认值捕获[=]捕获的是this指针,而非成员副本
auto handler = [=]() { this->process(); /* 如果this已销毁... */ };
// 如果将handler存储起来并在对象销毁后调用,将导致未定义行为。
}
};
安全准则:
- 对于局部变量,如果lambda的生命周期可能超过该变量, 永远不要使用引用捕获 ,改用值捕获。
-
对于成员变量的访问,如果需要值语义,在C++14及以上使用初始化捕获来显式拷贝成员,或者传递
*this(C++17)进行值捕获。// C++14 初始化捕获成员变量 auto handler = [data = this->data_]() { /* 使用data副本 */ }; // C++17 捕获*this auto handler = [*this]() { /* 安全地访问所有成员的副本 */ };
5.3
std::function
的开销与替代方案
std::function
的调用通常涉及一次间接调用(通过内部指针),可能还有一次动态内存分配(如果捕获的可调用对象太大,无法放入其内部的小缓冲区)。在需要极致性能的场合(例如,在每帧调用成千上万次的游戏主循环或高频交易系统中),可以考虑:
- 使用函数指针 :如果回调类型固定且简单。
-
使用模板参数
:将可调用对象类型作为模板参数,这样调用是内联的,零开销。
template<typename Callback> void fastForEach(Callback cb) { for (int i = 0; i < 1000000; ++i) cb(i); } fastForEach([](int i) { /* ... */ }); // Callback类型在编译时确定,可能被内联 -
使用
function_ref提案或类似库 (如folly::FunctionRef,llvm::function_ref):这是一种非拥有的、轻量级的回调类型,类似于std::function的视图,没有所有权语义,开销更低。
5.4 类型推导与
auto
的配合
Lambda和可变参数模板经常与
auto
关键字一起使用,以实现泛型编程。但需要注意
auto
的推导规则。
// auto 推导lambda的类型为唯一的匿名闭包类型
auto lambda = [](auto x, auto y) { return x + y; }; // C++14 泛型lambda
// lambda的类型是编译器生成的某个特定类型,你可以用decltype(lambda)获取它。
// 在可变参数模板中,auto... 用于声明泛型参数包 (C++17 折叠表达式常用)
template<typename... Args>
auto sum(Args... args) {
return (... + args);
}
泛型Lambda(C++14)实际上是一个模板化的
operator()
,它让lambda变得更加强大,可以处理任意类型的参数。
6. 综合案例:实现一个泛型的事件总线
最后,我们用一个综合案例来串联这三个特性:实现一个简单但功能完整的 泛型事件总线(Event Bus) 。它允许组件订阅特定类型的事件,并在事件发布时通知所有订阅者。
#include <functional>
#include <unordered_map>
#include <vector>
#include <memory>
#include <iostream>
// 前向声明
class EventBus;
// 事件接收器基类(用于管理生命周期)
class EventReceiverBase {
public:
virtual ~EventReceiverBase() = default;
};
// 事件总线类
class EventBus {
private:
// 使用std::function存储任意类型的回调
using EventHandler = std::function<void(const void*)>;
// 存储某一类型事件的所有处理器
std::unordered_map<size_t, std::vector<EventHandler>> handlers_;
// 存储接收器,确保它们在事件总线之前销毁
std::vector<std::shared_ptr<EventReceiverBase>> receivers_;
public:
// 订阅事件:T是事件类型,Callback是任意可调用对象
template<typename T, typename Callback>
std::shared_ptr<EventReceiverBase> subscribe(Callback&& cb) {
// 计算事件类型的哈希值作为键
size_t eventType = typeid(T).hash_code();
// 将用户回调包装成统一的事件处理器
// 这里用lambda捕获回调,并完成类型转换
EventHandler handler = [callback = std::forward<Callback>(cb)](const void* eventData) {
// 将void*转换回具体的事件类型,并调用用户回调
callback(*static_cast<const T*>(eventData));
};
handlers_[eventType].push_back(std::move(handler));
// 创建并返回一个接收器(本例中简化处理,实际可能更复杂)
auto receiver = std::make_shared<EventReceiverBase>();
receivers_.push_back(receiver);
return receiver;
}
// 发布事件:使用可变参数模板完美转发事件构造参数
template<typename T, typename... Args>
void publish(Args&&... args) {
size_t eventType = typeid(T).hash_code();
auto it = handlers_.find(eventType);
if (it == handlers_.end()) return;
// 就地构造事件对象,避免一次拷贝
T event(std::forward<Args>(args)...);
// 通知所有订阅者
for (const auto& handler : it->second) {
handler(&event); // 传递事件对象的地址
}
}
};
// --- 使用示例 ---
struct PlayerJoinedEvent {
std::string playerName;
int playerId;
};
struct ChatMessageEvent {
std::string from;
std::string message;
};
int main() {
EventBus bus;
// 订阅PlayerJoinedEvent,使用lambda
auto sub1 = bus.subscribe<PlayerJoinedEvent>([](const PlayerJoinedEvent& e) {
std::cout << "Player joined: " << e.playerName << " (ID: " << e.playerId << ")\n";
});
// 订阅ChatMessageEvent,使用普通函数
void onChatMessage(const ChatMessageEvent& e) {
std::cout << "[" << e.from << "] says: " << e.message << "\n";
}
auto sub2 = bus.subscribe<ChatMessageEvent>(onChatMessage);
// 发布事件,直接传递构造参数
bus.publish<PlayerJoinedEvent>("Alice", 1001);
bus.publish<ChatMessageEvent>("Bob", "Hello, World!");
// 当sub1和sub2被销毁时,理论上应该从总线上取消订阅(本例简化了生命周期管理)
return 0;
}
这个案例展示了:
-
可变参数模板
:在
publish函数中,使用Args&&... args和std::forward完美转发事件对象的构造参数,实现了高效、灵活的事件创建。 -
Lambda表达式
:在
subscribe函数内部,使用lambda捕获用户传入的回调函数callback,并将其包装成一个统一的EventHandler(std::function<void(const void*)>)。这个lambda完成了从void*到具体事件类型的转换。 -
std::function:作为EventHandler的类型,统一存储了所有不同类型的用户回调,使事件处理器的存储和调用变得简单。 -
类型擦除与安全转换
:通过
typeid获取类型哈希作为键,结合const void*和内部的静态转换,在类型安全的前提下实现了泛型事件系统。
当然,这是一个简化版本,生产级的实现还需要考虑线程安全、更精细的生命周期管理(如弱引用)、优先级、事件过滤等。但它清晰地勾勒出了如何运用C++11的这些高级特性来构建一个强大、灵活的基础设施组件。
更多推荐
所有评论(0)