25、C++17 核心特性:std::variant,类型安全的联合体
说到联合体,老派的C程序员肯定不陌生。union能让我们在同一块内存里存放不同类型的数据,但代价是什么?你得自己记住当前存的是哪个类型。一旦记错了,读出来的数据就是一团乱码。我早年做嵌入式开发时,就因为这个bug排查了整整两天——嗯,从那以后我对union就多了几分敬畏。
C++17带来的std::variant,说白了就是一个“类型安全的union”。它帮你记住了当前存的是什么类型,还提供了安全访问的接口。你想想看,这不就是我们一直想要的吗?
基本用法:告别裸union
先看一个最简单的例子:
#include <variant>
#include <iostream>
#include <string>
int main() {
// 定义一个可以存int、double或std::string的variant
std::variant<int, double, std::string> v;
// 默认构造,第一个类型会被值初始化
// 这里v持有int(0)
std::cout << std::get<int>(v) << '\n'; // 输出 0
// 赋值double
v = 3.14;
std::cout << std::get<double>(v) << '\n'; // 输出 3.14
// 赋值string
v = "hello";
std::cout << std::get<std::string>(v) << '\n'; // 输出 hello
return 0;
}
看到没?std::variant自动管理了当前存储的类型。你不用再像union那样,额外搞一个枚举来标记当前类型。我个人习惯用std::get<T>来取值,但要注意:如果当前存储的类型和请求的不匹配,它会抛出std::bad_variant_access异常。
我曾经在一个线上服务里用
std::get取值,结果因为某个分支逻辑没覆盖到,程序直接崩溃了。后来我改用std::get_if,先检查再访问,稳多了。
安全访问:get_if 和 visit
为了避免异常,C++17提供了两种更安全的访问方式。
方式一:std::get_if
std::variant<int, double, std::string> v = 42;
// 返回指针,如果类型不匹配则返回nullptr
if (auto* p = std::get_if<int>(&v)) {
std::cout << "int: " << *p << '\n';
} else if (auto* p = std::get_if<double>(&v)) {
std::cout << "double: " << *p << '\n';
} else if (auto* p = std::get_if<std::string>(&v)) {
std::cout << "string: " << *p << '\n';
}
方式二:std::visit(推荐)
这才是真正的现代C++风格。用一个访问者模式,一次性处理所有可能的情况:
std::variant<int, double, std::string> v = 3.14;
std::visit([](auto&& arg) {
using T = std::decay_t<decltype(arg)>;
if constexpr (std::is_same_v<T, int>) {
std::cout << "int: " << arg << '\n';
} else if constexpr (std::is_same_v<T, double>) {
std::cout << "double: " << arg << '\n';
} else if constexpr (std::is_same_v<T, std::string>) {
std::cout << "string: " << arg << '\n';
}
}, v);
这里用了if constexpr,编译期就能确定分支,没有运行时开销。我建议你在项目里优先用std::visit,代码更整洁,也更容易扩展。
variant的底层原理
为了让你更直观地理解variant的工作方式,我画了一张图:
说白了,std::variant就是在union的基础上加了一个“类型标签”(discriminator)。每次赋值时,它自动更新这个标签;每次取值时,它先检查标签是否匹配。这就是“类型安全”的由来。
常用操作一览
| 操作 | 代码 | 说明 |
|---|---|---|
| 构造 | std::variant<int, float> v{42}; |
直接初始化 |
| 赋值 | v = 3.14f; |
自动切换类型 |
| 取值(异常) | std::get<int>(v) |
类型不匹配抛异常 |
| 取值(安全) | std::get_if<int>(&v) |
返回指针,失败返回nullptr |
| 访问者 | std::visit(visitor, v) |
推荐方式,编译期多态 |
| 查询类型 | v.index() |
返回当前类型的索引 |
| 判空 | v.valueless_by_exception() |
是否因异常变为无值状态 |
实际应用场景
我在项目中用std::variant最多的场景是解析配置文件。比如一个JSON字段可能是数字、字符串或布尔值:
using ConfigValue = std::variant<int64_t, double, std::string, bool>;
class ConfigParser {
public:
ConfigValue get(const std::string& key) const {
auto it = data_.find(key);
if (it != data_.end()) {
return it->second;
}
return std::string{}; // 默认返回空字符串
}
private:
std::unordered_map<std::string, ConfigValue> data_;
};
// 使用
ConfigParser parser;
auto val = parser.get("timeout");
std::visit([](auto&& arg) {
using T = std::decay_t<decltype(arg)>;
if constexpr (std::is_same_v<T, int64_t>) {
std::cout << "timeout (int): " << arg << '\n';
} else if constexpr (std::is_same_v<T, std::string>) {
std::cout << "timeout (string): " << arg << '\n';
}
// 其他类型...
}, val);
另一个常见场景是状态机。用std::variant表示不同的状态,每个状态携带不同的数据:
struct IdleState {};
struct RunningState { int progress; };
struct ErrorState { std::string message; int code; };
using State = std::variant<IdleState, RunningState, ErrorState>;
void handleState(const State& s) {
std::visit([](const auto& state) {
using T = std::decay_t<decltype(state)>;
if constexpr (std::is_same_v<T, IdleState>) {
std::cout << "系统空闲\n";
} else if constexpr (std::is_same_v<T, RunningState>) {
std::cout << "运行中,进度: " << state.progress << "%\n";
} else if constexpr (std::is_same_v<T, ErrorState>) {
std::cout << "错误: " << state.message
<< " (代码: " << state.code << ")\n";
}
}, s);
}
如果你需要频繁地判断variant的当前类型,可以用
v.index()配合switch语句。但说实话,std::visit更优雅,而且编译器会帮你检查是否覆盖了所有类型。
性能与注意事项
关于性能,我直接说结论:
- 内存占用:
std::variant的大小等于最大类型的大小加上对齐开销,再加上一个整数索引(通常1-2字节)。比union多了一点点,但换来的是类型安全,值了。 - 运行时开销:赋值和访问时会有一次类型检查,但这是O(1)的。相比你手动维护union+枚举的方案,性能几乎一样。
- 异常安全:如果赋值过程中抛出异常,variant会进入
valueless_by_exception()状态。这时候访问它会抛异常。我建议你在关键路径上检查这个状态。
我曾经在代码里用
std::variant<std::string, std::vector<int>>,结果发现每次赋值都会触发拷贝构造。后来改成std::variant<std::unique_ptr<std::string>, std::unique_ptr<std::vector<int>>>,移动语义就生效了。记住:variant内部会存储完整的对象,大对象要考虑用智能指针包装。
最后说一句:std::variant不是万能的。如果你的类型列表很长(超过10个),或者类型之间差异巨大,考虑用继承+多态可能更合适。但大多数场景下,variant是比union更现代、更安全的选择。
公众号:蓝海资料掘金营,微信deep3321