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

小技巧:从 C++20 开始,你可以用 []<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 就行。

避坑指南

我曾经踩过一个坑:如果 variant 里有两个类型相同(比如 std::variant<int, int>),编译会报错。因为 std::visit 无法区分它们。所以一定要保证类型列表里的类型是唯一的。

还有一个坑:std::variant 的默认构造函数会构造第一个类型的默认值。如果第一个类型没有默认构造函数,那 variant 本身也不能默认构造。我建议给 variant 的第一个类型选一个「安全」的类型,比如 intstd::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 风格的 union
  • std::visit 自动根据当前类型分发到对应的处理逻辑
  • 配合 overloaded 模式,可以写出类似模式匹配的代码
  • 性能优秀,适合生产环境使用
std::variant + std::visit + Lambda 核心流程 std::variant std::visit(overloaded{...}, v) [](int i) { ... } 处理 int 类型 [](double d) { ... } 处理 double 类型 [](string s) { ... } 处理 string 类型 自动匹配当前类型,调用对应的 Lambda 表达式

这张图展示了整个流程:variant 里存了一个值,std::visit 根据它的实际类型,自动选择对应的 Lambda 来处理。你不需要写任何 if-elseswitch,编译器帮你搞定一切。

公众号:蓝海资料掘金营,微信 deep3321