8. std::variant
# 8. std::variant
C++17中另一个实用的包装类型是std::variant
。这是一种类型安全的联合体(union),你可以存储不同类型的变体,并且能确保对象生命周期的正确性。与C风格的联合体相比,这个新类型具有巨大优势。你可以在其中存储各种类型,无论是像int
或float
这样的简单类型,还是像std::vector<std::string>
这样的复杂实体。在所有情况下,对象都会被正确初始化和清理。
至关重要的是,这个新类型增强了设计模式的实现。例如,现在你可以更轻松地对不相关的类型层次结构使用访问者模式、模式匹配和运行时多态性。
在本章中,你将了解:
- 联合体可能会出现哪些问题
- 什么是带标签的联合体(discriminated unions),以及为什么我们需要联合体的类型安全性
std::variant
的工作原理和作用- 对
std::variant
的操作 - 性能开销和内存需求
- 示例用例
# 基础知识
联合体在客户端代码中很少使用,大多数情况下应该避免使用。例如,在浮点运算中有一个 “常见” 的技巧:
union SuperFloat {
float f;
int i;
};
int RawMantissa(SuperFloat f) {
return f.i & ((1 << 23) - 1);
}
int RawExponent(SuperFloat f) {
return (f.i >> 23) & 0xFF;
}
2
3
4
5
6
7
8
9
10
11
12
然而,虽然上述代码在C99中可能有效,但由于C++中更严格的别名规则,这属于未定义行为!
关于这一点,现有一条C++核心准则(Core Guideline Rule)C.183 (opens new window):
C.183:不要使用联合体进行类型双关(type punning)。 读取联合体成员时使用与写入时不同的类型,这属于未定义行为。这种双关行为不易察觉,或者至少比使用命名转换(named cast)更难发现。使用联合体进行类型双关是错误的来源。 |
---|
在C++17之前,如果你想要一个类型安全的联合体,可以使用boost::variant
或其他第三方库。但现在有了std::variant
。
下面是一个展示这个新类型功能的示例:
Chapter Variant/variant_demo.cpp
#include <string>
#include <iostream>
#include <variant>
using namespace std;
// 用于打印当前活动的类型
struct PrintVisitor {
void operator ()(int i) { cout << "int: " << i << '\n'; }
void operator ()(float f) { cout << "float: " << f << '\n'; }
void operator ()(const string& s) { cout << "str: " << s << '\n'; }
};
int main() {
variant<int, float, string> intFloatString;
static_assert(variant_size_v<decltype(intFloatString)> == 3);
// 默认初始化为第一个可选类型,应该是0
visit(PrintVisitor{}, intFloatString);
// index将显示当前使用的“类型”
cout << "index = " << intFloatString.index() << endl;
intFloatString = 100.0f;
cout << "index = " << intFloatString.index() << endl;
intFloatString = "hello super world";
cout << "index = " << intFloatString.index() << endl;
// 尝试使用get_if:
if (const auto intPtr = get_if<int>(&intFloatString))
cout << "int: " << *intPtr << '\n';
else if (const auto floatPtr = get_if<float>(&intFloatString))
cout << "float: " << *floatPtr << '\n';
if (holds_alternative<int>(intFloatString))
cout << "the variant holds an int!\n";
else if (holds_alternative<float>(intFloatString))
cout << "the variant holds a float\n";
else if (holds_alternative<string>(intFloatString))
cout << "the variant holds a string\n";
// try/catch和bad_variant_access
try {
auto f = get<float>(intFloatString);
cout << "float! " << f << '\n';
}
catch (bad_variant_access&) {
cout << "our variant doesn't hold float at this moment...\n";
}
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
输出:
int: 0
index = 0
index = 1
index = 2
the variant holds a string
our variant doesn't hold float at this moment...
2
3
4
5
6
上面的示例中有几点值得研究:
- 第15、19行:如果你没有给变体(variant)初始化一个值,那么变体将使用第一个类型进行初始化。在这种情况下,第一个可选类型必须有一个默认构造函数。第22行将打印值0。
- 第22、24、26、34、36、38行:你可以通过
index()
或holds_alternative
检查当前使用的类型是什么。 - 第29、31行:你可以使用
get_if
访问值,当类型不是当前活动类型时,它会返回空指针。 - 第43、46行:你可以使用
get
访问值(编译器可能会抛出bad_variant_access
异常)。 - 类型安全性——变体不允许你获取非当前活动类型的值。
- 不会发生额外的堆内存分配。
- 第8、19行:你可以使用访问者(visitor)对当前活动类型执行一个操作。该示例使用
PrintVisitor
打印当前活动的值。它是一个简单的结构体,对operator()
进行了重载。然后将访问者传递给std::visit
来执行访问操作。 variant
类会调用非平凡类型(non-trivial types)的析构函数和构造函数,所以在示例中,在我们切换到新的变体之前,字符串对象会被清理。
# 何时使用
除非你在做一些底层的工作,并且可能只涉及简单类型,否则联合体可能是一个合理的选择²。但对于所有其他需要可选类型的用例,std::variant
才是正确的选择。
一些可能的使用场景:
- 在所有可能为单个字段获取几种类型的地方,比如解析命令行、ini文件、语言解析器等。
- 高效表达计算的几种可能结果,比如求方程的根。
- 错误处理——例如,你可以返回
variant<Object, ErrorCode>
。如果有值可用,就返回Object
,否则分配一些错误码。 - 有限状态机(Finite State Machines)。
- 无需虚函数表(vtables)和继承的多态性(借助访问者模式)。
# 函数式编程背景
值得一提的是,变体类型(也称为带标签的联合体、带区分的联合体或和类型)源自函数式编程语言和类型理论³。
# std::variant的创建
有几种方法可以创建和初始化std::variant
:
Chapter Variant/variant_creation.cpp
// 默认初始化:(第一个类型必须有默认构造函数)
std::variant<int, float> intFloat;
std::cout << intFloat.index() << ", val: " << std::get<int>(intFloat) << '\n';
// 使用monostate进行默认初始化:
class NotSimple {
public:
NotSimple(int, float) {}
};
// std::variant<NotSimple, int> cannotInit; // 错误
std::variant<std::monostate, NotSimple, int> okInit;
std::cout << okInit.index() << '\n';
// 传递一个值:
std::variant<int, float, std::string> intFloatString { 10.5f };
std::cout << intFloatString.index()
<< ", value " << std::get<float>(intFloatString) << '\n';
// 存在歧义
// double可能转换为float或int,所以编译器无法决定
//std::variant<int, float, std::string> intFloatString { 10.5 };
// 使用in_place解决歧义
variant<long, float, std::string> longFloatString {
std::in_place_index<1>, 7.6 // double!
};
std::cout << longFloatString.index() << ", value "
<< std::get<float>(longFloatString) << '\n';
// 对复杂类型使用in_place
std::variant<std::vector<int>, std::string> vecStr {
std::in_place_index<0>, { 0, 1, 2, 3 }
};
std::cout << vecStr.index() << ", vector size "
<< std::get<std::vector<int>>(vecStr).size() << '\n';
// 从其他变体进行拷贝初始化:
std::variant<int, float> intFloatSecond { intFloat };
std::cout << intFloatSecond.index() << ", value "
<< std::get<int>(intFloatSecond) << '\n';
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
- 默认情况下,变体对象使用第一个类型进行初始化
- 如果该类型没有默认构造函数,无法进行初始化,你会得到一个编译错误
- 在这种情况下,你可以使用
std::monostate
作为第一个类型 std::monostate
允许你构建一个 “无值” 的变体,因此它的行为可以类似于std::optional
- 你可以使用一个值进行初始化,然后会使用最匹配的类型
- 如果存在歧义,你可以使用
std::in_place_index
版本显式指定应该使用的类型
- 如果存在歧义,你可以使用
std::in_place
还允许你创建更复杂的类型,并向构造函数传递更多参数
# 关于std::monostate
在示例中,你可能注意到了一个名为std::monostate
的特殊类型。这只是一个空类型,可与变体(variant)一起使用,用于表示空状态。当第一个可选类型没有默认构造函数时,这个类型会很有用。在这种情况下,你可以将std::monostate
作为第一个可选类型(或者也可以调整类型顺序,找到有默认构造函数的类型)。
# 就地构造
std::variant
有两个就地构造辅助函数可供使用:
std::in_place_type
- 用于指定你想要在变体中更改/设置的类型。std::in_place_index
- 用于指定你想要更改/设置的索引。类型从0开始编号。在std::variant<int, float, std::string>
变体中,int
的索引为0,float
的索引为1,string
的索引为2。该索引与variant::index
方法返回的值相同 。
幸运的是,并不总是需要使用这些辅助函数来创建变体。它足够智能,能够识别是否可以从传入的单个参数进行构造:
// 这会构造第二个类型/float:
std::variant<int, float, std::string> intFloatString { 10.5f };
2
对于变体,至少在以下两种情况下需要使用这些辅助函数:
- 模糊性 - 当有多个类型都可能匹配时,用于区分应该创建哪种类型。
- 高效的复杂类型创建(类似于
std::optional
)。
# 模糊性
如果你有如下的初始化操作会怎样:
std::variant<int, float> intFloat { 10.5 }; // 从double转换?
值10.5
既可以转换为int
,也可以转换为float
,编译器不知道应该应用哪种转换,可能会报出好几页的编译错误。你可以通过指定想要创建的类型轻松处理此类错误:
std::variant<int, float> intFloat { std::in_place_index<0>, 10.5 };
// 或者
std::variant<int, float> intFloat { std::in_place_type<int>, 10.5 };
2
3
# 复杂类型
与std::optional
类似,如果你想要高效地创建需要多个构造函数参数的对象,那么可以使用std::in_place_index
或std::in_place_type
:
例如:
std::variant<std::vector<int>, std::string> vecStr {
std::in_place_index<0>, { 0, 1, 2, 3 } // 初始化传入vector的列表
};
2
3
# 不必要的类型转换和窄化
在最初的实现中,std::variant
使用常规的C++规则来处理构造函数和赋值运算符的转换。在需要进行转换的情况下,编译器倾向于选择窄化转换,而这可能并非你所期望的。
例如:
std::variant<std::string, int, bool> vStrIntBool = "Hello World";
上面这行代码创建了一个以bool
为活动类型的变体,而不是std::string
。字符串字面量"Hello World"
并非vStrIntBool
中出现的精确类型,因此必须进行转换。编译器看到两种可能的转换:一种是从const char*
转换为bool
,另一种是从const char*
转换为std::string
。由于bool
是内置类型,编译器会选择它。
还有一个窄化转换的例子:
variant<float, long, double> v = 0;
在修复之前,这行代码无法编译(因为存在多种可能的窄化转换),但在改进之后,它会存储long
类型的值。
通过P0608:合理的变体转换构造函数 (opens new window)中的修复,实现得到了改进,自GCC 10.0起可用。
下面的表格展示了对于给定表达式会选择哪种类型,包含修复(P0608)前后的情况:
表达式 | 修复前 | 修复后 |
---|---|---|
1. variant<bool, string> v = "Hello" | bool | string |
2. variant<float, optional<double>> x = 10.05 | float | optional |
3. variant<float, char> v = 0 | 格式错误 | 格式错误 |
4. variant<float, long> v = 0 | 格式错误 | 选择long |
注释: |
- 现在不再考虑窄化到
bool
的转换,而是选择string
。 - 优先选择非窄化转换为
std::optional
。 - 由于两种转换都需要窄化,所以整个表达式无法编译。
- 在修复之前,两种转换都是可能的;修复之后,不再考虑
float
类型的转换。
尽量匹配std::variant
中已有的精确类型,以减少意外转换的情况。
# 更改值
有四种方法可以更改变体的当前值:
- 赋值运算符
emplace
- 使用
get
获取当前活动类型,然后为其分配新值 - 访问者(visitor,不能更改类型,但可以更改当前可选类型的值)
重要的是要知道,所有操作都是类型安全的,并且对象的生命周期也会得到妥善处理。
// 位于Chapter Variant/variant_changing_values.cpp
std::variant<int, float, std::string> intFloatString { "Hello" };
intFloatString = 10; // 现在是int类型
intFloatString.emplace<2>(std::string("Hello")); // 现在又变回string类型
// std::get返回一个引用,所以你可以更改值:
std::get<std::string>(intFloatString) += std::string(" World");
intFloatString = 10.1f;
if (auto pFloat = std::get_if<float>(&intFloatString); pFloat)
*pFloat *= 2.0f;
2
3
4
5
6
7
8
9
10
# 对象生命周期
当使用联合(union
)时,需要管理内部状态:调用构造函数或析构函数。这很容易出错,并且容易给自己带来麻烦。但std::variant
会按照预期处理对象的生命周期。这意味着,如果它即将更改当前存储的类型,那么会调用底层类型的析构函数。
std::variant<std::string, int> v { "Hello A Quite Long String" };
// v为字符串分配了一些内存
v = 10; // 我们调用了字符串的析构函数!
// 没有内存泄漏
2
3
4
或者看这个自定义类型的示例,代码位于Chapter Variant/variant_lifetime.cpp
:
class MyType {
public:
MyType() { std::cout << "MyType::MyType\n"; }
~MyType() { std::cout << "MyType::~MyType\n"; }
};
class OtherType {
public:
OtherType() { std::cout << "OtherType::OtherType\n"; }
~OtherType() { std::cout << "OtherType::~OtherType\n"; }
};
int main() {
std::variant<MyType, OtherType> v;
v = OtherType();
return 0;
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
这将产生以下输出:
MyType::MyType
OtherType::OtherType
MyType::~MyType
OtherType::~OtherType
OtherType::~OtherType
2
3
4
5
一开始,我们使用MyType
类型的默认值进行初始化;然后用OtherType
的实例更改值,在赋值之前,会调用MyType
的析构函数。之后,我们销毁临时对象和存储在变体中的对象。
# 访问存储的值
从目前你看到的所有示例中,你可能已经对如何访问值有了一些想法。但让我们总结一下这个重要的操作。
首先,即使你知道当前活动的类型是什么,你也不能这样做:
std::variant<int, float, std::string> intFloatString { "Hello" };
std::string s = intFloatString;
// 错误: 从
// 'std::variant<int, float, std::string>'
// 转换为非标量类型'std::string' 被请求 // std::string s = intFloatString;
2
3
4
5
所以你必须使用辅助函数来访问值。
你可以使用std::get<Type|Index>(variant)
,这是一个非成员函数。如果目标类型是活动类型,它会返回对该类型的引用(你可以传入类型或索引)。否则,你会得到std::bad_variant_access
异常。
std::variant<int, float, std::string> intFloatString;
try {
auto f = std::get<float>(intFloatString);
std::cout << "float! " << f << '\n';
} catch (std::bad_variant_access&) {
std::cout << "我们的variant此时不包含float类型的值...\n";
}
2
3
4
5
6
7
下一个选择是std::get_if
。这个函数也是非成员函数,并且不会抛出异常。它返回指向活动类型的指针,或者nullptr
。std::get
需要variant
的引用,而std::get_if
接受指针。
if (const auto intPtr = std::get_if<0>(&intFloatString))
std::cout << "int!" << *intPtr << '\n';
2
然而,访问variant
内部值的最重要方式可能是使用访问者(visitors)。
# std::variant
的访问者
随着std::variant
的引入,我们也有了一个方便的STL函数std::visit
。
它可以对所有传入的variant
调用给定的“访问者”。以下是它的声明:
template <class Visitor, class... Variants>
constexpr visit(Visitor&& vis, Variants&&... vars);
2
它会对variant
的当前活动类型调用vis
。
如果你只传入一个variant
,那么你必须为该variant
中的类型提供重载。如果你传入两个variant
,那么你必须为这些variant
中所有可能的类型对提供重载。
访问者是“一个可调用对象,它能接受每个variant
中的所有可能替代类型”。让我们看一些例子:
// 一个通用lambda表达式:
auto PrintVisitor = [](const auto& t) { std::cout << t << '\n'; };
std::variant<int, float, std::string> intFloatString { "Hello" };
std::visit(PrintVisitor, intFloatString);
2
3
4
在上面的例子中,一个通用lambda表达式用于生成所有可能的重载。由于variant
中的所有类型都支持<<
(流输出操作符),所以我们可以打印它们。
在另一个例子中,我们可以使用访问者来修改值:
auto PrintVisitor = [](const auto& t) { std::cout << t << '\n'; };
auto TwiceMoreVisitor = [](auto& t) { t*= 2; };
std::variant<int, float> intFloat { 20.4f };
std::visit(PrintVisitor, intFloat);
std::visit(TwiceMoreVisitor, intFloat);
std::visit(PrintVisitor, intFloat);
2
3
4
5
6
如果我们的类型具有相同的“接口”,通用lambda表达式就能起作用,但在大多数情况下,我们希望根据活动类型执行不同的操作。
这就是为什么我们可以定义一个结构体,为operator()
提供几个重载:
struct MultiplyVisitor {
float mFactor;
MultiplyVisitor(float factor) : mFactor(factor) { }
void operator ()(int& i) const {
i *= static_cast<int>(mFactor);
}
void operator ()(float& f) const { f *= mFactor; }
void operator ()(std::string& ) const {
// 这里无需操作...
}
};
std::visit(MultiplyVisitor(0.5f), intFloat);
std::visit(PrintVisitor, intFloat);
2
3
4
5
6
7
8
9
10
11
12
13
14
在这个例子中,你可能注意到MultiplyVisitor
使用一个状态来保存所需的缩放因子值。这为访问操作提供了很多选择。
使用lambda表达式时,我们习惯在使用的地方声明它们。当你需要编写一个单独的结构体时,就需要跳出局部作用域。这就是为什么使用重载构造可能会很方便。
# 重载
使用这个工具,你可以在一个地方为所有匹配类型编写所有lambda表达式:
std::variant<int, float, std::string> myVariant;
std::visit(
overload {
[](const int& i) { std::cout << "int: " << i; },
[](const std::string& s) { std::cout << "string: " << s; },
[](const float& f) { std::cout << "float: " << f; }
},
myVariant );
2
3
4
5
6
7
8
目前这个辅助工具还不是标准库的一部分(可能会在C++20中添加)。你可以用以下代码实现它:
template <class... Ts> struct overload : Ts... { using Ts::operator()...; };
template <class... Ts> overload(Ts...) -> overload<Ts...>;
2
这段代码创建了一个从lambda表达式继承的结构体,并使用它们的Ts::operator()
。现在整个结构体可以传递给std::visit
,然后它会选择合适的重载。
overload
使用了C++17的三个特性:
- 在
using
声明中的参数包展开(Pack expansions)——使用可变参数模板的简洁语法。 - 自定义模板参数推导规则——这使得编译器能够推导作为模式基类的lambda表达式的类型。没有它,我们就必须定义一个“生成”函数。
- 聚合初始化(aggregate Initialisation)的扩展——
overload
模式使用聚合初始化来初始化基类。在C++17之前,这是不可能的。
下面是另一个如何使用overload
模式的例子:
// Variant/variant_overload.cpp
std::variant<int, float, std::string> intFloatString { "Hello" };
std::visit(overload{
[](int& i) { i*= 2; },
[](float& f) { f*= 2.0f; },
[](std::string& s) { s = s + s; }
}, intFloatString);
std::visit(PrintVisitor, intFloatString);
// 输出: "HelloHello"
2
3
4
5
6
7
8
9
关于std::overload
提案的相关论文是:P0051 - C++ 通用重载函数 (opens new window) 。
你可以在bfilipek.com的这篇博客文章中了解更多关于overload
模式的机制:2行代码与3个C++17特性 - overload
模式 (opens new window) 。
# 访问多个variant
std::visit
不仅允许你访问一个variant
,还允许在一次调用中访问多个。然而,必须知道的是,对于多个variant
,你必须实现函数重载,其参数数量与输入的variant
数量相同,并且你必须提供所有可能的类型组合。
例如,对于:
std::variant<int, float, char> v1 { 's' };
std::variant<int, float, char> v2 { 10 };
2
如果你对这两个variant
调用std::visit
,就必须提供9个函数重载:
std::visit(overload{
[](int a, int b) { },
[](int a, float b) { },
[](int a, char b) { },
[](float a, int b) { },
[](float a, float b) { },
[](float a, char b) { },
[](char a, int b) { },
[](char a, float b) { },
[](char a, char b) { }
}, v1, v2);
2
3
4
5
6
7
8
9
10
11
如果你跳过一个重载,编译器就会报告错误。
看一下下面的例子,每个variant
代表一种配料,我们想把其中两种配料组合在一起:
// Variant/visit_multiple.cpp
#include <iostream>
#include <variant>
template <class... Ts> struct overload : Ts... { using Ts::operator()...; };
template <class... Ts> overload(Ts...) -> overload<Ts...>;
struct Pizza { };
struct Chocolate { };
struct Salami { };
struct IceCream { };
int main() {
std::variant<Pizza, Chocolate, Salami, IceCream> firstIngredient{IceCream()};
std::variant<Pizza, Chocolate, Salami, IceCream> secondIngredient{Chocolate()};
std::visit(overload{
[](const Pizza& p, const Salami& s) {
std::cout << "给你,披萨加萨拉米香肠!\n";
},
[](const Salami& s, const Pizza& p) {
std::cout << "给你,披萨加萨拉米香肠!\n";
},
[](const Chocolate& c, const IceCream& i) {
std::cout << "巧克力加冰淇淋!\n";
},
[](const IceCream& i, const Chocolate& c) {
std::cout << "冰淇淋加一点巧克力!\n";
},
[](const auto& a, const auto& b) {
std::cout << "无效的组合...\n";
},
}, firstIngredient, secondIngredient);
return 0;
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
代码将输出:冰淇淋加一点巧克力!
上述代码使用了overload
,并使用多个lambda表达式,而不是为operator()
编写一个单独的重载结构体。
有趣的是,这个例子只为“有效的”配料组合提供了实现,而“其余的”由通用lambda表达式(来自C++14)处理。
一个通用lambda表达式[](const auto& a, const auto& b) { }
等同于以下可调用类型:
class UnnamedUniqueClass { // << 编译器特定的名称...
public:
template <typename T, typename U>
auto operator () (const T& a, const T& b) const { }
};
2
3
4
5
示例中使用的通用lambda表达式将为配料类型提供所有剩余的函数重载。由于它是一个模板,在确定最佳可行函数时,它总是排在具体的重载(带有具体类型的lambda表达式)之后。
# std::variant的其他操作
为完整性考虑,补充如下内容:
- 可以比较两个相同类型的
variant
:- 如果它们包含相同的活动替代类型,那么会调用相应的比较运算符。
- 如果一个
variant
的活动替代类型在定义顺序上“靠前”,那么它“小于”活动替代类型在其后的variant
。
variant
是一个值类型,因此可以对其进行移动操作。- 如果
std::hash
对variant
的所有替代类型都可用,那么std::hash
针对variant
进行了特化。该哈希值可能与活动类型的哈希值不同,这使得可以区分具有重复类型的variant
,比如std::variant<int, int, float>
。
# 异常安全保证
到目前为止,一切看起来都很好且顺利…… 但是当在variant
中创建替代类型时发生异常会怎样呢?
例如:
Chapter Variant/variant_valueless.cpp
class ThrowingClass {
public:
explicit ThrowingClass(int i) {
if (i == 0) throw int(10);
}
operator int() {
throw int(10);
}
};
int main(int argc, char** argv) {
std::variant<int, ThrowingClass> v;
// 修改值:
try {
v = ThrowingClass(0);
}
catch (...) {
std::cout << "catch(...)\n ";
// 我们保持旧状态!
std::cout << v.valueless_by_exception() << '\n ';
std::cout << std::get<int>(v) << '\n ';
}
// 在emplace内部
try {
v.emplace<0>(ThrowingClass(10)); // 调用operator int
}
catch (...) {
std::cout << "catch(...)\n ";
// 旧状态已被销毁,所以我们不会处于无效状态!
std::cout << v.valueless_by_exception() << '\n ';
}
return 0;
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
在第一种情况下(使用赋值运算符),异常在类型的构造函数中抛出。这发生在variant
中的旧值被替换之前,所以variant
的状态保持不变。正如你所见,我们仍然可以访问并打印int
类型的值。
然而,在第二种情况下(使用emplace
),异常在variant
的旧状态被销毁之后抛出。emplace
调用operator int
来替换值,但这会抛出异常。之后,variant
处于错误状态,我们无法恢复之前的状态。
还要注意,因异常而无值(valueless by exception
)的variant
处于无效状态,无法从这样的variant
中访问值。这就是为什么variant::index
返回variant_npos
,并且std::get
和std::visit
会抛出bad_variant_access
异常。
# 性能与内存考量
std::variant
使用内存的方式与union
类似:它会占用底层类型中最大的空间。但由于我们需要有机制来确定当前的活动替代类型,所以还需要额外使用一些空间。另外,所有内容都需要遵循对齐规则。
以下是一些基本的大小示例:
Chapter Variant/variant_sizeof.cpp
std::cout << "sizeof string: "
<< sizeof(std::string) << '\n ';
std::cout << "sizeof variant<int, string>: "
<< sizeof(std::variant<int, std::string>) << '\n ';
std::cout << "sizeof variant<int, float>: "
<< sizeof(std::variant<int, float>) << '\n ';
std::cout << "sizeof variant<int, double>: "
<< sizeof(std::variant<int, double>) << '\n ';
2
3
4
5
6
7
8
在GCC 8.1(32位)环境下:
sizeof string: 32
sizeof variant<int, string>: 40
sizeof variant<int, float>: 8
sizeof variant<int, double>: 16
2
3
4
更有趣的是,std::variant
不会分配任何额外空间!不会为存储variant
或鉴别器进行动态内存分配。
为了拥有一个安全的联合类型,需要付出增加内存占用的代价。这些额外的位可能会影响CPU缓存。这就是为什么对于应用程序中使用variant
的热点部分,你可能需要进行一些基准测试。
# 从boost::variant迁移
Boost Variant大约在2004年被引入,因此在std::variant
被添加到标准库之前,它已有13年的应用经验。标准库中的std::variant
借鉴了Boost版本的经验并进行了改进。
主要变化如下:
特性 | Boost.Variant (1.67.0)⁷ | std::variant |
---|---|---|
额外内存分配 | 在赋值时可能发生,见《设计概述 - 从不为空 (opens new window)》⁸ | 否 |
访问 | apply_visitor | std::visit |
通过索引获取 | 否 | 是 |
递归变体 | 是,见make_recursive_variant (opens new window) | 否 |
重复条目 | 否 | 是 |
空替代类型 | boost::blank | std::monostate |
# std::variant的示例
在了解了std::variant
的大部分细节之后,现在我们来探究一些示例。
# 错误处理
基本思路是用某种ErrorCode
包装可能的返回类型,这样函数就能输出关于错误的更多信息,而无需使用异常或输出参数。这与std::expected
(计划在未来C++标准中引入的新类型)的思路类似。
Chapter Variant/variant_error_handling.cpp
enum class ErrorCode {
Ok,
SystemError,
IoError,
NetworkError
};
std::variant<std::string, ErrorCode> FetchNameFromNetwork(int i) {
if (i == 0)
return ErrorCode::SystemError;
if (i == 1)
return ErrorCode::NetworkError;
return std::string("Hello World!");
}
int main() {
auto response = FetchNameFromNetwork(0);
if (std::holds_alternative<std::string>(response))
std::cout << std::get<std::string>(response) << "n";
else
std::cout << "Error!\n ";
response = FetchNameFromNetwork(10);
if (std::holds_alternative<std::string>(response))
std::cout << std::get<std::string>(response) << "n";
else
std::cout << "Error!\n ";
return 0;
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
在这个示例中,返回的是ErrorCode
或常规类型。
# 解析命令行
命令行可能包含文本参数,这些参数可以有多种解释方式:
- 作为整数;
- 作为浮点数;
- 作为布尔标志;
- 作为字符串(未解析);
- 或其他一些类型……
我们可以构建一个variant
来容纳所有可能的选项。下面是一个包含int
、float
和string
的简单版本:
Chapter Variant/variant_parsing_int_float.cpp
class CmdLine {
public:
using Arg = std::variant<int, float, std::string>;
private:
std::map<std::string, Arg> mParsedArgs;
public:
explicit CmdLine(int argc, const char** argv) {
ParseArgs(argc, argv);
}
std::optional<Arg> Find(const std::string& name) const;
//...
};
2
3
4
5
6
7
8
9
10
11
12
解析代码如下:
Chapter Variant/variant_parsing_int_float.cpp
CmdLine::Arg TryParseString(std::string_view sv) {
// 先尝试解析为float
float fResult = 0.0f;
const auto last = sv.data() + sv.size();
const auto res = std::from_chars(sv.data(), last, fResult);
if (res.ec != std::errc{} || res.ptr != last) {
// 如果无法解析为float,就假定它是一个字符串
return std::string{sv};
}
// 没有小数部分?那么直接转换为整数
if (static_cast<int>(fResult) == fResult)
return static_cast<int>(fResult);
return fResult;
}
void CmdLine::ParseArgs(int argc, const char** argv) {
// 格式:-argName value -argName value
for (int i = 1; i < argc; i += 2) {
if (argv[i][0] != '-') // 超高级模式匹配! :)
throw std::runtime_error("错误的命令名");
mParsedArgs[argv[i] + 1] = TryParseString(argv[i + 1]);
}
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
在撰写本文时,GCC/Clang中的std::from_chars
仅支持整数。从Visual Studio 2017 15.8版本开始,MSVC对浮点数也提供了完整支持。你可以在单独的“字符串转换”一章中了解更多关于from_chars
的内容。如果你想在GCC/Clang中运行这段代码,可以使用variant_parsing_int_float_gcc.cpp
文件,它仅支持整数和字符串。
TryParseString
函数的思路是尝试将输入字符串解析为最匹配的类型。所以如果看起来像整数,就尝试获取整数;否则,返回未解析的字符串。当然,我们可以扩展这种方法。
解析完成后,客户端可以使用Find()
方法检查参数是否存在:
Chapter Variant/variant_parsing_int_float.cpp
std::optional<CmdLine::Arg> CmdLine::Find(const std::string& name) const {
if (const auto it = mParsedArgs.find(name); it != mParsedArgs.end())
return it->second;
return {};
}
2
3
4
5
Find()
使用std::optional
来返回值。如果在映射中找不到参数,客户端将得到空的optional
。
下面是使用示例:
Chapter Variant/variant_parsing_int_float.cpp
try {
CmdLine cmdLine(argc, argv);
if (auto arg = cmdLine.Find("paramInt"); arg)
std::cout << "paramInt is " << std::get<int>(*arg) << '\n ';
if (auto arg = cmdLine.Find("paramFloat"); arg) {
if (const auto intPtr = std::get_if<int>(&*arg); intPtr)
std::cout << "paramFloat is " << *intPtr << " (integer)\n ";
else
std::cout << "paramFloat is " << std::get<float>(*arg) << '\n ';
}
if (auto arg = cmdLine.Find("paramText"); arg)
std::cout << "paramText is " << std::get<std::string>(*arg) << '\n ';
}
catch (const std::bad_variant_access& err) {
std::cerr << " ...err: 访问错误的variant类型, " << err.what() << '\n ';
}
catch (const std::runtime_error& err) {
std::cerr << " ...err: " << err.what() << '\n ';
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
上述示例使用cmdLine.Find()
检查是否存在给定参数。它返回std::optional
,所以我们必须检查它是否为空。当确定参数存在时,我们可以检查其类型。
CmdLine
尝试找到最匹配的类型,所以对于浮点数,可能存在歧义——例如,90也可以是浮点数,但代码会将其存储为整数(因为它没有小数部分)。
为了解决这种歧义,我们可以传递一些关于期望类型的额外信息,或者提供一些辅助方法。
# 解析配置文件
这个思路源于前面命令行解析的示例。在配置文件的场景中,我们通常处理<名称, 值>
对。其中“值”可能是不同的类型:字符串、整数、数组、布尔值、浮点数等等。
对于这样的用例,甚至可以使用void*
来存储这种未知类型。然而,这种模式极易出错。如果我们知道所有可能的类型,可以使用std::variant
来改进设计,或者也可以使用std::any
。
# 状态机
用std::variant
来建模状态机怎么样呢?例如,门的状态:
图8.1 门状态机
我们可以使用不同类型来表示状态,并使用访问器表示事件:
Chapter Variant/variant_fsm.cpp
struct DoorState {
struct DoorOpened {};
struct DoorClosed {};
struct DoorLocked {};
using State = std::variant<DoorOpened, DoorClosed, DoorLocked>;
void open() {
m_state = std::visit(OpenEvent{}, m_state);
}
void close() {
m_state = std::visit(CloseEvent{}, m_state);
}
void lock() {
m_state = std::visit(LockEvent{}, m_state);
}
void unlock() {
m_state = std::visit(UnlockEvent{}, m_state);
}
State m_state;
};
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
下面是事件定义:
Chapter Variant/variant_fsm.cpp
struct OpenEvent {
State operator ()(const DoorOpened&){ return DoorOpened(); }
State operator ()(const DoorClosed&){ return DoorOpened(); }
// 不能打开已锁定的门
State operator ()(const DoorLocked&){ return DoorLocked(); }
};
struct CloseEvent {
State operator ()(const DoorOpened&){ return DoorClosed(); }
State operator ()(const DoorClosed&){ return DoorClosed(); }
State operator ()(const DoorLocked&){ return DoorLocked(); }
};
struct LockEvent {
// 不能锁定已打开的门
State operator ()(const DoorOpened&){ return DoorOpened(); }
State operator ()(const DoorClosed&){ return DoorLocked(); }
State operator ()(const DoorLocked&){ return DoorLocked(); }
};
struct UnlockEvent {
// 不能解锁已打开的门
State operator ()(const DoorOpened&){ return DoorOpened(); }
State operator ()(const DoorClosed&){ return DoorClosed(); }
// 解锁
State operator ()(const DoorLocked&){ return DoorClosed(); }
};
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
现在我们可以创建Door
对象并在不同状态之间切换:
DoorState state;
assert(std::holds_alternative<DoorState::DoorOpened>(state.m_state));
state.lock();
assert(std::holds_alternative<DoorState::DoorOpened>(state.m_state));
2
3
4
你可以在以下博客文章中了解更多关于状态机以及一个简单太空游戏的实现:《基于std::variant的状态机示例 (opens new window)》 。
# 多态性
在C++中,大多数时候我们可以安全地使用基于虚函数表(vtable)方式的运行时多态。你有一组相关的类型,它们共享相同的接口,并且有一个定义明确的虚方法可以被调用。
但是如果你有一些“不相关”的类型,它们没有相同的基类呢?如果你想快速添加新功能而不改变受支持类型的代码呢?
使用std::variant
和std::visit
,我们可以构建以下示例:
Chapter Variant/variant_polymorphism.cpp
class Triangle {
public:
void Render() { std::cout << "Drawing a triangle!\n"; }
};
class Polygon {
public:
void Render() { std::cout << "Drawing a polygon!\n"; }
};
class Sphere {
public:
void Render() { std::cout << "Drawing a sphere!\n"; }
};
int main() {
std::vector<std::variant<Triangle, Polygon, Sphere>> objects {
Polygon(), Triangle(), Sphere(),
Triangle()
};
auto CallRender = [](auto& obj) { obj.Render(); };
for (auto& obj : objects)
std::visit(CallRender, obj);
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
上述示例仅展示了从无关类型中调用方法的第一种情况。它将所有可能的形状类型包装到一个variant
中,然后使用访问器将调用分发给合适的类型。
例如,如果你想对对象进行排序,那么可以编写另一个访问器,让它持有一些状态。这样,你就可以在不改变类型的情况下获得更多功能。
# 总结
关于std::variant
,需要记住以下几点:
- 它以类型安全的方式持有多种可选类型中的一种。
- 无需额外的内存分配。
variant
所需的空间为可选类型中最大尺寸的大小,再加上一些用于确定当前活动值的少量额外空间。 - 默认情况下,它使用第一个可选类型的默认值进行初始化。
- 你可以通过
std::get
、std::get_if
或通过访问器的形式来访问值。 - 要检查当前活动的类型,可以使用
std::holds_alternative
或std::variant::index
。 std::visit
提供了一种对当前可能是variant
中活动类型的任何类型执行操作的方式。这种多态操作由一个可调用对象表示,该对象为variant
可能持有的每种类型实现其调用运算符。- 极少数情况下,
std::variant
可能会进入无效状态,你可以使用valueless_by_exception()
方法检查这个问题。
# 编译器支持
特性 | GCC | Clang | MSVC |
---|---|---|---|
std::variant | 7.1 | 4.0 | VS 2017 15.0 |