引子
经受了 big sur 的折磨之后,有那么几篇 Posts 不讲技术面的东西而且转而扮演怨妇,想必也是可以理解的吧?
你们都知道,技术性的东西,最紧要就是合适的锤子。
所以,我一技术人,有那么几篇锤子般的 trouble-solving-posts,这自然是合情合理的,不是吗?
当然还是要回到老路上来,认真梳理里来曾经学到过的东西。比方说……
但在这之前,最后碎碎念一次,刚刚,又特么 reboot 了一次,(哦,其实最近几天也都有 crashes,麻木的我),幸好看起来这次也似乎只是特别怀念版而不是常态版。而且,老 MBP 突然也无缘无故地崩了一次,是因为我装了 Parelles 试用版的原因吗?现在总是在疑神疑鬼,生活收到了极大的摧残,身心俱毙,对的,完全不是疲。
std::variant, std::optional, std::any in C++17
变体类型,自由类型,随便你怎么叫,它最终是表达这么一种场景:你可以将任意类型的值放进去,然后你可以安全地将其抽出来。
这个统一的要求,具体实现起来有多种取舍。大多数情况下,除了能够依照原来放入时的数据类型正确地抽出来之外,还应该具体和字符串的交互能力:也就是说,能够从字符串(流)中转换得到一个特定数据类型的值(例如将 “123” 转译为 123),也能够从特定数据类型的值输出为一个字符串表示(例如从 8.301 输出为 “8.301”)。
本篇之中,仅仅述及 std::variant
,至于另两位新模板因为篇幅原因下次再说吧。
C++17 之前
在 C 时代以及早期 C++ 时代,语法层面支持的变体类型有两种方式:void
或者 union
。
void
void
代表着无类型,但你不能直接定义 void
类型的变量,只能通过 void*
指针的方式来间接引用它。同时,由于它是一种“无类型”,所以放入的数值的类型信息就完全被抹除了,需要你依靠人脑来保证正确的抽出:
1
2
3
4
5
6
7
8
9
void *varval;
char* s = "string";
varval = &s;
printf("%s\n", *varval);
int i = 9;
varval = &i;
printf("%d\n", *varval);
注意抽取类型错了的话,后果不可预知。
union
比 void* 更好一点的方式是 union,它代表着一组类型的混合体,你可以决定放入什么类型并取出什么类型,但同样需要人脑记忆来保证不要异种类型交互:如果放入 char* 却以 int 方式取出,结果是什么呢?此时的结果是未定义的。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
typedef union _U {
int32_t i;
float f;
char* s;
struct _2_WORD {
unsigned short lo;
unsigned short hi;
} double_word;
} __attribute__ ((packed)) U;
U u;
u.i = 0x12345678;
printf("lo_word = %x, hi_word = %s\n", u.double_word.lo, u.double_word.hi);
printf("i = %d (%x)\n", u.i, u.i);
# RESULTS
#
# Hello, Wandbox!
# lo_word = 5678, hi_word = 1234
# i = 305419896 (12345678)
注意,上述代码需要 gcc 编译,因为它用到了 packed struct 定义方式,这是为了紧缩结构字段的内存的排布方式,在这种方式下,32bit 的整数(int32_t) 和两个 16bit 的整数(double_word) 在内存占用上是完全重叠的。所以我们可以直接取出 lo 和 hi 并能够得到正确的结果。
UB(NO,NO)
注意,这种“正确的结果” 依赖于编译器,目标运行环境(CPU 字长,CPU 的 Endian 表示)等多种因素,所以它实际上是不安全的,或者至少说是不可移植的。
稍好一点的包装:Tagged Union
不关心移植性,union 也是一个危险的工具,想想看如果我们放进去一个字符串,然后以 float 方式取出来,或者放入一个整数值,却以字符串的方式取出来呢?轻则数据是错误的,重则可能内存越界、或者侵占或意外修改别的数据,致命的情况是造成安全隐患。
你能看到直接使用 union 需要一个精明警醒的老练程序员,否则太容易出错了。通过简易地包装 union 我们可以定义更安全的使用方式:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
struct S {
private:
enum{CHAR, INT, DOUBLE} tag;
union
{
char c;
int i;
double d;
};
public:
void put(char c){ this->c = c; tag = CHAR; }
void put(int i){ this->i = i; tag = INT; }
void put(double d){ this->d = d; tag = DOUBLE; }
int get_int() const { if (tag==INT) return i; throw; }
// ...
}
然而它也只是有限的安全,而且并不易用,你可以进一步改进,但能做的并不多。
上述的包装方案实际上就是 Tagged Union 的简写示意版。所谓的 标签联合
,也正是变体类型的最经典的方案。
事实上,变体类型理所当然地就是应该被如此实现的,不同的方案的区别只在于:标签放哪里,应该被如何具现。
Variant
在 VC++ 中,随着 OLE/COM/MFC/ATL 类库而来的还有一个 VARIANT
类型及其包装。
各种第三方库也有一些 variant 的实现,例如 boost::varian
。
在支持 RTTI 的编译器中,你也可以通过 typeid 关键字的方式来自行实现。其坏处在于 RTTI 本身会带来少许的性能开销,不过甚至是上月球,可能也都不会在意这个开销能有多大,也许只有华尔街的高频交易才会 Care 它。
不使用 RTTI,我们仍然有武器:template。实际上,大多数第三方库对 variant 的实现方案,一律都是借助模板的类型安全手段。
C++17 的 std::variant
但不管怎么说,现在尘埃落定了,C++14 没有加入的 std::variant
终于在 17 中列装了标准库。它和 C++11 的 std::regex
一样,是关键性的、决定性的。
基本操作
std::variant
提供一个类型安全的变体类型,它的特点在于:
- 通过可变的模板参数(variable template,variadic template)你可以指定一组可选类型,它们将是这个实例类型所支持的值类型表。例如
std::variant<int, boo>
允许你放入 int 或者 bool 的值并安全的抽出它。 - 其实例在任意时刻要么包含一个其可选类型之一的值,要么处于病式状态。
- 其实例的默认值为其首个可选类型的默认构造值。即
std::variant<int, bool> a;
语句中,a
具有(int)(0)
值。如果首个可选类型没有默认的构造器,那么你需要显式地提供初始化表达式。 - 不支持引用类型,数组,void 等作为其可选类型。
类模板 std::variant
并没有无值状态,默认构造的 variant
保有其首个选项的值,除非该选项不是可默认构造的。所以有时候你可能需要一个空结构来表达无值态,这种情况下一般应该使用辅助类 std::monostate
使这种 variant
,它是一个标准库中的空结构,可以被借用来表达空值。
1
2
3
4
5
6
7
8
#include <variant>
#include <string>
#include <cassert>
std::variant<std::monostate, int, bool, float> v; // v的初值现在为 std::monostate{}
if (std::holds_alternative<std::monostate>(v)) {
// std::cout << "empty value" << std::endl;
}
你能看到我们通过 std::holds_alternative<T>
来测试 variant 中的值是不是某种特定类型,注意这是运行期的测试。
可以使用 std::get
或者 std::get_if
来抽取特定类型的值,区别在于是抛出异常还是返回 nullptr:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
#include <variant>
#include <string>
#include <cassert>
std::variant<std::monostate, int, bool, float> v;
v = 12;
// std::cout << "valid: " << std::get<int>(v) <<< std::endl;
v = true;
assert(v.index() == 2);
v = 3.14;
assert(v.index() == 1);
try {
std::get<int>(v); // v 含 float 而非 int, 将抛出异常
}
catch (const std::bad_variant_access&) {}
if (auto pval = std::get_if<int>(&v)) // get_if 不抛出异常而是返回 null 指针
std::cout << "variant value: " << *pval << '\n';
可选类型的构造函数只要无歧义,那么一定程度上的自动转换是可行的:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
#include <variant>
#include <string>
#include <cassert>
{
using namespace std::literals;
std::variant<std::string> x("abc"); // 转换构造函数在无歧义时起作用
x = "def"; // 转换赋值在无歧义时亦起作用
std::variant<std::string, void const*> y("abc");
// 传递 char const * 时转换成 void const *
assert(std::holds_alternative<void const*>(y)); // 成功
y = "xyz";
assert(std::holds_alternative<std::string>(y)); // 失败
}
通过 visit
可以通过 std::visit 提供一组观察器来抽出实际值,这是替代 switch 语句的好途径,但也不必纠结。
简单地看,你可以在一个结构中定义和 variant 可选类型相同的函数重载,然后利用这些重载来处理对应的可选类型。其概要结构是这样的:
1
2
3
4
5
6
7
8
9
10
11
12
13
struct Visitor {
void operator()(float) const {}
void operator()(bool) const {}
void operator()(int) const {}
void operator()(const std::monostate&) const{}
};
std::variant<std::monostate, int, bool, float> v = 1;
std::visit(Visitor{}, v); // for int
v = std::monostate{};
std::visit(Visitor{}, v); // for std::monostate
v = 2.718f;
std::visit(Visitor{}, v); // for float
上面的结构是为了让你理解 visit 要 what,how。但 visit 实际上可以采用更多的语法形式,下面是摘自 cppreferences 的例子:
1
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
50
51
52
53
#include <iomanip>
#include <iostream>
#include <string>
#include <type_traits>
#include <variant>
#include <vector>
// 要观览的 variant
using var_t = std::variant<int, long, double, std::string>;
// 观览器 #3 的辅助常量
template<class> inline constexpr bool always_false_v = false;
// 观览器 #4 的辅助类型
template<class... Ts> struct overloaded : Ts... { using Ts::operator()...; };
// 显式推导指引( C++20 起不需要)
template<class... Ts> overloaded(Ts...) -> overloaded<Ts...>;
int main() {
std::vector<var_t> vec = {10, 15l, 1.5, "hello"};
for(auto&& v: vec) {
// 1. void 观览器,仅为其副效应调用
std::visit([](auto&& arg){std::cout << arg;}, v);
// 2. 返回值的观览器,返回另一 variant 的常见模式
var_t w = std::visit([](auto&& arg) -> var_t {return arg + arg;}, v);
std::cout << ". After doubling, variant holds ";
// 3. 类型匹配观览器:亦能为带 4 个重载的 operator() 的类
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, long>)
std::cout << "long 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 << "std::string with value " << std::quoted(arg) << '\n';
else
static_assert(always_false_v<T>, "non-exhaustive visitor!");
}, w);
}
for (auto&& v: vec) {
// 4. 另一种类型匹配观览器:有三个重载的 operator() 的类
std::visit(overloaded {
[](auto arg) { std::cout << arg << ' '; },
[](double arg) { std::cout << std::fixed << arg << ' '; },
[](const std::string& arg) { std::cout << std::quoted(arg) << ' '; },
}, v);
}
}
复制的代价
由于 std::variant
身为容器并持有其可选类型的值,因此复制语义的后果是深度复制相应的值,这也同时代表着复制的代价是相当昂贵的。
所以 std::variant
也提供原位构造 emplace 和交换 swap 来降低深度复制带来的开销:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
#include <iostream>
#include <string>
#include <variant>
typedef std::variant<int, std::string> var_t;
var_t v{"a string"};
std::visit([] (auto&& x) { std::cout << x << ' '; }, v);
v.emplace<int>(37); // construct a int, and destroy the old string
std::visit([] (auto&& x) { std::cout << x << ' '; }, v);
var_t z{3};
z.swap(v);
std::visit([] (auto&& x) { std::cout << x << ' '; }, v);
std::swap(z, v);
std::visit([] (auto&& x) { std::cout << x << ' '; }, v);
派生类
声明 std::variant
的派生子类是行得通的。
1
2
3
4
5
6
7
class MyVarT: public std::variant<int, float> {
public:
MyVarT() = default;
~MyVarT() = default;
};
MyVarT v{ { 9 } };
如果你需要对特定类型封装抽出函数的话,借助于派生类可以很好地达到你的要求。
小结
std::variant
可以有广泛的用途,类库作者对这种东西根本没有丝毫抵抗力。
如果想做一个 JSON/YAML 处理器,那么你大概是绕不开 std::variant 的(或者类似品),这是它能大显身手的地方。
此外,像 hedzr/cmdr 这样的具有应用程序配置管理器的类库,当然也少不了 std::variant 的身影。只不过,hedzr/cmdr 是一个 Golang 版本。而它的 C++17 版本或许会将要放出来,我很久没在 C++ 这边研究过类库了,谦虚点说和初学者也没区别,重新来吧。
留下评论