一、从虚表到静态多态:C++17/20 带来的新思路
说实话,我写 C++ 十几年了,早期做多态设计时,脑子里只有虚函数和继承。直到 C++17/20 出来,我才发现——原来多态可以不用虚表,甚至不用继承。
你想想看,传统的虚函数多态,说白了就是运行时通过 vptr 查虚表。这招虽然好用,但代价也不小:每个对象多一个指针,虚表查找有开销,而且类层次一旦复杂,维护起来真是头疼。
我在项目中遇到过几次这样的场景:一个接口类有十几个虚函数,子类只重写其中两三个,剩下的全是空实现。嗯,这种代码看着就难受。后来我改用 std::variant + 访问者模式,代码量直接砍半。
这一章,我们就来聊聊 C++17/20 给多态带来的新武器。它们不是要取代虚函数,而是给你更多选择。
二、std::variant:类型安全的联合体
2.1 它解决了什么问题?
传统的 union 有个大坑:你得自己记住当前存的是哪个类型。我见过不少 bug 就是因为写入了 int 却读成了 float,编译器还不报错。
std::variant 就是类型安全的 union。它知道当前存的是什么类型,访问错了会抛异常。
#include <variant>
#include <iostream>
std::variant<int, double, std::string> v;
v = 42; // 存 int
std::cout << std::get<int>(v); // 正确
v = 3.14; // 改成 double
// std::get<int>(v); // 运行时抛 std::bad_variant_access
// 安全访问方式
if (auto* p = std::get_if<double>(&v)) {
std::cout << "double: " << *p;
}
std::get_if 就别用 std::get。前者返回指针,失败返回 nullptr,比抛异常优雅得多。
2.2 访问者模式:让 variant 动起来
光能存不同类型还不够,我们还得对它们做操作。这就是 std::visit 登场的时候了。
struct Visitor {
void operator()(int i) const {
std::cout << "int: " << i << "\n";
}
void operator()(double d) const {
std::cout << "double: " << d << "\n";
}
void operator()(const std::string& s) const {
std::cout << "string: " << s << "\n";
}
};
std::variant<int, double, std::string> v = "hello";
std::visit(Visitor{}, v); // 输出: string: hello
这其实就是访问者模式在 C++ 里的现代实现。你想想看,传统做法得写一个基类、几个子类、再加一个 accept 函数。现在一个 variant + 一个 visitor 就搞定了。
auto visitor = [](auto&& arg) { /* 通用处理 */ };
三、std::any:真正的“任意类型”容器
std::any 比 variant 更灵活——它可以存任何类型,不限于你预先列出的那些。代价是运行时开销更大。
#include <any>
std::any a = 42;
a = std::string("hello");
a = 3.14;
// 取出时必须知道原类型
try {
auto val = std::any_cast<double>(a);
std::cout << val;
} catch (const std::bad_any_cast& e) {
std::cerr << "类型不匹配";
}
// 更安全的做法
if (auto* p = std::any_cast<double>(&a)) {
std::cout << *p;
}
我个人觉得 std::any 适合做“类型擦除”的场景。比如插件系统里,你不想让主程序知道插件的具体类型,但又想存下来。这时候 any 就比 void* 安全得多。
四、std::function:函数的多态容器
std::function 其实也是一种多态——它用类型擦除技术,让你可以存任何可调用对象,只要签名匹配。
#include <functional>
std::function<int(int, int)> func;
func = [](int a, int b) { return a + b; }; // lambda
func = std::multiplies<int>{}; // 函数对象
func = [](int a, int b) { return a * a + b; }; // 另一个 lambda
// 调用时完全不知道内部是什么
int result = func(3, 4);
我在项目中常用 std::function 做回调注册。比如一个事件系统,不同模块注册不同的处理函数,统一用 function 存起来。调用方不需要关心具体是 lambda、函数指针还是成员函数。
五、Concepts:编译期多态的“契约”
C++20 的 Concepts 是我最期待的特性之一。它让模板的错误信息从“天书”变成了人话。
传统模板多态的问题是:你传错了类型,编译器给你报几十行错误,全是模板实例化内部的细节。有了 Concepts,你可以明确说“这个类型必须支持这些操作”。
#include <concepts>
// 定义一个 concept:可比较大小
template <typename T>
concept Comparable = requires(T a, T b) {
{ a < b } -> std::convertible_to<bool>;
{ a == b } -> std::convertible_to<bool>;
};
// 使用 concept 约束模板
template <Comparable T>
T max(T a, T b) {
return (a < b) ? b : a;
}
// 错误示例:传一个不支持 < 的类型
// struct Point { int x, y; };
// max(Point{1,2}, Point{3,4}); // 编译错误,提示清晰
你想想看,这其实就是一种“编译期多态”——在编译阶段就检查类型是否满足接口要求。和虚函数多态不同,它没有运行时开销,而且约束更精确。
六、知识体系总览
下面这张图总结了 C++17/20 中多态相关的新特性,以及它们各自的应用场景。
七、总结与选择建议
说了这么多,到底什么时候用哪个?我根据自己的经验给个参考:
| 场景 | 推荐方案 | 理由 |
|---|---|---|
| 类型集合固定(如 AST 节点) | std::variant + std::visit | 编译期确定,无堆分配,性能好 |
| 类型完全未知(如插件系统) | std::any | 灵活性最高,但注意性能 |
| 回调/事件/策略模式 | std::function | 类型擦除,接口统一 |
| 模板接口约束 | Concepts | 编译期检查,零开销 |
| 需要运行时多态(如 GUI 框架) | 传统虚函数 | 虚函数仍然是正确选择 |
嗯,这一章就到这里。记住一点:多态的本质是“同一接口,不同实现”。虚函数是一种实现方式,variant + visit 是另一种,Concepts 又是一种。理解它们的适用场景,比死记语法更重要。
公众号:蓝海资料掘金营,微信deep3321