std::variant与访问者模式:std::visit、Lambda与variant结合
好,咱们今天聊一个我特别喜欢的组合——std::variant 加上 std::visit,再配上 Lambda。说实话,这三样东西放在一起,能写出非常优雅的代码。
先说说 std::variant 是什么。它本质上是一个「类型安全的联合体」。你想想看,C 语言里的 union 有多危险?你存了一个 int,却当成 float 读出来,编译器都不带吭声的。而 std::variant 不会让你这么干——它记住了当前存的是哪种类型,访问错了直接抛异常。
基本用法
先看个最简单的例子:
#include <variant>
#include <iostream>
#include <string>
std::variant<int, double, std::string> v;
v = 42; // 存 int
v = 3.14; // 存 double
v = std::string("hello"); // 存 string
嗯,这里要注意:std::variant 的模板参数里列出的类型,就是它能存的所有可能。你不能塞一个 char* 进去,除非你把它加到类型列表里。
如何读取?
读取的方式有两种。第一种是用 std::get<T>:
try {
double d = std::get<double>(v);
std::cout << d << std::endl;
} catch (const std::bad_variant_access& e) {
std::cout << "类型不匹配" << std::endl;
}
第二种是用 std::get_if<T>,返回指针:
if (auto* p = std::get_if<double>(&v)) {
std::cout << *p << std::endl;
} else {
std::cout << "不是 double" << std::endl;
}
我个人习惯用 std::get_if,因为不用写 try-catch,代码更干净。
真正的杀手锏:std::visit
但上面这些写法,说实话,还是有点啰嗦。真正的优雅写法是 std::visit。它接受一个可调用对象和一个 variant,然后根据 variant 当前存储的类型,自动调用对应的重载。
看这个例子:
std::visit([](auto&& arg) {
std::cout << arg << std::endl;
}, v);
这里用了泛型 Lambda,auto&& 会推导出实际类型。不管 v 里存的是 int、double 还是 string,都能正确打印。
但问题来了——如果你对不同类型要做不同的事情呢?比如 int 要加 1,double 要取整,string 要转大写。这时候泛型 Lambda 就不够用了。
重载技巧:Lambda 的「组合拳」
我在项目中遇到过这个需求。当时我写了一个辅助类,叫 overloaded:
template<class... Ts>
struct overloaded : Ts... {
using Ts::operator()...;
};
template<class... Ts>
overloaded(Ts...) -> overloaded<Ts...>;
这个类继承自多个 Lambda,然后把它们的 operator() 全部引入。这样你就可以写:
std::visit(overloaded{
[](int i) { std::cout << "int: " << i + 1 << std::endl; },
[](double d) { std::cout << "double: " << static_cast<int>(d) << std::endl; },
[](const std::string& s) { std::cout << "string: " << s.size() << std::endl; }
}, v);
你看,每个类型对应一个 Lambda,清晰又灵活。C++17 之后,这个 overloaded 模式成了标准写法。我自己的项目里几乎每个用到 std::variant 的地方,都会配一个 overloaded。
[]<typename T>(T arg){} 这种模板 Lambda,配合 if constexpr 也能达到类似效果。但我个人还是喜欢 overloaded 模式,因为可读性更好。
实际应用场景
我举一个真实的例子。之前做一个配置解析器,配置项可以是整数、浮点数、字符串、布尔值。用 std::variant 来存:
using ConfigValue = std::variant<int, double, std::string, bool>;
std::map<std::string, ConfigValue> config;
config["timeout"] = 30;
config["rate"] = 0.85;
config["name"] = std::string("server");
config["debug"] = true;
然后序列化的时候,用 std::visit 配合 overloaded:
for (const auto& [key, value] : config) {
std::visit(overloaded{
[&key](int i) { std::cout << key << " = " << i << " (int)" << std::endl; },
[&key](double d) { std::cout << key << " = " << d << " (double)" << std::endl; },
[&key](const std::string& s) { std::cout << key << " = " << s << " (string)" << std::endl; },
[&key](bool b) { std::cout << key << " = " << (b ? "true" : "false") << " (bool)" << std::endl; }
}, value);
}
代码非常干净,而且扩展起来也容易——想加一个新类型,只需要在 variant 的类型列表里加上,再在 overloaded 里加一个 Lambda 就行。
避坑指南
std::variant<int, int>),编译会报错。因为 std::visit 无法区分它们。所以一定要保证类型列表里的类型是唯一的。
还有一个坑:std::variant 的默认构造函数会构造第一个类型的默认值。如果第一个类型没有默认构造函数,那 variant 本身也不能默认构造。我建议给 variant 的第一个类型选一个「安全」的类型,比如 int 或 std::monostate。
std::monostate 是标准库提供的一个空类型,专门用来做 variant 的「空状态」占位符。比如:
std::variant<std::monostate, int, double, std::string> v;
这样 v 默认是「空」的,你可以用 std::holds_alternative<std::monostate>(v) 来判断是否为空。
性能方面
你可能会担心 std::visit 的性能。其实它内部是用一个跳转表实现的,复杂度是 O(1),跟 switch-case 差不多。我在性能敏感的场景下用过,完全没问题。
但要注意一点:如果 variant 的类型列表很长(比如超过 10 个),编译后的代码体积会变大。因为编译器会为每个类型生成一份代码。不过大多数情况下,3-5 个类型就够用了。
总结一下
std::variant + std::visit + Lambda 这个组合,说白了就是 C++ 里的「模式匹配」。它让代码更安全、更清晰、更容易扩展。如果你还在用 union 或者手写 if-else 判断类型,我建议你试试这个方案。
嗯,最后说一句:overloaded 这个辅助类,建议你直接放到项目的公共头文件里。我几乎每个项目都会用到它,省事又省心。
std::variant是类型安全的联合体,替代 C 风格的 unionstd::visit自动根据当前类型分发到对应的处理逻辑- 配合 overloaded 模式,可以写出类似模式匹配的代码
- 性能优秀,适合生产环境使用
这张图展示了整个流程:variant 里存了一个值,std::visit 根据它的实际类型,自动选择对应的 Lambda 来处理。你不需要写任何 if-else 或 switch,编译器帮你搞定一切。