
std::variant
std::variant 是 C++17 标准库中加入的一个类模板 ,它代表一个类型安全的联合体(union) 。它可以持有其模板参数列表中指定的任何一种类型的值。我们也不叫他联合体了,常说的便是"变体"
🔗 参考:https://en.cppreference.com/w/cpp/header/variant.html
传统的 C++ union 不是类型安全的。我们需要自己记住当前存储的是哪种类型,如果访问错了(比如在一个存储 int 的 union 上读取 float),会导致未定义行为,就好比内存里实际是 int 10 的二进制数据,但你要求编译器按照 float 的格式解析这段二进制 ------float 和 int 的二进制编码规则完全不同(比如 float 是 IEEE 754 浮点格式,int 是补码),解析出来的结果是无意义的 "垃圾值";
而且它无法处理非平凡类型(如 std::string),其中非平凡类型指那些有自定义构造函数 / 析构函数、自定义拷贝 / 移动语义、虚函数 的类型(比如 std::string、std::vector、std::map 等),这类类型的对象需要编译器自动管理资源(比如 std::string 会在堆上分配内存存储字符,析构时释放)。
详细的对比就是:
C++11 之前:union 完全禁止包含非平凡类型 ------ 因为 union 的构造 / 析构函数是编译器自动生成的,它只会分配内存,但不会调用成员的构造 / 析构函数。比如:
cpp
// C++11 前编译报错!
union BadUnion {
int i;
std::string s; // std::string 有自定义构造/析构/拷贝
};
如果允许,BadUnion 的析构函数不知道该调用 int 还是 std::string 的析构函数,std::string 的堆内存会泄漏,导致资源管理崩溃。
C++11 及之后:允许包含非平凡类型,但需要手动管理构造 / 析构,极其繁琐且容易出错:
cpp
#include <iostream>
#include <string>
union StringUnion {
int i;
std::string s;
// 空构造:不初始化任何成员(必须手动构造)
StringUnion() {}
// 空析构:不销毁任何成员(必须手动销毁)
~StringUnion() {}
// 手动构造 std::string 成员
void init_string(const std::string& str) {
// 定位 new:在 s 对应的内存地址上构造 std::string 对象
new (&s) std::string(str);
}
// 手动销毁 std::string 成员
void destroy_string() {
// 显式调用 std::string 的析构函数,释放堆内存
s.~basic_string();
}
};
int main() {
// ========== 场景1:正确使用(先构造、再使用、最后销毁) ==========
StringUnion u1;
// 1. 手动构造 string 成员(必须先构造,才能访问)
u1.init_string("Hello Union");
// 2. 使用 string 成员
std::cout << "u1.s = " << u1.s << std::endl; // 输出:Hello Union
// 3. 手动销毁 string 成员(必须销毁,否则内存泄漏)
u1.destroy_string();
// ========== 场景2:切换成员(先销毁旧成员,再构造新成员) ==========
StringUnion u2;
// 先使用 int 成员(int 是平凡类型,无需手动构造/销毁)
u2.i = 100;
std::cout << "u2.i = " << u2.i << std::endl; // 输出:100
// 切换到 string 成员:int 无需销毁,但必须先构造 string
u2.init_string("Switch to string");
std::cout << "u2.s = " << u2.s << std::endl; // 输出:Switch to string
u2.destroy_string(); // 用完必须销毁
// ========== 场景3:错误示例(忘记销毁,导致内存泄漏) ==========
// StringUnion u3;
// u3.init_string("Memory Leak");
// // 忘记调用 destroy_string():std::string 的堆内存永远不会释放
// ========== 场景4:错误示例(未构造就访问,未定义行为) ==========
// StringUnion u4;
// std::cout << u4.s << std::endl; // 未构造就访问:程序崩溃/垃圾值
return 0;
}
细节点:
new (&s) std::string(str):调用定位 new,不在堆上分配新内存 ,而是直接在&s这个地址上构造std::string对象(执行std::string的构造函数,初始化其内部的指针、长度等成员)。
这种写法需要你手动记住 "当前活跃的是哪个成员",手动调用构造 / 析构 ------ 不仅代码复杂,还回到了 "记类型" 的问题,一旦漏调用析构,就会导致内存泄漏;调用错了,又是未定义行为。
std::variant 的优势是它解决了所有这些问题,它知道当前存储的是哪种类型,并确保对象被正确构造和析构,我们可以把它想象成一个 "智能的"、"类型丰富的" union。
🛠️ 定义和赋值修改
cpp
#include <variant>
#include <string>
#include <iostream>
// 示例:定义和赋值
int main() {
// 定义一个 variant,它可以存储一个 int,一个 double,或一个 std::string
std::variant<int, double, std::string> v;
v = 42; // 现在持有 int
std::cout << "int: " << std::get<int>(v) << std::endl;
v = 3.14; // 现在持有 double
std::cout << "double: " << std::get<double>(v) << std::endl;
v = "hello"; // 现在持有 std::string
std::cout << "string: " << std::get<std::string>(v) << std::endl;
// 赋值时如果找不到对应类型的值则报错
// v = std::pair<int, int>{}; // Error
// 使用index()获取当前持有的类型索引
std::cout << "Current index: " << v.index() << std::endl;
std::variant<std::string, std::string> v2;
// v2 = "abc"; // Error
}
std::variant 是 C++17 引入的类型安全联合体,在定义时必须指定它能存储的所有类型列表,且这些类型会按顺序分配索引(从 0 开始)。
cpp
// 格式:std::variant<类型1, 类型2, 类型3, ...> 变量名;
std::variant<int, double, std::string> v; // 可存储int(索引0)、double(索引1)、string(索引2)
- 定义时至少要指定一种 类型,空的
std::variant<>是非法的; - 不允许重复定义相同类型(如
std::variant<std::string, std::string>),这类定义无意义且会导致编译错误; - 未显式赋值时,std::variant 会默认初始化第一个类型(如上面的 v 初始持有值为 0 的 int)。(所以我们就单纯定义的话,也会调用构造,所以要求第一个参数必须要有默认构造)(细节不要错!如果都没有默认构造,我们可以第一个传入一个该类提供的一个空类 --- std::monostate)
std::variant 支持直接赋值,但只能赋值为定义时指定的类型,赋值后会自动切换内部存储的类型。
cpp
std::variant<int, double, std::string> v;
// 1. 赋值为int类型(索引0)
v = 42;
std::cout << "int值: " << std::get<int>(v) << std::endl; // 输出:int值: 42
// 2. 赋值为double类型(索引1)
v = 3.14;
std::cout << "double值: " << std::get<double>(v) << std::endl; // 输出:double值: 3.14
// 3. 赋值为std::string类型(索引2)
v = "hello"; // 字面量自动转换为std::string
std::cout << "string值: " << std::get<std::string>(v) << std::endl; // 输出:string值: hello
- ❌ 不能赋值为定义时未指定的类型(如
v = std::pair<int, int>{}),会直接编译报错; - ❌ 重复类型的
std::variant(如std::variant<std::string, std::string>)无法赋值,因为编译器无法区分重复类型; - ✅ 赋值时会自动处理类型转换(如
const char*字面量可赋值给std::string类型的变体)。
通过 index() 成员函数可获取当前存储类型的索引,验证赋值是否成功切换类型:
cpp
std::variant<int, double, std::string> v;
v = "hello"; // 切换为string类型(索引2)
std::cout << "当前类型索引: " << v.index() << std::endl; // 输出:当前类型索引: 2
- 索引从 0 开始,与定义时的类型顺序严格对应;
- 若
std::variant处于 "空状态"(如异常情况下),index()会返回std::variant_npos(通常是size_t最大值)。
🔍 访问值
1. 使用 std::get<T> 或 std::get<N>
我们可以通过类型或索引来(类型模板参数/非类型模板参数)直接获取值。但如果当前 variant 存储的不是我们请求的类型 / 索引,它会抛出 std::bad_variant_access 异常。
cpp
int main() {
std::variant<int, double> v = 42;
try {
std::cout << std::get<int>(v) << std::endl;
std::cout << std::get<double>(v) << std::endl; // 抛出异常
}
catch (const std::bad_variant_access& e) {
std::cout << "Error: " << e.what() << std::endl;
}
}
2. 使用 std::get_if<T>
std::get_if 不会抛出异常。它接受一个指针参数,如果 variant 当前存储的是指定类型,则返回一个指向该值的指针;否则返回 nullptr。
cpp
int main() {
std::variant<int, double, std::string> v = "hello";
// 使用std::get_if尝试获取值
if (auto pval = std::get_if<int>(&v)) {
std::cout << "int value: " << *pval << std::endl;
}
else if (auto pval = std::get_if<double>(&v)) {
std::cout << "double value: " << *pval << std::endl;
}
else if (auto pval = std::get_if<std::string>(&v)) {
std::cout << "string value: " << *pval << std::endl;
}
}
3. 使用 std::visit(推荐,最安全强大)
std::visit 类模板允许你提供一个 "访问者"(visitor)来根据当前存储的类型执行相应的操作,这是最类型安全、最清晰的方式。第一个参数访问者是一个可调用对象,通常是一个重载了 operator() 的类(或者使用 lambda 表达式结合 overloaded 技巧),std::visit 会把 std::variant 对象中存储的值取出来,作为参数传给 visitor 可调用对象。
所以在处理 std::variant 时,std::visit 是更现代、更安全也更强大的选择,相比传统的 std::get 和 std::get_if,它在代码的健壮性、可维护性和表达力上都有明显优势。
cpp
#include <iomanip>
#include <iostream>
#include <string>
#include <type_traits>
#include <variant>
#include <vector>
// the variant to visit
using value_t = std::variant<int, double, std::string>;
struct VisitorOP {
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';
}
};
// helper type for the visitor #4
template<class... Ts> struct overloaded : Ts... { using Ts::operator()...; };
// explicit deduction guide (not needed as of C++20)
template<class... Ts> overloaded(Ts...) -> overloaded<Ts...>;
int main() {
std::vector<value_t> vec = { 10, 1.5, "hello" };
for (auto& v : vec)
{
std::visit(VisitorOP(), v);
}
std::cout << '\n';
for (auto& v : vec)
{
// 1. void visitor, only called for side-effects (here, for I/O)
std::visit([](auto&& arg) { std::cout << arg; }, v);
// 2. value-returning visitor, demonstrates the idiom of returning
// another variant
value_t w = std::visit([](auto&& arg) -> value_t { return arg + arg; }, v);
// 3. type-matching visitor: a lambda that handles each type
// differently
std::cout << ". After doubling, variant holds ";
std::visit([](auto&& arg) {
using T = std::decay_t<decltype(arg)>;
if constexpr (std::is_same_v<T, int>)
std::cout << "int with value " << arg << '\n';
else if constexpr (std::is_same_v<T, double>)
std::cout << "double with value " << arg << '\n';
else if constexpr (std::is_same_v<T, std::string>)
std::cout << "string with value \"" << std::quoted(arg) << "\"\n";
else
static_assert(false, "non-exhaustive visitor!");
}, w);
}
std::cout << '\n';
for (auto& v : vec)
{
// 4. another type-matching visitor: a class with 3 overloaded
// operator()'s
std::visit(overloaded{
[](int arg) { std::cout << "int: " << arg << ' '; },
[](double arg) { std::cout << "double: " << arg << ' '; },
[](const std::string& arg) { std::cout << "string: " << std::quoted(arg) << ' '; }
}, v);
}
}
std::get / std::get_if :需要你手动保证访问的类型或索引与 variant 当前存储的类型一致。如果类型不匹配,std::get 会抛出异常,std::get_if 会返回 nullptr,但这些错误都发生在运行时。
std::visit :编译器会强制你处理 variant 中所有可能的类型。如果漏掉了任何一种类型,代码会在编译期就报错(比如代码里的 static_assert(false, "non-exhaustive visitor!")),从根源上杜绝了运行时错误。
而且性能开销,visit 是比较小的,没有运行时的检查类型
1. 基础背景:代码的核心目标
这段代码先定义了一个能存储 int/double/string 的 variant 类型(value_t),然后创建了包含这三种类型值的向量 vec。整个程序的核心,就是用 std::visit 遍历这个向量,对每个 variant 里的不同类型值执行不同操作 ------ 本质是 "根据 variant 存储的实际类型,自动调用对应逻辑"。
2. 第一种用法:用自定义结构体做 "访问者"
代码里的 VisitorOP 是一个结构体,它重载了 3 次 operator(),分别处理 int/double/string 类型。std::visit(VisitorOP(), v) 就是把 v 里存储的值传给 VisitorOP 的对象,编译器会自动匹配值的类型,调用对应的 operator()(比如 v 存的是 int,就调用处理 int 的那个函数),最终打印对应类型和值。这是 std::visit 最基础的用法:用 "重载函数调用运算符的类" 封装所有类型的处理逻辑。
3. 第二种 + 第三种用法:用 lambda 做访问者(进阶)
第二段循环里,第一个 std::visit 直接传了一个泛型 lambda([](auto&& arg) { std::cout << arg; }),因为 lambda 是泛型的,能接收任意类型的参数,所以可以直接处理 variant 里的所有类型;第二个 std::visit 更巧妙:它先返回一个新的 variant(把原值翻倍,比如 int 10 变 20,string "hello" 变 "hellohello"),然后又用一个带 if constexpr 的泛型 lambda,在编译期判断参数类型,分别打印不同的提示语 ------ 核心是 "用泛型 lambda + 编译期判断,替代结构体重载"。
4. 第四种用法:用 overloaded 组合多个 lambda(最优实践)
代码里的 overloaded 是一个模板技巧:它能把多个不同的 lambda "合并" 成一个对象,每个 lambda 处理一种类型。std::visit(overloaded{处理int的lambda, 处理double的lambda, 处理string的lambda}, v) 就会根据 v 的实际类型,调用对应的 lambda------ 这是实际开发中最常用的写法,不用写结构体,直接用 lambda 组合,代码更简洁。
不过部分的大家呢,这个会比较看不懂,这里解释一下:
其实它是 C++ 里一个极其巧妙但核心简单 的模板技巧,这段代码的目的是:把多个不同的 lambda(或函数对象)"合并" 成一个对象,让这个对象拥有所有 lambda 的 operator() 重载版本 ,这样就能用它作为 std::visit 的访问者,匹配 variant 的不同类型。(其实就是上面的方法,就是写法的差别而已)
cpp
template<class... Ts> struct overloaded : Ts... { using Ts::operator()...; };
拆解成 3 个关键部分:
template<class... Ts> :这是 C++11 的变参模板 ,Ts... 表示 "任意数量、任意类型的模板参数"(比如传 3 个 lambda,Ts 就是这 3 个 lambda 的类型)。
struct overloaded : Ts... :overloaded 结构体公有继承 了所有 Ts 里的类型(也就是继承了所有传入的 lambda)。lambda 本质是匿名的函数对象,每个 lambda 都有自己的 operator(),继承后 overloaded 就 "拥有" 了这些 operator()。
using Ts::operator()...; :这是 C++17 的包展开 语法,作用是 "把所有基类(Ts)的 operator() 都引入到 overloaded 的作用域中"。
- ❌ 不加这行的问题:C++ 中,子类继承多个基类的同名函数(这里都是 operator())时,基类的函数会被 "隐藏",编译器不知道该调用哪个;
- ✅ 加这行的作用:显式把所有基类的 operator() 暴露出来,让编译器能根据参数类型匹配对应的重载版本。(这才是重点)
cpp
template<class... Ts> overloaded(Ts...) -> overloaded<Ts...>;
这是 C++17 的类模板推导指南,作用是:
- 当我们使用
overloaded{lambda1, lambda2, lambda3}这种方式创建对象时,编译器能自动推导模板参数Ts就是这 3 个 lambda 的类型; - 比如我们写的
overloaded{[](int){}, [](double){}},编译器会推导Ts是 "处理 int 的 lambda 类型 + 处理 double 的 lambda 类型",自动生成overloaded<lambda1_type, lambda2_type>的对象; - 备注:C++20 起,编译器能自动推导,这行可以省略,但为了兼容通常会保留。
overloaded 是一个继承了多个类型(Ts...)的变参模板结构体 ,当你用 overloaded{lambda1, lambda2} 这种 "聚合初始化" 的方式创建对象时:
- C++17 的编译器默认只会 "从结构体的成员变量" 推导模板参数,不会从 "基类列表(Ts...)" 推导;
- 而
overloaded结构体本身没有任何成员变量,只有继承的基类,所以编译器会直接报错:"无法推导 overloaded 的模板参数"。
第一步:先看 "有成员变量" 的正常情况(编译器能推导)
假设我们写一个简单的模板结构体,里面有成员变量:
cpp// 模板结构体:有一个成员变量,类型是 T template<class T> struct MyStruct { T value; // 成员变量 }; int main() { // 用 {10} 初始化,编译器能推导: // 1. 看到成员变量 value 被赋值为 10(int类型) // 2. 所以模板参数 T = int,自动生成 MyStruct<int> MyStruct s{10}; return 0; }这个场景编译器能正常推导,因为它能从成员变量的赋值里找到模板参数的匹配关系。
第二步:再看 overloaded 的情况(编译器推导失败)
回到我们的
overloaded结构体,它的定义是:
cpptemplate<class... Ts> struct overloaded : Ts... { // 只有基类 Ts...,没有任何成员变量 using Ts::operator()...; };当你写
overloaded{lambda1, lambda2}时,问题就来了:编译器的思考过程(C++17):
- "我要推导 overloaded 的模板参数 Ts...,首先找它的成员变量...... 哦,它没有成员变量!"
- "那我该从哪找 Ts... 的类型?基类列表?不行,规则说我只看成员变量,不看基类!"
- "完了,找不到匹配的模板参数,报错!"
通俗比喻:这就像你去买奶茶,店员只认 "菜单上的选项"(成员变量),不认 "赠品"(基类)。你指着赠品说 "我要这个",店员会说 "我不知道这是什么,没法下单"------ 编译器就是这个店员,它只看成员变量,不认基类,所以推导失败。
第三步:推导指南的作用(给编译器 "开特例")
我们写的推导指南:
cpptemplate<class... Ts> overloaded(Ts...) -> overloaded<Ts...>;本质是给编译器加了一条 "特例规则":
"当有人用
overloaded{参数1, 参数2,...}创建对象时,不管你有没有成员变量,直接把这些参数的类型当成模板参数 Ts...!"加上这条规则后,编译器再看到
overloaded{lambda1, lambda2}:
- "哦,有推导指南!不用看成员变量了。"
- "参数 1 是 lambda1(类型 L1),参数 2 是 lambda2(类型 L2)。"
- "所以 Ts... = L1, L2,模板参数就定了,生成 overloaded<L1, L2>!"
所以:
C++17 编译器推导模板参数时 "眼里只有成员变量",而
overloaded没有成员变量、只有继承的基类,所以编译器猜不到模板参数;推导指南的作用就是 "绕开成员变量规则",直接告诉编译器:用初始化参数的类型作为模板参数。你可以把这个过程记成:
- 正常情况:成员变量类型 → 模板参数(编译器会)
- overloaded 情况:初始化参数类型 → 模板参数(需要推导指南教编译器)
🧩 综合案例(简化)
cpp
#include <iostream>
#include <list>
#include <set>
#include <string>
#include <type_traits>
#include <variant>
#include <vector>
template<class... Ts> struct overloaded : Ts... { using Ts::operator()...; };
template<class... Ts> overloaded(Ts...) -> overloaded<Ts...>;
// 实现一个哈希表,桶可以是一个链表,也可以是一个红黑树
class HashTable {
private:
using Value = std::variant<std::list<int>, std::set<int>>;
std::vector<Value> _tables;
public:
HashTable(size_t len) : _tables(len) {}
void insert(const int& key) {
size_t hash = key % _tables.size();
// 扩容
if (std::holds_alternative<std::list<int>>(_tables[hash])) {
auto& list = std::get<std::list<int>>(_tables[hash]);
// 小于,则插入到链表
if (list.size() < 8) {
list.push_back(key);
}
else {
// 大于,则转换到红黑树
std::set<int> s(list.begin(), list.end());
s.insert(key);
_tables[hash] = move(s);
}
}
else {
auto& set = std::get<std::set<int>>(_tables[hash]);
set.insert(key);
}
}
bool find(const int& key) {
size_t hash = key % _tables.size();
// 查找
auto findInList = [&key](std::list<int>& list) -> bool {
return std::find(list.begin(), list.end(), key) != list.end();
};
auto findInSet = [&key](std::set<int>& set) -> bool {
return set.count(key);
};
return std::visit(overloaded{
findInList,
findInSet
}, _tables[hash]);
}
};
int main() {
HashTable ht(10);
for (int i = 0; i < 10; ++i) {
ht.insert(i * 10);
}
std::cout << ht.find(3) << std::endl;
std::cout << ht.find(30) << std::endl;
return 0;
}
这个哈希表案例中,std::variant<std::list<int>, std::set<int>> 被用来定义哈希桶的类型,让每个桶既能存储链表(std::list)也能存储红黑树(std::set)------ 插入元素时,先通过 std::holds_alternative 判断当前桶是链表还是红黑树,若链表元素数超过 8 则自动转为红黑树;查找元素时,利用 std::visit 结合 overloaded 技巧,根据桶的实际类型(链表 / 红黑树)自动调用对应的查找逻辑(链表用 std::find、红黑树用 count),std::variant 在这里的核心价值是用类型安全的方式替代传统 union ,既能灵活存储两种不同的容器类型,又能通过配套的 std::holds_alternative/std::get/std::visit 安全地处理不同类型的逻辑,避免了手动管理类型标识的繁琐和出错风险,实现了 "一个容器位置存储多种类型、且每种类型执行专属逻辑" 的需求。
所以,在这里 std::variant 承载的核心价值就是通过 "链表 / 红黑树的切换规则(元素数≥8)" 这个数学阈值,平衡哈希表的时间 / 空间消耗 ------ 链表(std::list)的优势是插入快、空间开销小,但查找慢(O (n)),适合元素少的场景;红黑树(std::set)的优势是查找快(O (logn)),但插入 / 空间开销大,适合元素多的场景。开发时通过 "8 个元素" 这个数学阈值作为切换条件,用 std::variant 让每个哈希桶根据元素数量动态切换存储类型:元素少的时候用链表省空间、快插入,元素多的时候用红黑树提查找效率,最终实现 "低元素量时控空间消耗,高元素量时控时间消耗" 的平衡,而 std::variant 则是实现这种 "动态类型切换" 的类型安全载体,避免了传统方式(如手动标记类型、强制类型转换)的出错风险。
简单实现原理
std::visit 本质是 "编译期生成类型分发表 + 运行时查表调用",把 variant 的类型索引映射到对应的处理函数。下面我用 "通俗原理 + 简化实现" 的方式,给你讲透它的底层逻辑:
一、std::visit 核心实现原理(大白话版)
-
编译期准备:生成 "类型 - 函数" 映射表 编译器会先分析
variant的类型列表(比如本例中是list<int>和set<int>),以及你传入的访问者(overloaded组合的两个 lambda),为每个类型生成对应的 "处理函数地址",并按类型索引(0 对应 list、1 对应 set)整理成一张 "分发表"。 -
运行时执行:查表 + 调用 程序运行时,
std::visit先获取variant当前存储类型的索引(通过variant.index()),然后到 "分发表" 里找到该索引对应的处理函数,最后把variant里的实际值传给这个函数执行 ------ 整个过程就像 "根据类型编号找对应的工具干活"。
二、简化版实现(帮你理解核心逻辑)
我们用伪代码模拟 std::visit 的核心逻辑,你一看就懂:
cpp
// 模拟 std::variant 的核心结构
template<class... Ts>
struct MyVariant {
size_t index; // 存储当前类型的索引
// 存储实际值的内存(简化版,实际是对齐的内存块)
alignas(Ts...) char data[max_sizeof(Ts...)];
// 获取当前类型索引
size_t get_index() const { return index; }
// 按索引获取值的指针(简化版)
void* get_data() { return data; }
};
// 模拟 std::visit 的核心实现
template<class Visitor, class... Ts>
auto my_visit(Visitor&& visitor, MyVariant<Ts...>& var) {
// 编译期生成:类型索引 → 处理函数 的映射表
using FuncTable = void* (*)[];
static FuncTable table = {
// 对每个类型 Ts,生成"把 var 的值传给 visitor"的函数
[](MyVariant<Ts...>& v) {
return visitor(*static_cast<Ts*>(v.get_data()));
}...
};
// 运行时:根据索引查表,调用对应函数
size_t idx = var.get_index();
return table[idx](var);
}
这个简化版里:
- 编译期:
table数组会被编译器生成,每个元素对应一个类型的处理函数; - 运行时:只需要根据
index取数组元素,调用函数即可,没有多余的if-else分支。
三、结合哈希表案例的具体执行流程
在你的哈希表 find 函数中:
cpp
return std::visit(overloaded{findInList, findInSet}, _tables[hash]);
编译期 :编译器生成一张表,索引 0 对应 findInList(处理 list)、索引 1 对应 findInSet(处理 set);
运行时:
- 先获取
_tables[hash]的索引(0 或 1); - 若索引是 0 → 调用
findInList,把variant里的 list 传给它; - 若索引是 1 → 调用
findInSet,把variant里的 set 传给它; - 最终返回查找结果。
总结
std::visit的核心是编译期生成分发表,运行时快速查表调用 ,比手动写if-else + get_if更高效;- 它的原理本质是 "把类型判断从运行时的分支,提前到编译期的表生成",既保证类型安全,又不损失性能;
- 在哈希表案例中,它的作用就是 "根据 variant 的实际类型(list/set),自动调用对应的查找函数",不用手动写分支判断。
总结
std::variant 作为 C++17 的类型安全联合体,核心只允许存放可析构、可移动 / 拷贝、可实例化的非引用值类型 ,绝对不能存放引用类型(需用std::reference_wrapper包装)、void类型、不完整类型(未定义的结构体)、抽象类(含纯虚函数);同时不建议存放重复类型(如variant<int, int>,会导致std::get编译报错)、无合法移动 / 拷贝语义的复杂类型(易资源泄漏)、超大内存类型(徒增variant内存开销),而 C++17 中还要求其第一个类型必须有默认构造函数(C++20 放宽此限制),你的哈希表案例中list<int>和set<int>因满足 "可默认构造、可移动、尺寸适中" 的要求,是variant的典型合理用法。
所以:
std::variant禁存:引用、void、不完整类型、抽象类;- 不建议存:重复类型、无合法移动 / 拷贝的类型、超大类型;
- 核心要求:存放类型需是可析构、可移动 / 拷贝、可实例化的值类型。