类型擦除与部分异步编程: 消除差别,统一使用
C++ 中类型擦除最典型的实现思路分为两类------模板(编译期擦除)与多态(运行时擦除),这两种方式大家都比较熟悉。而标准库为我们封装了更易用的类型擦除工具,核心包括 std::function、std::any、std::span 和 std::variant,它们在不同场景下帮我们"消除类型差别,实现统一使用";同时,类型擦除也是异步编程的核心基础,std::function 搭配相关组件可实现任意异步任务的统一调度,这也是我们将两者结合讲解的核心原因。
1. std::function:可调用对象的"统一调用接口"
std::function 是针对可调用对象的类型擦除工具,其底层实现核心是「抽象基类 + 模板子类」的多态模式,也是运行时类型擦除的典型应用:
- 抽象基类:定义了与"函数签名"完全匹配的纯虚调用接口(比如
virtual Ret call(Args...) = 0),作为统一调用的基准; - 模板子类:存储具体的可调用对象(函数、lambda、仿函数、
std::bind结果等),并重写抽象基类的call方法,适配具体对象的调用逻辑。
正因为 std::function 是通过调用抽象基类的统一接口 ,间接呼叫存入模板子类中的具体函数,所以我们必须提前明确告知 std::function 完整的函数签名(返回值类型、参数类型、参数个数) ------ 这是抽象基类定义统一调用接口的前提,只有签名一致,所有被擦除类型的可调用对象,才能通过抽象基类的接口被正确调用。也正因运行时的多态派发(通过抽象基类指针调用子类的 call 方法),std::function 会产生一定的运行时开销。
测试代码:std::function 统一调用不同可调用对象
cpp
#include <iostream>
#include <functional>
#include <string>
// 普通函数
int add(int a, int b) { return a + b; }
// 仿函数
struct Multiply {
int operator()(int a, int b) { return a * b; }
};
int main() {
// 定义函数签名:int(int, int)
std::function<int(int, int)> func;
// 存储普通函数
func = add;
std::cout << "add(3,4) = " << func(3, 4) << std::endl; // 输出7
// 存储lambda表达式
func = [](int a, int b) { return a - b; };
std::cout << "sub(3,4) = " << func(3, 4) << std::endl; // 输出-1
// 存储仿函数
func = Multiply{};
std::cout << "mul(3,4) = " << func(3, 4) << std::endl; // 输出12
return 0;
}
代码说明:无论存储的是普通函数、lambda还是仿函数,只要函数签名匹配 int(int, int),就能通过 std::function 统一调用,体现了类型擦除"消除差别,统一使用"的核心。
2. std::any & std::variant:数据存储的"类型擦除双雄"
两者均用于实现数据存储的类型擦除,但定位互补,std::variant 核心是弥补 std::any 的繁琐与低效问题。
std::any:无约束的全类型擦除
std::any 是经典的"全类型擦除"工具,它完全擦除编译期的类型信息,仅保留"数据本身 + 运行时类型ID(std::type_info)",相当于一个"带类型标签的万能盒子",能存储任意类型的数据。
和 std::function 类似,std::any 需在运行时通过类型ID识别内部数据类型,因此存在运行时开销;此外,std::any 对大类型会进行堆内存分配,进一步增加轻微的内存开销。其最大的特点是自由无约束,但这份自由也带来了操作繁琐的问题------使用时必须手动通过 typeid 检查类型,再用 any_cast 提取数据,且类型错误只能在运行时暴露(抛出 std::bad_any_cast 异常)。
std::variant:有限制的高效类型擦除
std::variant 是为解决 std::any 的痛点而生,它通过编译期提前声明可存储的类型范围,实现了更高效、更安全的类型擦除,属于"有限类型擦除":
- 编译期兜底:写错类型(比如用
std::get提取非活跃类型)会被编译器及时提醒,更早暴露问题,避免运行时异常难以调试; - 统一便捷处理:无需手写一堆
if (typeid)判断分支,通过std::visit就能批量处理所有预定义类型,代码更简洁、不易漏分支; - 零堆开销:
std::variant的大小在编译期确定(等于所有预定义类型中最大类型的尺寸 + 类型标签尺寸),所有数据均存储在栈上,无堆分配开销; - 安全提取:提供
std::holds_alternative(判断是否为指定类型)、std::get_if(安全提取,不匹配返回nullptr)等工具,无需捕获异常,类型检查和数据提取更直观、安全。
简单来说,std::any 是"无拘无束但全靠手动",std::variant 是"有限制但编译器帮你兜底",这份限制恰恰是它简化操作、提升效率的核心。
测试代码:std::any 与 std::variant 对比
cpp
#include <iostream>
#include <any>
#include <variant>
#include <string>
#include <typeinfo>
// 处理std::any
void process_any(std::any val) {
if (val.type() == typeid(int)) {
std::cout << "any存储int:" << std::any_cast<int>(val) << std::endl;
} else if (val.type() == typeid(std::string)) {
std::cout << "any存储string:" << std::any_cast<std::string>(val) << std::endl;
} else if (val.type() == typeid(double)) {
std::cout << "any存储double:" << std::any_cast<double>(val) << std::endl;
}
}
// 处理std::variant
using MyVariant = std::variant<int, std::string, double>;
void process_variant(const MyVariant& val) {
// 无需手写if(typeid),std::visit批量处理
std::visit([](const auto& v) {
using T = std::decay_t<decltype(v)>;
if constexpr (std::is_same_v<T, int>) {
std::cout << "variant存储int:" << v << std::endl;
} else if constexpr (std::is_same_v<T, std::string>) {
std::cout << "variant存储string:" << v << std::endl;
} else if constexpr (std::is_same_v<T, double>) {
std::cout << "variant存储double:" << v << std::endl;
}
}, val);
}
int main() {
// std::any测试
std::any a = 10;
process_any(a); // 输出any存储int:10
a = std::string("hello any");
process_any(a); // 输出any存储string:hello any
a = 3.14;
process_any(a); // 输出any存储double:3.14
// std::variant测试
MyVariant v = 20;
process_variant(v); // 输出variant存储int:20
v = std::string("hello variant");
process_variant(v); // 输出variant存储string:hello variant
v = 6.28;
process_variant(v); // 输出variant存储double:6.28
// std::variant安全提取示例
if (std::holds_alternative<double>(v)) {
auto p = std::get_if<double>(&v);
std::cout << "安全提取double:" << *p << std::endl; // 输出6.28
}
return 0;
}
代码说明:
std::any需手动写if (typeid)分支,新增类型时需手动扩展;std::variant借助std::visit批量处理所有预定义类型,代码更简洁,且holds_alternative/get_if让类型检查/提取更安全。
3. std::span:连续容器的"零开销类型擦除"
std::span 是针对连续内存容器 的"特制类型擦除工具",专门用于消除不同连续容器的类型差异,实现统一访问:
它会擦除 std::vector、std::array、C风格数组等连续容器的具体类型,仅保留"起始指针 + 元素长度"两个核心特征,相当于给所有连续内存容器提供了一个统一的"视图"。
std::span 的核心优势是零运行时开销 ------类型擦除在编译期完成,无需运行时额外计算或内存分配;但也有明确限制:仅支持连续内存容器,无法处理 std::list 等非连续内存容器。
测试代码:std::span 统一访问不同连续容器
cpp
#include <iostream>
#include <span>
#include <vector>
#include <array>
// 统一处理所有连续int容器
void print_span(std::span<int> sp) {
std::cout << "容器长度:" << sp.size() << ",内容:";
for (int val : sp) {
std::cout << val << " ";
}
std::cout << std::endl;
}
int main() {
// std::vector
std::vector<int> vec = {1, 2, 3};
print_span(vec); // 输出容器长度:3,内容:1 2 3
// std::array
std::array<int, 4> arr = {4, 5, 6, 7};
print_span(arr); // 输出容器长度:4,内容:4 5 6 7
// C风格数组
int c_arr[] = {8, 9, 10};
print_span(c_arr); // 输出容器长度:3,内容:8 9 10
// 切片访问(span的额外优势)
print_span(std::span(vec).subspan(1, 2)); // 输出容器长度:2,内容:2 3
return 0;
}
代码说明:print_span 函数无需关心传入的是 vector、array 还是C数组,std::span 擦除了容器类型差异,实现统一访问,且无任何运行时开销。
4. 类型擦除在异步编程中的核心应用
为什么要将类型擦除与异步编程结合?因为 std::function 的类型擦除能力,是异步任务"统一管理"的核心,它常搭配 lambda 表达式、std::packaged_task、std::bind 实现任意异步任务的统一调度,核心逻辑是"擦除任务差异,统一管理,按需获取结果":
- 用
std::bind将任务参数与可调用对象绑定,擦除不同任务的参数类型差异,让有参任务适配统一的调用形式; - 将绑定后的任务装入
std::packaged_task,通过std::packaged_task内置的std::promise,获取std::future对象(用于后续接收异步任务的返回值)------ 此时任务的返回值类型未被擦除; - 通过 lambda 表达式封装
std::packaged_task的执行逻辑,将"有返回值的任务"包装成无返回值的void()类型,从而擦除返回值差异; - 最终,所有异步任务均可统一装进
std::function<void()>中进行管理,任务的返回值则在异步执行完成后,自动存入std::packaged_task内部的std::promise,我们通过之前获取的std::future就能按需获取异步结果,实现"任务统一管理 + 结果按需获取"。
测试代码:类型擦除实现异步任务统一管理
cpp
#include <iostream>
#include <functional>
#include <future>
#include <thread>
#include <queue>
#include <mutex>
#include <condition_variable>
#include <string>
// 全局任务队列:存储统一的无返回值任务
std::queue<std::function<void()>> task_queue;
std::mutex mtx;
std::condition_variable cv;
bool stop = false;
// 工作线程:消费任务队列
void worker() {
while (!stop) {
std::function<void()> task;
// 加锁取任务
{
std::unique_lock<std::mutex> lock(mtx);
cv.wait(lock, []() { return stop || !task_queue.empty(); });
if (stop && task_queue.empty()) return;
task = std::move(task_queue.front());
task_queue.pop();
}
// 执行任务
task();
}
}
// 提交任务模板:擦除参数/返回值差异,统一存入队列
template<typename F, typename... Args>
auto submit_task(F&& f, Args&&... args) -> std::future<decltype(f(args...))> {
// 绑定参数,擦除参数差异
auto bound_task = std::bind(std::forward<F>(f), std::forward<Args>(args)...);
// 定义packaged_task,保留返回值类型
using RetType = decltype(f(args...));
std::packaged_task<RetType()> pt(std::move(bound_task));
// 获取future用于接收结果
std::future<RetType> fut = pt.get_future();
// 封装成void()任务,擦除返回值差异
std::function<void()> wrapper = [pt = std::move(pt)]() mutable {
pt(); // 执行packaged_task,结果存入promise
};
// 存入任务队列
{
std::lock_guard<std::mutex> lock(mtx);
task_queue.push(std::move(wrapper));
}
cv.notify_one(); // 唤醒工作线程
return fut;
}
// 测试任务1:有参有返回值(计算平方)
int square(int x) {
std::this_thread::sleep_for(std::chrono::seconds(1));
return x * x;
}
// 测试任务2:有参有返回值(拼接字符串)
std::string concat(const std::string& a, const std::string& b) {
std::this_thread::sleep_for(std::chrono::seconds(1));
return a + b;
}
int main() {
// 启动工作线程
std::thread t(worker);
// 提交任务1:计算5的平方
auto fut1 = submit_task(square, 5);
// 提交任务2:拼接字符串
auto fut2 = submit_task(concat, "hello ", "async");
// 主线程等待结果
std::cout << "等待异步任务结果..." << std::endl;
std::cout << "5的平方:" << fut1.get() << std::endl; // 输出25
std::cout << "字符串拼接:" << fut2.get() << std::endl; // 输出hello async
// 停止工作线程
stop = true;
cv.notify_one();
t.join();
return 0;
}
代码说明:
square和concat是不同签名的任务(参数/返回值均不同);- 通过
std::bind擦除参数差异,std::packaged_task保留返回值并绑定future,lambda 封装成void()擦除返回值差异; - 最终所有任务都能存入
std::function<void()>队列,实现统一管理,体现了类型擦除在异步编程中的核心价值。
整体总结
标准库中的四种类型擦除工具,虽定位不同,但核心目标一致------消除类型差别,实现统一使用:
std::function:针对可调用对象,统一调用接口,依赖函数签名和多态实现,有运行时开销;std::any:针对任意数据,全类型擦除,自由但繁琐、有运行时和堆内存开销;std::variant:针对有限范围数据,弥补std::any不足,编译期兜底、高效便捷、零堆开销;std::span:针对连续容器,零开销类型擦除,统一连续内存访问接口,仅支持连续容器。
而类型擦除与异步编程的结合,核心是借助 std::function 的统一管理能力,搭配 lambda、std::packaged_task、std::bind 等组件,擦除不同异步任务的参数和返回值差异,实现任意异步任务的统一调度,这也是类型擦除在实际开发中最常用的场景之一。