C++17 工具类optional、variant、any

一、std::optional

1.1 引言

在开发中,经常会处理一些“可能为空”的函数返回值,该返回值可能赋予了一个有效值,也可能没有被赋予有效值。为了区分这两种情况,需要写一些额外的代码,导致代码可读性较差,甚至更容易出错。常见的做法有:

  • 返回一个magic value,表示“为空”,不是有效值。但它存在语义不清晰,稍不注意就会与合法值冲突。
  • 函数形参增加一个输出型参数,表示函数返回值是否被赋值。但它会导致接口隐晦,代码可读性差。

例如:有个函数查找一个值是否在数组中存在,如果存在则返回在数组中首次出现的下标。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
// 使用-1作为 magic value
int find_value(const std::vector<int> &vec, int val)
{
for (int i = 0; i < vec.size(); i++)
{
if (vec[i] == val) return i;
}

return -1;
}

// 使用输出型参数
int find_value(const std::vector<int> &vec, int val, bool &exists)
{
int i = 0;
exists = true;
for (; i < vec.size(); i++)
{
if (vec[i] == val) return i;
}

exists = false;
return i;
}

1.2 std::optional是什么

std::optional 是 C++17 引入的一个 模板类,定义在optional头文件中。它是一个容器,要么包含一个类型为T的值,要么为“空状态”。“空状态”也有自己的类型和值:类型是std::nullopt_t,值为std::nullopt

声明如下:

1
2
template<class T>
class optional;

有了std::optional可以很优雅处理变量可能“为空”问题,1.1节中代码可以改为:

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
#include <iostream>
#include <optional>

std::optional<int> find_value(const std::vector<int> &vec, int val)
{
for (int i = 0; i < vec.size(); i++)
{
if (vec[i] == val) return i;
}

return std::nullopt;
}

int main()
{
std::vector<int> v{1, 3, 5, 7};
auto result = find_value(v, 2);

if(result.has_value()) // 检查值是否设置
std::cout << "index=" << result.value() << std::endl; // 访问值
else
std::cout << "does not exist" << std::endl;

return 0;
}

std::optional对象初始化非常简单,可以默认初始化为空,或使用std::nullopt显式初始化为空。可以用任何一个T类型或可以隐式转换成T类型的变量来构造它,也可以传入T类型构造参数,调用T类型的构造函数进行构造。

1
2
3
4
5
6
7
8
9
// 初始化为空
std::optional<std::string> obj;
std::optional<std::string> obj = std::nullopt;

// 初始化不为空
std::optional<std::string> obj("hello");
std::optional<std::string> obj = "hello";
std::optional<std::string> obj = std::make_optional("hello");
std::optional<std::string> obj(std::in_place, "hello");

1.3 相关函数

下面为std::optional常用成员函数

has_valueoperator bool:如果不为空返回true,否则返回false

value:如果不为空返回值的引用,否则抛出std::bad_optional_access异常

value_or:如果不为空则返回该值(非引用),否则返回传入的默认值

operator*:如果不为空返回值的引用,为空则是未定义行为

operator->:如果不为空返回值的指针,为空则是未定义行为

reset:置为空

emplace:在内部就地构造一个T类型的值,如果之前有值,会先析构然后再构造新的

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
#include <iostream>
#include <optional>

int main()
{
std::optional<std::string> obj; // 初始化为空
if (!obj.has_value()) // obj为空,执行该条件语句,将值设置为"hello"
obj = "hello";
else
std::cout << "当前字符串=" << obj.value() << std::endl;

obj.reset(); // 置为空
auto str = obj.value_or("world"); // 由于为空,所以返回默认值"world"
std::cout << "str=" << str << std::endl;

if (obj) // 调用operator bool函数,函数返回false
std::cout << "当前字符串=" << *obj << std::endl;
else
obj = "abc";

obj.emplace("hello world"); // obj值为"abc",先析构,然后构造为"hello world"
std::cout << "当前字符串长度=" << obj->size() << std::endl; // 调用operator->函数,返回字符串长度

return 0;
}

1.4 原地构造

将使用std::in_placestd::make_optional把参数直接转发给 T类型的构造函数的方式称为原地构造,那么什么时候需要原地构造呢?主要有以下情况:

  1. 类型T需要使用默认构造函数进行构造

如果有一个类,它只提供了一个默认构造函数,那么怎么使用该类默认构造函数构造std::optional内部的值呢?

1
2
3
4
5
6
7
class A
{
public:
A() : _a(1) {}

int _a;
};

我们可能想到先构造一个类A的临时对象,然后将该临时对象拷贝构造给std::optional内的值。但是这种会有额外开销,并且如果类A的拷贝构造函数被删除的话,将会编译失败。在这种情况下,可以使用std::in_placestd::make_optional来原地构造。

1
2
3
4
5
6
// 不推荐
std::optional<A> obj{A()};

// 推荐
std::optional<A> obj{std::in_place};
std::optional<A> obj = std::make_optional<A>();
  1. 拷贝构造函数或移动构造函数被删除

如果类型T的拷贝构造或移动构造函数被删除了,将一个T类型对象拷贝或移动给std::optional内的要构造的值会编译失败。在这种情况下,可以使用std::in_placestd::make_optional来原地构造。

1
2
3
4
5
6
7
8
9
10
11
class A
{
public:
A() : _a(1) {}

A(const A &) = delete; // 删除拷贝构造函数

A(A &&) = delete; // 删除移动构造函数

int _a;
};
  1. 类型T 的构造函数有多个参数

如果类型T的构造函数中有多个参数也推荐使用原地构造方式将这些参数直接转发给 T的构造函数。避免先创建一个类型T对象,然后再将该对象移动到std::optional内,这样会有额外开销。

1
2
3
4
5
6
7
// 不推荐
A obj(20, 25);
std::optional<A> x = std::move(obj);

// 推荐
std::optional<A> x(std::in_place, 20, 25);
std::optional<A> x = std::make_optional<A>(20, 25);

二、std::variant

2.1 引言

如果有个变量需要存放多种类型,但在任一时间点内只会存放其中一种类型,我们第一时间会想到unionunion内所有成员共享同一块内存,所占内存大小等于最大成员变量的大小(不考虑内存对齐)。union在内存敏感、网络协议解析等场景普遍应用。但是union需要手动维护标志位才能知道当前存的是哪种类型,并且缺乏类型安全检查,对非POD类型限制较多(C++11之前)。

2.2 std::variant是什么

std::variant 是 C++17 引入的一个 模板类,定义在variant头文件中。它是一个类型安全联合体,只需要在定义时指定可能需要存放的类型。

声明如下:

1
2
template<class... Types>
class variant;

注意:类型不允许为引用、数组、void

下面为一个简单例子:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
#include <iostream>
#include <variant>
#include <string>

int main()
{
std::variant<int, double, std::string> var;

var = 10; // 存放int
std::cout << std::get<0>(var) << std::endl; // 访问类型index为0的值,即int类型

var = 3.14;
std::cout << std::get<double>(var) << std::endl; // 访问类型为double的值

var.emplace<std::string>("hello"); // 构造类型为std::string的值
std::cout << std::get<std::string>(var) << std::endl;

var.emplace<float>(2.22f); // 编译错误,var定义时没有指定float类型

return 0;
}

2.3 初始化

std::variant如果没有显式初始化,默认会调用第一个类型的默认构造函数进行初始化,即默认初始化第一个类型。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
class A
{
public:
A() { std::cout << "A()" << std::endl; }
};

class B
{
public:
B() { std::cout << "B()" << std::endl; }
};

class C
{
public:
C() { std::cout << "C()" << std::endl; }
};

int main()
{
std::variant<A, B, C> var;

return 0;
}

输出:

1
A()

如果默认初始化且第一个类型没有默认构造函数的话,编译会失败,此时可以使用std::monostatestd::monostate 是一个辅助类,里面没有任何成员变量,并且提供默认构造函数,可以作为 std::variant 的“空值”类型。即把 std::monostate 放在第一个位置,就又能让std::variant默认初始化了。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
class A
{
public:
// 提供一个有参构造函数,编译器不再生成默认构造函数
A(int a) :_a(a){ std::cout << "A()" << std::endl; }

int _a;
};

class B
{
public:
B() { std::cout << "B()" << std::endl; }
};

class C
{
public:
C() { std::cout << "C()" << std::endl; }
};


std::variant<std::monostate, A, B, C> var;

如果std::variant中第一个类型没有默认构造函数、想初始化非第一个类型、类型拷贝构造/移动构造函数被删除时的情况下。可以显式初始化std::variant

1
2
3
4
5
6
std::variant<int, double, std::string> var1 = 2025;        // int
std::variant<int, double, std::string> var2 = 3.14; // double
std::variant<int, double, std::string> var3 = "hello"; // const char* 会隐式转换为 std::string

std::variant<int, const char *, std::string> var4(std::in_place_type<const char *>, "hello"); // const char *
std::variant<int, double, std::string> v5(std::in_place_index<2>, "world"); // std::string

2.4 相关函数

下面为std::variant常用成员函数

index:返回当前存放值的类型index

emplace:在内部就地构造一个指定类型的值,如果之前有值,会先析构然后再构造新值

下面为常用非成员函数

std::get:返回存储值的引用,可以基于index或类型。如果存放的不是该类型的值,则抛出std::bad_variant_access异常

std::get_if:返回存储值的指针,可以基于index或类型。如果存放的不是该类型的值,则返回空指针

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
#include <iostream>
#include <variant>
#include <string>
#include <cassert>

int main()
{
std::variant<int, double, const char *, std::string> var;
var = 3.14;
assert(1 == var.index());

var.emplace<const char *>("hello");
try
{
std::cout << std::get<std::string>(var) << std::endl;
}
catch (std::bad_variant_access const &ex)
{
std::cout << ex.what() << ": var contained string, not const char*" << std::endl;
}

std::cout << std::get<2>(var) << std::endl;

var = 2025;
if (double *p = std::get_if<double>(&var); p != nullptr)
{
std::cout << "variant value: " << *p << std::endl;
}
else
{
std::cout << "failed to get_if value!" << std::endl;
}
std::cout << *(std::get_if<int>(&var)) << std::endl;

return 0;
}

输出:

1
2
3
4
bad_variant_access: var contained string, not const char*
hello
failed to get_if value!
2025

三、std::any

3.1 引言

C++ 是一个静态类型语言,变量类型需要在编译期确定。但在有些场景下,我们在运行时才知道类型,例如解析配置文件、解析JSON串等。如果想单个变量存储任意类型的值,常用方法是通过void*void*无类型指针, 可以指向任意类型的数据。但它 不保存类型信息,使用时需要自己知道类型并强制转换回来,并且存在对象内存管理麻烦等缺点。

1
2
3
4
5
6
7
8
9
void *p = nullptr;

int i = 2025;
p = &i;
std::cout << "val=" << *reinterpret_cast<int*>(p) << std::endl;

std::string s = "hello";
p = &s;
std::cout << "val=" << *reinterpret_cast<std::string*>(p) << std::endl;

3.2 std::any是什么

std::any 是 C++17 引入的一个 工具类,定义在any头文件中。它是一个类型安全的容器,可以存放任意类型的值。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
#include <iostream>
#include <any>
#include <string>

int main()
{
std::any var;

var = 2025;
std::cout << std::any_cast<int>(var) << std::endl;

var = 3.14;
std::cout << std::any_cast<double>(var) << std::endl;

var.emplace<std::string>("hello");
std::cout << std::any_cast<std::string>(var) << std::endl;

return 0;
}

3.3 初始化

std::any如果没有显式初始化,则默认为空,不包含任何值

1
2
std::any var;
assert(!var.has_value());

下面为显式初始化,它包含一个值

1
2
3
4
5
6
std::any var1 = 2025; // int
std::any var2 = 3.14; // double

std::any var3(std::in_place_type<std::string>, "hello"); // std::string

std::any var4 = std::make_any<float>(1.23f); // float

3.4 相关函数

下面为std::any常用成员函数

has_value:如果包含一个值返回true,否则返回false

type:返回所包含值的类型标识符,不包含值时返回值没有意义

emplace:在内部就地构造一个指定类型的值,如果之前有值,会先析构然后再构造新值

reset:置为空

下面为常用非成员函数

any_cast:对所包含的值进行类型安全的访问,通过值或引用获取时,如果类型不匹配,抛std::bad_any_cast异常。通过指针获取时,如果类型不匹配,返回空指针。

make_any:构造一个 std::any 对象,内部包含一个类型为 T的值,该值通过提供的参数进行构造

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
#include <iostream>
#include <any>
#include <cassert>
#include <vector>
#include <string>

int main()
{
std::any var = std::make_any<std::string>("hello");
assert(var.type() == typeid(std::string));
std::string str = std::any_cast<std::string>(var); // 存在拷贝
std::cout << str << std::endl;

var.reset(); // 置为空
assert(!var.has_value());

var.emplace<std::vector<int>>({1, 2, 3, 4, 5});
try
{
auto v = std::any_cast<std::vector<double> &>(var); // 获取值的引用,不存在拷贝,但是此时any内部值类型为vector<int>
}
catch (const std::bad_any_cast &e)
{
std::cout << "error: " << e.what() << std::endl;
}

var.emplace<int>(2025);
if (int *p = std::any_cast<int>(&var); p != nullptr) // 获取值的指针,如果值类型不匹配,则返回空指针
{
std::cout << "int: " << *p << std::endl;
}
else
{
std::cout << "var is not an int, real_type=" << var.type().name() << std::endl;
}

return 0;
}

输出:

1
2
3
hello
error: bad any cast
int: 2025

3.5 部分细节

3.5.1 存储结构

std::any的存储结构取决于存储的值的类型和大小。对于小的或者是POD(Plain Old Data)类型,std::any通常会使用一种称为”小对象优化”(Small Object Optimization)的技术,直接在std::any对象内部存储这些值。对于大的或者是非POD类型,std::any则会动态分配内存来存储这些值。

这种设计使得std::any可以有效地存储各种类型的值,同时避免了不必要的动态内存分配开销。然而,这也意味着std::any的大小并不是固定的,而是取决于存储的值的类型和大小。

3.5.2 类型擦除

std::any的实现通常使用了一种称为”类型擦除”(Type Erasure)的技术。类型擦除是一种允许程序在运行时处理不同类型的数据,同时保持类型安全的技术。在C++中,类型擦除通常通过使用模板和虚函数来实现。

此外,std::any的类型擦除特性也会带来一些性能开销。每次我们查询或转换std::any对象的类型时,都需要进行一些额外的运行时检查确保安全性。