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 的构造 / 析构函数是编译器自动生成的,它只会分配内存,但不会调用成员的构造 / 析构函数。比如:
// C++11 前编译报错! union BadUnion { int i; std::string s; // std::string 有自定义构造/析构/拷贝 };如果允许,BadUnion的析构函数不知道该调用int还是std::string的析构函数,std::string的堆内存会泄漏,导致资源管理崩溃。
C++11 及之后:允许包含非平凡类型,但需要手动管理构造 / 析构,极其繁琐且容易出错:
#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。
🛠️ 定义和赋值修改
#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 开始)。
// 格式: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支持直接赋值,但只能赋值为定义时指定的类型,赋值后会自动切换内部存储的类型。
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()成员函数可获取当前存储类型的索引,验证赋值是否成功切换类型:
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异常。
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。
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,它在代码的健壮性、可维护性和表达力上都有明显优势。
#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的不同类型。(其实就是上面的方法,就是写法的差别而已)
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()暴露出来,让编译器能根据参数类型匹配对应的重载版本。(这才是重点)
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 的模板参数”。
第一步:先看 “有成员变量” 的正常情况(编译器能推导)
假设我们写一个简单的模板结构体,里面有成员变量:
// 模板结构体:有一个成员变量,类型是 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结构体,它的定义是:template<class... Ts> struct overloaded : Ts... { // 只有基类 Ts...,没有任何成员变量 using Ts::operator()...; };当你写
overloaded{lambda1, lambda2}时,问题就来了:编译器的思考过程(C++17):
- “我要推导 overloaded 的模板参数 Ts...,首先找它的成员变量…… 哦,它没有成员变量!”
- “那我该从哪找 Ts... 的类型?基类列表?不行,规则说我只看成员变量,不看基类!”
- “完了,找不到匹配的模板参数,报错!”
通俗比喻:这就像你去买奶茶,店员只认 “菜单上的选项”(成员变量),不认 “赠品”(基类)。你指着赠品说 “我要这个”,店员会说 “我不知道这是什么,没法下单”—— 编译器就是这个店员,它只看成员变量,不认基类,所以推导失败。
第三步:推导指南的作用(给编译器 “开特例”)
我们写的推导指南:
template<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 情况:初始化参数类型 → 模板参数(需要推导指南教编译器)
🧩 综合案例(简化)
#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的核心逻辑,你一看就懂:
// 模拟 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函数中:
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、不完整类型、抽象类;- 不建议存:重复类型、无合法移动 / 拷贝的类型、超大类型;
- 核心要求:存放类型需是可析构、可移动 / 拷贝、可实例化的值类型。