通过std::variant<>
,C++标准库提供了一个新的 联合 类型,
它最大的优势是提供了一种新的具有多态性的处理异质集合的方法。
也就是说,它可以帮助我们处理不同类型的数据,并且不需要公共基类和指针。
起源于C语言,C++也提供对union
的支持,它的作用是持有一个值,
这个值的类型可能是指定的若干类型中的任意 一个 。
然而,这项语言特性有一些缺陷:
-
对象并不知道它们现在持有的值的类型。
-
因此,你不能持有非平凡类型,例如
std::string
(没有进行特殊处理的话)。 -
你不能从
union
派生。
通过std::variant<>
,C++标准库提供了一种 可辨识的联合(closed discriminated union)
(这意味着要指明一个可能的类型列表)
- 当前值的类型已知
- 可以持有任何类型的值
- 可以从它派生
事实上,一个std::variant<>
持有的值有若干 候选项(alternative) ,这些选项通常有不同的类型。
然而,两个不同选项的类型也有可能相同,这在多个类型相同的选项分别代表不同含义的时候很有用
(例如,可能有两个选项类型都是字符串,分别代表数据库中不同列的名称,
你可以知道当前的值代表哪一个列)。
variant
所占的内存大小等于所有可能的底层类型中最大的再加上一个记录当前选项的固定内存开销。
不会分配堆内存。
一般情况下,除非你指定了一个候选项来表示为空,否则variant
不可能为空。然而,在非常罕见的情况下
(例如赋予一个不同类型新值时发生了异常),variant
可能会变为没有值的状态。
和std::optional<>
、std::any
一样,variant
对象是值语义。
也就是说,拷贝被实现为 深拷贝 ,将会创建一个在自己独立的内存空间内存储有当前选项的值的新对象。
然而,拷贝std::variant<>
的开销要比拷贝当前选项的开销稍微大一点,
这是因为variant
必须找出要拷贝哪个值。另外,variant
也支持move语义。
下面的代码展示了std::variant<>
的核心功能:
#include <variant>
#include <iostream>
int main()
{
std::variant<int, std::string> var{"hi"}; // 初始化为string选项
std::cout << var.index() << '\n'; // 打印出1
var = 42; // 现在持有int选项
std::cout << var.index() << '\n'; // 打印出0
...
try {
int i = std::get<0>(var); // 通过索引访问
std::string s = std::get<std::string>(var); // 通过类型访问(这里会抛出异常)
...
}
catch (const std::bad_variant_access& e) { // 当索引/类型错误时进行处理
std::cerr << "EXCEPTION: " << e.what() << '\n';
...
}
}
成员函数index()
可以用来指出当前选项的索引(第一个选项的索引是0)。
初始化和赋值操作都会查找最匹配的选项。如果类型不能精确匹配, 可能会发生奇怪的事情。
注意空variant、有引用成员的variant、有C风格数组成员的variant、
有不完全类型(例如void
)的variant都是不允许的。
variant没有空的状态。这意味着每一个构造好的variant对象,至少调用了一次构造函数。 默认构造函数会调用第一个选项类型的默认构造函数:
std::variant<std::string, int> var; // => var.index()==0, 值==""
如果第一个类型没有默认构造函数,那么调用variant的默认构造函数将会导致编译期错误:
struct NoDefConstr {
NoDefConstr(int i) {
std::cout << "NoDefConstr::NoDefConstr(int) called\n";
}
};
std::variant<NoDefConstr, int> v1; // ERROR:不能默认构造第一个选项
辅助类型std::monostate
提供了处理这种情况的能力,还可以用来模拟空值的状态。
为了支持第一个类型没有默认构造函数的variant,C++标准库提供了一个特殊的辅助类:
std::monostate
。std::monostate
类型的对象总是处于相同的状态。
因此,比较它们的结果总是相等。它的作用是可以作为一个选项,当variant处于这个选项时表示
此variant 没有其他任何类型的值 。
因此,std::monostate
可以作为第一个选项类型来保证variant能默认构造。例如:
std::variant<std::monostate, NoDefConstr, int> v2; // OK
std::cout << "index: " << v2.index() << '\n'; // 打印出0
某种意义上,你可以把这种状态解释为variant为空的信号。
下面的代码展示了几种检测monostate的方法,也同时展示了variant的其他一些操作:
if (v2.index() == 0) {
std::cout << "has monostate\n";
}
if (!v2.index()) {
std::cout << "has monostate\n";
}
if (std::holds_alternative<std::monostate>(v2)) {
std::cout << "has monostate\n";
}
if (std::get_if<0>(&v2)) {
std::cout << "has monostate\n";
}
if (std::get_if<std::monostate>(&v2)) {
std::cout << "has monostate\n";
}
get_if<>()
的参数是一个指针,并在当前选项为T
时返回一个指向当前选项的指针,
否则返回nullptr
。这和get<T>()
不同,后者获取variant的引用作为参数并在
提供的索引或类型正确时以值返回当前选项,否则抛出异常。
和往常一样,你可以赋予variant一个和当前选项类型不同的其他选项的值,
甚至可以赋值为monostate
来表示为空:
v2 = 42;
std::cout << "index: " << v2.index() << '\n'; // index:1
v2 = std::monostate{};
std::cout << "index: " << v2.index() << '\n'; // index: 0
你可以从variant派生。例如,你可以定义如下派生自std::variant<>
的
聚合体:
class Derived : public std::variant<int, std::string> {
};
Derived d = {{"hello"}};
std::cout << d.index() << '\n'; // 打印出:1
std::cout << std::get<1>(d) << '\n'; // 打印出:hello
d.emplace<0>(77); // 初始化为int,销毁string
std::cout << std::get<0>(d) << '\n'; // 打印出:77
这一节详细描述了std::variant<>
类型和操作。
在头文件variant
,C++标准库以如下方式定义了类std::variant<>
:
namespace std {
template<typename... Types> class variant;
// 译者注:此处原文的定义是
// template<typename Types...> class variant;
// 应是作者笔误
}
也就是说,std::variant<>
是一个 可变参数(variadic) 类模板
(C++11引入的处理任意数量参数的特性)。
另外,还定义了下面的类型和对象:
- 类模板
std::variant_size
- 类模板
std::variant_alternative
- 值
std::variant_npos
- 类型
std::monostate
- 异常类
std::bad_variant_access
,派生自std::exception
还有两个为variant定义的变量模板:std::in_place_type<>
和std::in_place_index<>
。
它们的类型分别是std::in_place_type_t
和
std::in_place_index_t
,定义在头文件<utility>
中。
表std::variant
的操作列出了std::variant<>
的所有操作。
操作符 | 效果 |
---|---|
构造函数 | 创建一个variant对象(可能会调用底层类型的构造函数) |
析构函数 | 销毁一个variant对象 |
= |
赋新值 |
emplace<T>() |
销毁旧值并赋一个T 类型选项的新值 |
emplace<Idx>() |
销毁旧值并赋一个索引为Idx 的选项的新值 |
valueless_by_exception() |
返回变量是否因为异常而没有值 |
index() |
返回当前选项的索引 |
swap() |
交换两个对象的值 |
==、!=、<、<=、>、>= |
比较variant对象 |
hash<> |
计算哈希值的函数对象类型 |
holds_alternative<T>() |
返回是否持有类型T的值 |
get<T>() |
返回类型为T 的选项的值 |
get<Idx>() |
返回索引为Idx 的选项的值 |
get_if<T>() |
返回指向类型为T 的选项的指针或nullptr |
get_if<Idx>() |
返回指向索引为Idx 的选项的指针或nullptr |
visit() |
对当前选项进行操作 |
默认情况下,variant的默认构造函数会调用第一个选项的默认构造函数:
std::variant<int, int, std::string> v1; // 第一个int初始化为0,index()==0
选项被默认初始化,意味着基本类型会初始化为0
、false
、nullptr
。
如果传递一个值来初始化,将会使用最佳匹配的类型:
std::variant<long, int> v2{42};
std::cout << v2.index() << '\n'; // 打印出1
然而,如果有两个类型同等匹配会导致歧义:
std::variant<long, long> v3{42}; // ERROR:歧义
std::variant<int, float> v4{42.3}; // ERROR:歧义
std::variant<int, double> v5{42.3}; // OK
std::variant<int, long double> v6{42.3}; // ERROR:歧义
std::variant<std::string, std::string_view> v7{"hello"}; // ERROR:歧义
std::variant<std::string, std::string_view, const char*> v8{"hello"}; // OK
std::cout << v8.index() << '\n'; // 打印出2
为了传递多个值来调用构造函数初始化,你必须使用in_place_type
或者
in_place_index
标记:
std::variant<std::complex<double>> v9{3.0, 4.0}; // ERROR
std::variant<std::complex<double>> v10{{3.0, 4.0}}; // ERROR
std::variant<std::complex<double>> v11{std::in_place_type<std::complex<double>>, 3.0, 4.0};
std::variant<std::complex<double>> v12{std::in_place_index<0>, 3.0, 4.0};
你也可以使用in_place_index
在初始化时解决歧义问题或者打破匹配优先级:
std::variant<int, int> v13{std::in_place_index<1>, 77}; // 初始化第二个int
std::variant<int, long> v14{std::in_place_index<1>, 77}; // 初始化long,而不是int
std::cout << v14.index() << '\n'; // 打印出1
你甚至可以传递一个带有其他参数的初值列:
// 用一个lambda作为排序准则初始化一个set的variant:
auto sc = [] (int x, int y) {
return std::abs(x) < std::abs(y);
};
std::variant<std::vector<int>, std::set<int, decltype(sc)>>
v15{std::in_place_index<1>, {4, 8, -7, -2, 0, 5}, sc};
然而,只有当所有初始值都和容器里元素类型匹配时才可以这么做。
否则你必须显式传递一个std:: initializer_list<>
:
// 用一个lambda作为排序准则初始化一个set的variant
auto sc = [] (int x, int y) {
return std::abs(x) < std::abs(y);
};
std::variant<std::vector<int>, std::set<int, decltype(sc)>>
v15{std::in_place_index<1>, std::initializer_list<int>{4, 5L}, sc};
std::variant<>
不支持类模板参数推导,
也没有make_variant<>()
快捷函数(不像std::optional<>
和std::any
)。这样做也没有意义,因为variant的目标是处理多个候选项。
如果所有的候选项都支持拷贝,那么就可以拷贝variant对象:
struct NoCopy {
NoCopy() = default;
NoCopy(const NoCopy&) = delete;
};
std::variant<int, NoCopy> v1;
std::variant<int, NoCopy> v2{v1}; // ERROR
通常的方法是调用get<>()
或get_if<>
来访问当前选项的值。
你可以传递一个索引、或者传递一个类型(该类型的选项只能有一个)。
使用一个无效的索引和无效/歧义的类型将会导致编译错误。
如果访问的索引或者类型不是当前的选项,将会抛出一个std::bad_variant_access
异常。
例如:
std::variant<int, int, std::string> var; // 第一个int设为0,index()==0
auto a = std::get<double>(var); // 编译期ERROR:没有double类型的选项
auto b = std::get<4>(var); // 编译期ERROR:没有第五个选项
auto c = std::get<int>(var); // 编译期ERROR:有两个int类型的选项
try {
auto s = std::get<std::string>(var); // 抛出异常(当前选项是第一个int)
auto i = std::get<0>(var); // OK,i==0
auto j = std::get<1>(var); // 抛出异常(当前选项是另一个int)
}
catch (const std::bad_variant_access& e) {
std::cout << "Exception: " << e.what() << '\n';
}
还有另一个API可以在访问值之前先检查给定的选项是否是当前选项。
你需要给get_if<>()
传递一个指针,如果访问成功则返回指向当前选项的指针,否则返回nullptr
。
if (auto ip = std::get_if<1>(&var); ip != nullptr) {
std::cout << *ip << '\n';
}
else {
std::cout << "alternative with index 1 not set\n";
}
这里还使用了带初始化的if
语句,把初始化过程和条件检查分成了两条语句。
你也可以直接把初始化语句用作条件语句:
if (auto ip = std::get_if<1>(&var)) {
std::cout << *ip << '\n';
}
else {
std::cout << "alternative with index 1 not set\n";
}
另一种访问不同选项的值的方法是使用variant访问器。
赋值操作和emplace()
函数可以修改值:
std::variant<int, int, std::string> var; // 设置第一个int为0,index()==0
var == "hello"; // 设置string选项,index()==2
var.emplace<1>(42); // 设置第二个int,index()==1
注意operator=
将会直接赋予variant一个新值,只要有和新值类型对应的选项。
emplace()
在赋予新值之前会先销毁旧的值。
你也可以使用get<>()
或者get_if<>()
来给当前选项赋予新值:
std::variant<int, int, std::string> var; // 设置第一个int为0,index()==0
std::get<0>(var) = 77; // OK,但当前选项仍是第一个int
std::get<1>(var) = 99; // 抛出异常(因为当前选项是另一个int)
if (auto p = std::get_if<1>(&var); p) { // 如果第二个int被设置
*p = 42; // 修改值
}
另一个修改不同选项的值的方法是variant访问器。
对两个类型相同的variant(也就是说,它们每个选项的类型和顺序都相同),你可以使用通常的比较运算符。 比较运算将遵循如下规则:
- 当前选项索引较小的小于当前选项索引较大的。
- 如果两个variant当前的选项相同,将调用当前选项类型的比较运算符进行比较。
注意所有的
std::monostate
类型的对象都相等。 - 两个variant都处于特殊状态(
valueless_by_exception()
为真的状态)时相等。 否则,valueless_by_ exception()
返回ture的variant小于另一个。
例如:
std::variant<std::monostate, int, std::string> v1, v2{"hello"}, v3{42};
std::variant<std::monostate, std::string, int> v4;
v1 == v4 // 编译期错误
v1 == v2 // 返回false
v1 < v2 // 返回true
v1 < v3 // 返回true
v2 < v3 // 返回false
v1 = "hello";
v1 == v2 // 返回true
v2 = 41;
v2 < v3 // 返回true
只要所有的选项都支持move语义,那么std::variant<>
也支持move语义。
如果你move了variant对象,那么当前状态会被拷贝,而当前选项的值会被move。 因此,被move的variant对象仍然保持之前的选项,但值会变为未定义。
你也可以把值移进或移出variant对象。
如果所有的选项类型都能计算哈希值,那么variant对象也能计算哈希值。 注意variant对象的哈希值 不 保证是当前选项的哈希值。在某些平台上它是,有些平台上不是。
另一个处理variant对象的值的方法是使用访问器(visitor)。访问器是为每一个可能的类型都提供一个 函数调用运算符的对象。当这些对象“访问”一个variant时,它们会调用和当前选项类型最匹配的函数。
例如:
#include <variant>
#include <string>
#include <iostream>
struct MyVisitor
{
void operator() (int i) const {
std::cout << "int: " << i << '\n';
}
void operator() (std::string s) const {
std::cout << "string: " << s << '\n';
}
void operator() (long double d) const {
std::cotu << "double: " << d << '\n';
}
};
int main()
{
std::variant<int, std::string, double> var(42);
std::visit(MyVisitor(), var); // 调用int的operator()
var = "hello";
std::visit(MyVisitor(), var); // 调用string的operator()
var = 42.7;
std::visit(MyVisitor(), var); // 调用long double的operator()
}
如果访问器没有某一个可能的类型的operator()
重载,那么调用visit()
将会导致
编译期错误,如果调用有歧义的话也会导致编译期错误。这里的示例能正常工作是因为long double
比int
更匹配double
。
你也可以使用访问器来修改当前选项的值(但不能赋予一个其他选项的新值)。 例如:
struct Twice
{
void operator()(double& d) const {
d *= 2;
}
void operator()(int& i) const {
i *= 2;
}
void operator()(std::string& s) const {
s = s + s;
}
};
std::visit(Twice(), var); // 调用匹配类型的operator()
访问器调用时只根据类型判断,你不能对类型相同的不同选项做不同的处理。
注意上面例子中的函数调用运算符都应该标记为const
,因为它们
是 无状态的(stateless) (它们的行为只受参数的影响)。
最简单的使用访问器的方式是使用泛型lambda,它是一个可以处理任意类型的函数对象:
auot printvariant = [] (const auto& val) {
std::cout << val << '\n';
};
...
std::visit(printvariant, var);
这里,泛型lambda生成的闭包类型中会将函数调用运算符定义为模板:
class ComplierSpecificClosureTypeName {
public:
template<typename T>
auto operator() (const T& val) const {
std::cout << val << '\n';
}
};
因此,只要调用时生成的函数内的语句有效(这个例子中就是输出运算符要有效),
那么把lambda传递给std::visit()
就可以正常编译。
你也可以使用lambda来修改当前选项的值:
// 将当前选项的值变为两倍:
std::visit([] (auto& val) {
val = val + val;
}, var);
或者:
// 将当前选项的值设为默认值:
std::visit([] (auto& val) {
val = std::remove_reference_t<decltype(val)>{};
}, var);
你甚至可以使用编译期if
语句来对不同的选项类型进行不同的处理。例如:
auto dblvar = [](auto& val) {
if constexpr(std::is_convertible_v<decltype(val), std::string>) {
val = val + val;
}
else {
val *= 2;
}
};
...
std::visit(dblvar, var);
这里,对于std::string
类型的选项,泛型lambda会把函数调用模板实例化为计算:
val = val + val;
而对于其他类型的选项,例如int
或double
,lambda函数调用模板会实例化为计算:
val *= 2;
注意检查val
的类型时必须小心。
这里,我们检查了val
的类型是否能转换为std::string
。
如下检查:
if constexpr(std::is_same_v<decltype(val), std::string>) {
将不能正确工作,因为val
的类型只可能是int&、std::string&、 long double&
这样的引用类型。
访问器中的函数调用可以返回值,但所有返回值类型必须相同。例如:
using IntOrDouble = std::variant<int, double>;
std::vector<IntOrDouble> coll { 42, 7.7, 0, -0.7 };
double sum{0};
for (const auto& elem : coll) {
sum += std::visit([] (const auto& val) -> double {
return val;
}, elem);
}
上面的代码会把所有选项的值加到sum
上。
如果lambda中没有显式指明返回类型将不能通过编译,因为自动推导的话返回类型会不同。
通过使用函数对象和lambda的 重载器(overloader) ,可以定义一系列lambda, 其中最佳的匹配将会被用作访问器。
假设有一个如下定义的overload
重载器:
// 继承所有基类里的函数调用运算符
template<typename... Ts>
struct overload : Ts...
{
using Ts::operator()...;
};
// 基类的类型从传入的参数中推导
template<typename... Ts>
overload(Ts...) -> overload<Ts...>;
你可以为每个可能的选项提供一个lambda,之后使用overload
来访问variant:
std::variant<int, std::string> var(42);
...
std::visit(overload { // 为当前选项调用最佳匹配的lambda
[](int i) { std::cout << "int: " << i << '\n'; },
[] (const std::string& s) {
std::cout << "string: " << s << '\n';
},
}, var);
你也可以使用泛型lambda,它可以匹配所有情况。例如,要想修改一个variant当前选项的值, 你可以使用重载实现字符串和其他类型值“翻倍”:
auto twice = overload {
[] (std::string& s) { s += s; },
[] (auto& i) { i *= 2; },
};
使用这个重载,对于字符串类型的选项,值将变为原本的字符串再重复一遍; 而对于其他类型,将会把值乘以2。下面展示了怎么应用于variant:
std::variant<int, std::string> var(42);
std::visit(twice, var); // 值42变为84
...
var = "hi";
std::visit(twice, var); // 值"hi"变为"hihi"
如果你赋给一个variant新值时发生了异常,那么这个variant可能会进入一个非常特殊的状态: 它已经失去了旧的值但还没有获得新的值。例如:
struct S {
operator int() { throw "EXCEPTION"; } // 转换为int时会抛出异常
};
std::variant<double, int> var{12.2}; // 初始化为double
var.emplace<1>(S{}); // OOPS:当设为int时抛出异常
如果这种情况发生了,那么:
var.valueless_by_exception()
会返回true
var.index()
会返回std::variant_npos
这些都标志该variant当前没有值。
这种情况下有如下保证:
- 如果
emplace()
抛出异常,那么valueless_by_exception()
可能会返回true
。 - 如果
operator=()
抛出异常且该修改不会改变选项,那么index()
和valueless_by_exception()
的状态将保持不变。值的状态依赖于值类型的异常保证。 - 如果
operator=()
抛出异常且新值是新的选项,那么variant 可能 会没有值 (valueless_by_exception()
可能 会返回true
)。具体情况依赖于异常抛出的时机。 如果发生在实际修改值之前的类型转换期间,那么variant将依然持有旧值。
通常情况下,如果你不再使用这种情况下的variant,那么这些保证就足够了。 如果你仍然想使用抛出了异常的variant,你需要检查它的状态。例如:
std::variant<double, int> var{12.2}; // 初始化为double
try {
var.emplace<1>(S{}); // OOPS:设置为int时抛出异常
}
catch (...) {
if (!var.valueless_by_exception()) {
...
}
}
std::variant
允许一种新式的多态性,可以用来实现异质集合。
这是一种带有闭类型集合的运行时多态性。
关键在于variant<>
可以持有多种选项类型的值。
可以将元素类型定义为variant来实现异质的集合,这样的集合可以持有不同类型的值。
因为每一个variant知道当前的选项,并且有了访问器接口,我们可以定义在运行时根据不同类型
进行不同操作的函数/方法。同时因为variant有值语义,所以我们不需要指针(和相应的内存管理)或者虚函数。
例如,假设我们要负责编写表示几何对象的库:
#include <iostream>
#include <variant>
#include <vector>
#include "coord.hpp"
#include "line.hpp"
#include "circle.hpp"
#include "rectangle.hpp"
// 所有几何类型的公共类型
using GeoObj = std::variant<Line, Circle, Rectangle>;
// 创建并初始化一个几何体对象的集合
std::vector<GeoObj> createFigure()
{
std::vector<GeoObj> f;
f.push_back(Line{Coord{1, 2}, Coord{3, 4}});
f.push_back(Circle{Coord{5, 5}, 2});
f.push_back(Rectangle{Coord{3, 3}, Coord{6, 4}});
return f;
}
int main()
{
std::vector<GeoObj> figure = createFigure();
for (const GeoObj& geoobj : figure) {
std::visit([] (const auto& obj) {
obj.draw(); // 多态性调用draw()
}, geoobj);
}
}
首先,我们为所有可能的类型定义了一个公共类型:
using GeoObj = std::variant<Line, Circle, Rectangle>;
这三个类型不需要有任何特殊的关系。事实上它们甚至没有一个公共的基类、没有任何虚函数、 接口也可能不同。例如:
#ifndef CIRCLE_HPP
#define CIRCLE_HPP
#include "coord.hpp"
#include <iostream>
class Circle {
private:
Coord center;
int rad;
public:
Circle (Coord c, int r) : center{c}, rad{r} {
}
void move(const Coord& c) {
center += c;
}
void draw() const {
std::cout << "circle at " << center << " with radius " << rad << '\n';
}
};
#endif
我们现在可以创建相应的对象并把它们以值传递给容器,最后可以得到这些类型的元素的集合:
std::vector<GeoObj> createFigure()
{
std::vector<GeoObj> f;
f.push_back(Line{Coord{1, 2}, Coord{3, 4}});
f.push_back(Circle{Coord{5, 5}, 2});
f.push_back(Rectangle{Coord{3, 3}, Coord{6, 4}});
return f;
}
以前如果没有使用继承和多态的话是不可能写出这样的代码的。
以前要想实现这样的异构集合,所有的类型都必须继承自GeoObj
,
并且最后将得到一个元素类型为GeoObj
的指针的vector。
为了使用指针,必须用new
创建新对象,这导致最后还要追踪什么时候调用delete
,
或者要使用智能指针来完成(unique_ptr
或者shared_ptr
)。
现在,通过使用访问器,我们可以迭代每一个元素,并依据元素的类型“做正确的事情”:
std::vector<GeoObj> figure = createFigure();
for (const GeoObj& geoobj : figure) {
std::visit([] (const auto& obj) {
obj.draw(); // 多态调用draw()
}, geoobj);
}
这里,visit()
使用了泛型lambda来为每一个可能的GeoObj
类型实例化。
也就是说,当编译visit()
调用时,lambda将会被实例化并编译为3个函数:
- 为类型
Line
编译代码:
[] (const Line& obj) {
obj.draw(); // 调用Line::draw()
}
- 为类型
Circle
编译代码:
[] (const Circle& obj) {
obj.draw(); // 调用Circle::draw()
}
- 为类型
Rectangle
编译代码:
[] (const Rectangle& obj) {
obj.draw(); // 调用Rectangle::draw()
}
如果这些实例中有一个不能编译,那么对visit()
的调用也不能编译。
如果所有实例都能编译,那么将保证会对所有元素类型调用相应的函数。
注意生成的代码并不是 if-else 链。
C++标准保证这些调用的性能不会依赖于variant选项的数量。
也就是说,从效率上讲,这种方式和虚函数表的方式的行为相同
(通过类似于为所有visit()
创建局部虚函数表的方式)。
注意,draw()
函数不需要是虚函数。
如果对不同类型的操作不同,我们可以使用编译期if
语句或
者重载访问器来处理不同的情况(见上边的第二个例子)。
考虑如下另一个使用std::variant<>
实现异质集合的例子:
#include <iostream>
#include <string>
#include <variant>
#include <vector>
#include <type_traits>
int main()
{
using Var = std::variant<int, double, std::string>;
std::vector<Var> values {42, 0.19, "hello world", 0.815};
for (const Var& val : values) {
std::visit([] (const auto& v) {
if constexpr(std::is_same_v<decltype(v), const std::string&>) {
std::cout << '"' << v << "\" ";
}
else {
std::cout << v << ' ';
}
}, val);
}
}
我们又一次定义了自己的类型来表示若干可能类型中的一个:
using Var = std::variant<int, double, std::string>;
我们可以用它创建并初始化一个异质的集合:
std::vector<Var> values {42, 0.19, "hello world", 0.815};
注意我们可以用若干异质的元素来实例化vector,因为它们都能自动转换为variant类型。
然而,如果我们还传递了一个long
类型的初值,上面的初始化将不能编译,
因为编译器不能决定将它转换为int
还是double
。
当我们迭代元素时,我们使用了访问器来调用相应的函数。这里使用了一个泛型lambda。
lambda为3种可能的类型分别实例化了一个函数调用。
为了对字符串进行特殊的处理(在输出值时用双引号包括起来),我们使用了编译期if
语句:
for (const Var& val : values) {
std::visit([] (const auto& v) {
if constexpr(std::is_same_v<decltype(v), const std::string&>) {
std::cout << '"' << v << "\" ";
}
else {
std::cout << v << ' ';
}
}, val);
}
这意味着输出将是:
42 0.19 "hello world" 0.815
通过使用重载的访问器,我们可以像下面这样实现:
for (const auto& val : values) {
std::visit(overload {
[] (const auto& v) {
std::cout << v << ' ';
},
[] (const std::string& v) {
std::cout << '"' << v "\" ";
}
}, val);
}
然而,注意这样可能会陷入重载匹配的问题。有的情况下泛型lambda(即函数模板) 匹配度比隐式类型更高,这意味着可能会调用错误的类型。
让我们来总结一下使用std::variant
实现多态的异构集合的优点和缺点:
优点有:
- 你可以使用任意类型并且这些类型不需要有公共的基类(这种方法是非侵入性的)
- 你不需要使用指针来实现异质集合
- 不需要
virtual
成员函数 - 值语义(不会出现访问已释放内存或内存泄露等问题)
- vector中的元素是连续存放在一起的(原本指针的方式所有元素是散乱分布在堆内存中的)
缺点有:
- 闭类型集合(你必须在编译期指定所有可能的类型)
- 每个元素的大小都是所有可能的类型中最大的(当不同类型大小差距很大时这是个问题)
- 拷贝元素的开销可能会更大
一般来说,我并不确定是否要推荐默认使用std::variant<>
来实现多态。
一方面这种方法很安全(没有指针,意味着没有new
和delete
),
也不需要虚函数。然而另一方面,使用访问器有一些笨拙,有时你可能会需要引用语义
(在多个地方使用同一个对象),还有在某些情形下并不能在编译期确定所有的类型。
性能开销也有很大不同。没有了new
和delete
可能会减少很大开销。
但另一方面,以值传递对象又可能会增大很多开销。
在实践中,你必须自己测试对你的代码来说哪种方法效率更高。
在不同的平台上,我已经观测到性能上的显著差异了。
特定类型的variant可能导致特殊或者出乎意料的行为。
如果一个std::variant<>
同时有bool
和std::string
选项,
赋予一个字符串字面量可能会导致令人惊奇的事,因为字符串字面量会优先转换为bool
,
而不是std::string
。例如:
std::variant<bool, std::string> v;
v = "hi"; // OOPS:设置bool选项
std::cout << "index: " << v.index() << '\n';
std::visit([] (const auto& val) {
std::cout << "value: " << val << '\n';
}, v);
这段代码片段将会有如下输出:
index: 0
value: true
可以看出,字符串字面量会被解释为把variant的bool选项初始化为true
(因为指针不是0
所以是true
)。
这里有一些修正这个赋值问题的方法:
v.emplace<1>("hello"); // 显式赋值给第二个选项
v.emplace<std::string>("hello"); // 显式赋值给string选项
v = std::string{"hello"}; // 确保用string赋值
using namespace std::literals; // 确保用string赋值
v = "hello"s;
参见https://wg21.link/p0608进一步了解关于这个问题的讨论。