7. std::optional
# 7. std::optional
C++17添加了一些包装类型,让编写更具表现力的代码成为可能。在本章中,你将了解std::optional
,它用于表示可空类型。借助这个工具,你的对象可以轻松表明它们没有任何值。这种行为比使用一些特殊值(如-1、null)来实现要直观得多。
在本章中,你将学习:
- 为什么需要可空类型;
std::optional
如何工作以及它的作用;- 对
std::optional
的操作; - 使用该类型的性能开销;
- 示例用例。
# 引言
如何标记一个类型不包含任何值呢?
一种方法是通过使用特殊值(-1、无穷大、nullptr
)来实现“可空性”。在使用前,需要将对象与预定义值进行比较,以查看它是否为空。这种模式在编程中很常见。例如,string::find
返回一个表示位置的值,当找不到模式时返回npos
,这里npos
就相当于“空值” 。
另外,你可以尝试使用std::unique_ptr<Type>
,并将空指针视为未初始化。这种方法可行,但需要为对象分配内存,并不是推荐的做法。
另一种技术是构建一个包装器,为其他类型添加一个布尔标志。这样的包装器可以快速判断对象的状态。简而言之,std::optional
就是这样工作的。
源自函数式编程领域的可空类型带来了类型安全性和表达能力。大多数其他语言都有类似的类型:例如Rust中的std::option
、Java中的Optional<T>
、Haskell中的Data.Maybe
。
std::optional
在C++17中被添加,它借鉴了多年来一直可用的boost::optional
的许多经验。在C++17中,你只需#include <optional>
,就可以使用这个类型。
此外,std::optional
在库基础技术规范(Library Fundamentals TS)中也已存在,所以你的C++14编译器有可能在<experimental/optional>
头文件中支持它。
std::optional
仍然是一个值类型(因此可以通过深拷贝进行复制)。此外,std::optional
无需在自由存储区分配任何内存。
std::optional
与std::any
、std::variant
和std::string_view
一样,是C++的标准库类型。
# 使用场景
通常可以在以下场景中使用可选包装器:
- 如果你想表示一个可空类型。
- 而不是使用特殊值(如-1、
nullptr
、NO_VALUE
等)。 - 例如,用户的中间名是可选的。你可能认为空字符串在这里可行,但了解用户是否输入了内容可能很重要。
std::optional<std::string>
可以提供更多信息。
- 而不是使用特殊值(如-1、
- 返回某些计算(处理)的结果,当计算未能产生值且并非错误时。
- 例如,在字典中查找元素:如果某个键下没有元素,这不是错误,但我们需要处理这种情况。
- 实现资源的延迟加载。
- 例如,如果资源类型的构造开销很大,或者没有默认构造函数,你可以将其定义为
std::optional<Resource>
。以这种形式,你可以在系统中传递它,然后在应用程序首次访问时对其进行初始化(加载资源)。
- 例如,如果资源类型的构造开销很大,或者没有默认构造函数,你可以将其定义为
- 向函数传递可选参数。
boost.optional
的文档中有关于何时应使用该类型的有用总结,见《何时使用Optional (opens new window)》:
建议在存在唯一一个各方都清楚的没有T 类型值的原因,并且没有值与拥有任何常规T 类型值一样自然的情况下,使用optional<T> 。 |
---|
虽然有时是否使用optional
的决定可能并不明确,但当值为空且这是程序的正常状态时,它最为适用。
# 基本示例
下面是一个关于optional
的简单示例:
// UI类...
std::optional<std::string> UI::FindUserNick() {
if (IsNickAvailable())
return mStrNickName; // 返回一个字符串
return std::nullopt; // 等同于return { };
}
// 使用:
std::optional<std::string> UserNick = UI->FindUserNick();
if (UserNick)
Show(*UserNick);
2
3
4
5
6
7
8
9
10
11
在上述代码中,我们定义了一个函数,它返回一个包含字符串的optional
。如果用户的昵称可用,它将返回该字符串;否则,返回nullopt
。之后,我们可以将其赋值给一个optional
,并通过将其转换为bool
来检查它是否包含值。optional
定义了operator*
,因此我们可以轻松访问存储的值。
在接下来的部分,你将了解如何创建std::optional
、对其进行操作、传递它,甚至会介绍你可能需要考虑的性能开销。
# std::optional的创建
创建std::optional
有几种方式:
- 初始化为空;
- 直接用值初始化;
- 使用推导指南用值初始化;
- 使用
make_optional
; - 使用
std::in_place
; - 从其他
optional
创建。
如下代码所示:
// 空初始化:
std::optional<int> oEmpty;
std::optional<float> oFloat = std::nullopt;
// 直接初始化:
std::optional<int> oInt(10);
std::optional oIntDeduced(10); // 推导指南
// make_optional
auto oDouble = std::make_optional(3.0);
auto oComplex = std::make_optional<std::complex<double>>(3.0, 4.0);
// in_place
std::optional<std::complex<double>> o7{std::in_place, 3.0, 4.0};
// 用{1, 2, 3}直接初始化vector
std::optional<std::vector<int>> oVec(std::in_place, {1, 2, 3});
// 从其他optional复制:
auto oIntCopy = oInt;
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
从上述代码示例中可以看出,创建optional
有很大的灵活性。对于基本类型来说很简单,这种简单性甚至扩展到了复杂类型。
如果你希望完全控制创建过程和效率,了解in_place
辅助类型也很有帮助。
# 就地构造
std::optional
是一种包装类型,因此你几乎可以用与创建被包装对象相同的方式来创建optional
对象。在大多数情况下确实如此:
std::optional<std::string> ostr{"Hello World"};
std::optional<int> oi{10};
2
你也可以不写构造函数来编写上述代码:
std::optional<std::string> ostr{std::string{"Hello World"}};
std::optional<int> oi{int {10}};
2
因为std::optional
有一个构造函数,它接受U&&
(对一种类型的通用引用,该类型可转换为optional
中存储的类型)。在我们的例子中,它被识别为const char*
,而字符串可以由此初始化。
那么在std::optional
中使用std::in_place_t
有什么好处呢?至少有两个关键原因:
- 默认构造函数
- 对有多个参数的构造函数进行高效构造
- 默认构造:如果你有一个带有默认构造函数的类,例如:
class UserName {
public:
UserName() : mName("Default") {
}
// ...
};
2
3
4
5
6
你要如何创建一个包含UserName{}
的optional
对象呢?你可以这样写:
std::optional<UserName> u0; // 空的optional
std::optional<UserName> u1{}; // 也是空的
// 包含默认构造对象的optional:
std::optional<UserName> u2{UserName()};
2
3
4
这样做是可行的,但它会创建一个额外的临时对象。如果我们追踪每个不同的构造函数和析构函数调用,会得到以下输出:
UserName::UserName('Default')
UserName::UserName(move 'Default') // 移动临时对象
UserName::~UserName('') // 删除临时对象
UserName::~UserName('Default')
2
3
4
这段代码创建了一个临时对象,然后将其移动到optional
中存储的对象里。在这里,我们可以使用一种更高效的构造函数——利用std::in_place_t
:
std::optional<UserName> opt{std::in_place};
追踪构造函数和析构函数时,你会得到以下输出:
UserName::UserName('Default')
UserName::~UserName('Default')
2
optional
中存储的对象是就地创建的,就像你调用UserName{}
一样。不需要额外的复制或移动操作。
查看Optional/optional_in_place_default.cpp
中的示例。在该文件中,你还可以看到构造函数和析构函数的追踪信息。
- 不可复制/不可移动类型:正如你在上一节的示例中看到的,如果你使用一个临时对象来初始化
std::optional
中包含的值,那么编译器将不得不使用移动或复制构造函数。但如果你的类型不允许这样做呢?例如,std::mutex
是不可移动也不可复制的。在这种情况下,std::in_place
是处理这类类型的唯一方法。 - 有多个参数的构造函数:另一个用例是当你的类型在构造函数中有更多参数时。默认情况下,
optional
可以处理单个参数(右值引用),并有效地将其传递给被包装的类型。但如果你想初始化Point(x, y)
呢?你总是可以创建一个临时副本,然后在构造时传递它:
// Optional/optional_point.cpp
struct Point {
Point(int a, int b) : x(a), y(b) {}
int x;
int y;
};
std::optional<Point> opt{Point{0, 0}}; // 创建了临时对象!
2
3
4
5
6
7
或者使用in_place
以及处理可变参数列表的构造函数版本:
template < class... Args >
constexpr explicit optional( std::in_place_t, Args&&... args );
// 或者初始化列表:
template < class U, class... Args >
constexpr explicit optional( std::in_place_t,
std::initializer_list<U> ilist,
Args&&... args );
2
3
4
5
6
7
8
你的代码可以这样写:
std::optional<Point> opt{std::in_place_t, 0, 0};
第二种选择虽然冗长,但避免了创建临时对象。对于容器或较大的对象来说,临时对象的效率不如就地构造。
std::make_optional()
:如果你不喜欢std::in_place
,那么可以看看make_optional
工厂函数。下面的代码:
auto opt = std::make_optional<UserName>();
auto opt = std::make_optional<Point>(0, 0);
2
与下面的代码效率相同:
std::optional<UserName> opt{std::in_place};
std::optional<Point> opt{std::in_place_t, 0, 0};
2
std::make_optional
实现了等同于return std::optional<T>(std::in_place, std::forward<Args>(args)...);
的就地构造。而且由于C++17强制的复制省略²,其中不会涉及临时对象。
# 返回std::optional
如果你从一个函数返回optional
,那么直接返回std::nullopt
或计算出的值会非常方便。
// Optional/optional_return_rvo.cpp
std::optional<std::string> TryParse(Input input) {
if (input.valid())
return input.asString();
return std::nullopt;
}
// 使用:
auto oStr = TryParse(Input{...});
2
3
4
5
6
7
8
9
在上述示例中,你可以看到函数返回从input.asString()
计算得到的std::string
,并将其包装在optional
中。如果值不可用,则返回std::nullopt
。
由于C++17强制的复制省略(更多内容请阅读“通用语言特性”一章中“保证复制省略”一节。 ),optional
对象oStr
将在调用者处创建。
或者,你也可以尝试非标准化的命名返回值优化(Named Returned Value Optimisation)。当你在函数开头创建一个对象,然后返回它时,就会发生这种情况。
// Optional/optional_return_rvo.cpp
std::optional<std::string> TryParseNrvo(Input input) {
std::optional<std::string> oOut; // 空的
if (input.valid())
oOut = input.asString();
return oOut;
}
// 使用:
auto oStr = TryParseNrvo(Input{...});
2
3
4
5
6
7
8
9
10
在第二个示例中,oStr
对象也应该在调用者处创建。你可以尝试这个示例,其中包含额外的日志记录,用于检查optional
变量的地址。
# 返回值时使用大括号要小心
下面这段代码³可能会让你感到惊讶:
std::optional<std::string> CreateString() {
std::string str {"Hello Super Awesome Long String"};
return {str}; // 这会导致拷贝
// return str; // 这会进行移动操作
}
2
3
4
5
根据标准,如果将返回值用大括号{}
括起来,就会阻止移动操作发生,返回的对象只会被拷贝。
这与不可拷贝类型的情况类似:
std::unique_ptr<int> foo() {
std::unique_ptr<int> p;
return {p}; // 尝试拷贝unique_ptr,编译会失败
// return p; // 这会进行移动操作,所以对于unique_ptr来说没问题
}
2
3
4
5
³感谢JFT指出这个问题。
标准在[class.copy.elision]/3⁴中规定:
在以下复制初始化上下文中,可以使用移动操作代替复制操作: - 如果 return 语句([stmt.return])中的表达式是(可能带括号的)标识表达式,该表达式命名了在最内层封闭函数或lambda表达式的函数体或形参声明子句中声明的具有自动存储期的对象;- 如果 throw 表达式的操作数是一个非易失性自动对象(不是函数或catch 子句参数)的名称,且其作用域不超出最内层封闭try 块(如果有的话)的末尾 |
---|
可以试着运行位于Chapter Optional/optional_return.cpp
中的示例代码。该代码展示了一些使用std::unique_ptr
、std::vector
、std::string
和自定义类型的示例。
# 访问存储的值
对于std::optional
(除了创建操作之外)来说,最重要的操作可能就是获取其中包含的值的方式了。有以下几种选择:
operator*
和operator->
- 如果没有值,行为是未定义的!value()
- 返回值,如果没有值则抛出std::bad_optional_access
异常。value_or(defaultVal)
- 如果有值则返回该值,否则返回defaultVal
。
要检查是否有值,可以使用has_value()
方法,或者通过if (optional)
来检查,因为std::optional
可以隐式转换为bool
类型。
下面的示例展示了这些操作:
// 通过operator*
std::optional<int> oint = 10;
std::cout<< "oint " << *oint << '\n ';
// 通过value()
std::optional<std::string> ostr("hello");
try {
std::cout << "ostr " << ostr.value() << '\n ';
}
catch (const std::bad_optional_access& e) {
std::cout << e.what() << '\n ';
}
// 通过value_or()
std::optional<double> odouble; // 空值
std::cout<< "odouble " << odouble.value_or(10.0) << '\n ';
2
3
4
5
6
7
8
9
10
11
12
13
14
还有一种便捷的模式,先检查是否有值,然后再访问它:
// 计算字符串的函数:
std::optional<std::string> maybe_create_hello();
// ...
if (auto ostr = maybe_create_hello(); ostr)
std::cout << "ostr " << *ostr << '\n ';
else
std::cout << "ostr is null\n ";
2
3
4
5
6
7
# std::optional
的操作
让我们看看这个类型还有哪些其他操作。
# 修改值和对象生命周期
如果你已经有一个std::optional
对象,可以通过emplace
、reset
、swap
、assign
等操作快速修改其中包含的值。如果用nullopt
进行赋值(或重置),并且std::optional
中包含一个值,那么该值的析构函数将会被调用。
下面的示例展示了所有这些情况,代码位于Chapter Optional/optional_reset.cpp
:
#include <optional>
#include <iostream>
#include <string>
class UserName {
public:
explicit UserName(std::string str) : mName(std::move(str)) {
std::cout << "UserName::UserName('" << mName << " ')\n ";
}
~UserName() {
std::cout << "UserName::~UserName('" << mName << " ')\n ";
}
private:
std::string mName;
};
int main() {
std::optional<UserName> oEmpty;
// emplace:
oEmpty.emplace("Steve");
// 调用~Steve并创建新的Mark:
oEmpty.emplace("Mark");
// 重置,使其再次为空
oEmpty.reset(); // 调用~Mark
// 等同于:
//oEmpty = std::nullopt;
// 赋值一个新值:
oEmpty.emplace("Fred");
oEmpty = UserName("Joe");
}
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
每次对象发生改变时,当前存储的UserName
的析构函数都会被调用。
# 比较操作
std::optional
允许你几乎“自然地”比较其中包含的对象,但当操作数中有nullopt
时会有一些特殊情况。如下所示,代码位于Chapter Optional/optional_comparision.cpp
:
#include <optional>
#include <iostream>
int main() {
std::optional<int> oEmpty;
std::optional<int> oTwo(2);
std::optional<int> oTen(10);
std::cout << std::boolalpha;
std::cout << (oTen > oTwo) << '\n ';
std::cout << (oTen < oTwo) << '\n ';
std::cout << (oEmpty < oTwo) << '\n ';
std::cout << (oEmpty == std::nullopt) << '\n ';
std::cout << (oTen == 10) << '\n ';
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
上述代码的输出结果为:
true // (oTen > oTwo)
false // (oTen < oTwo)
true // (oEmpty < oTwo)
true // (oEmpty == std::nullopt)
true // (oTen == 10)
2
3
4
5
当操作数都包含值(且类型相同)时,你会得到预期的结果。但当一个操作数是nullopt
时,它总是比任何包含值的std::optional
“小”。
# 性能和内存考量
使用std::optional
时,会增加内存占用。std::optional
类包装了你的类型,为其准备空间,然后添加了一个布尔型参数。这意味着它会根据对齐规则扩展你原来类型的大小。
从概念上讲,标准库中std::optional
的实现可能类似这样:
template <typename T>
class optional {
bool _initialized;
std::aligned_storage_t<sizeof(t), alignof(T)> _storage;
public: // 操作
};
2
3
4
5
6
std::optional
的对齐规则在optional.optional
⁵中定义如下:
所包含的值应分配在std::optional 存储区中适合类型T 对齐的区域内 |
---|
例如,假设sizeof(double) = 8
且sizeof(int) = 4
:
std::optional<double> od; // sizeof = 16字节
std::optional<int> oi; // sizeof = 8字节
2
虽然bool
类型通常只占用一个字节,但std::optional
类型需要遵循对齐规则,所以它的大小大于sizeof(YourType) + 1
字节。
例如,有两个类型:
struct Range {
std::optional<double> mMin;
std::optional<double> mMax;
};
struct RangeCustom {
bool mMinAvailable;
bool mMaxAvailable;
double mMin;
double mMax;
};
2
3
4
5
6
7
8
9
10
11
Range
占用的空间比RangeCustom
更多。在第一种情况下,Range
占用32字节!第二种情况是24字节。这是因为第二个类可以将布尔型变量“压缩”在结构体的开头,而Range
中的两个std::optional
对象必须对齐到double
类型的边界。
你可以在Chapter Optional/optional_sizeof.cpp
中查看完整代码。
# 从boost::optional
迁移
std::optional
直接改编自boost::optional
,因此你可以预期在这两个版本中有相似的使用体验。从一个迁移到另一个应该很容易,当然,还是存在一些细微差异。
下表总结了这些变化:
方面 | std::optional | boost::optional (截至1.67.0⁶) |
---|---|---|
移动语义(Move semantics) | 是 | 是 |
无异常(noexcept) | 是 | 是 |
哈希支持(hash support) | 是 | 否 |
抛出异常的值访问器 | 是 | 是 |
字面量类型(可用于constexpr 表达式) | 是 | 否 |
就地构造(in place construction) | emplace ,标签in_place | emplace() ,标签in_place_init_if_t 、in_place_init_t ,工具in_place_factory |
未初始化状态标签 | nullopt | 无 |
可选引用(optional references) | 否 | 是 |
从optional<U> 转换到optional<T> | 是 | 是 |
显式转换为指针(get_ptr ) | 否 | 是 |
推导指引(deduction guides) | 是 | 否 |
主要的区别在于std::optional
支持哈希运算、可用于constexpr
上下文,并且还有推导指引。然而,boost::optional
支持引用,而这在C++17版本的std::optional
中是不支持的。
在https://www.fluentcpp.com/2018/10/05/pros-cons-optional-references/ (opens new window)的 “Why Optional References Didn’t Make It In C++17”(为什么可选引用未被纳入C++17)一文中可以了解更多信息。
# 特殊情况:optional<bool>
和optional<T*>
虽然你可以在任何类型上使用optional
,但对于布尔类型和指针类型需要特别注意。
optional<bool>
——它表示什么呢?使用这种构造,你得到的是一个三态布尔值。如果你确实需要这种类型,或许寻找一个真正的三态布尔值,比如boost::tribool
(opens new window)会更好。 此外,使用这种类型可能会造成混淆,因为optional<bool>
可以转换为bool
。而且,如果其中有值,访问该值返回的也是bool
类型。- 类似地,指针类型也存在类似的歧义:
// 不要这样尝试,这只是个示例!
std::optional<int*> opi { new int (10) };
if (opi && *opi) {
std::cout << **opi << std::endl;
delete *opi;
}
if (opi)
std::cout << "opi is still not empty!";
2
3
4
5
6
7
8
在上面的示例中,你必须检查opi
来判断optional
是否为空,但opi
的值也可能是nullptr
。
int
指针本身就是 “可空的”,将其包装在optional
中会让使用变得混乱。
# std::optional
的示例
这里有一些更扩展的示例,展示std::optional
的良好应用场景。在第一个示例中,你将看到如何在类中使用optional
。在第二个示例中,我们将介绍整数解析并将结果存储在optional
中。
# 带有可选昵称和年龄的用户名
Chapter Optional/optional_user_name.cpp
#include <optional>
#include <iostream>
using namespace std;
class UserRecord {
public:
UserRecord(string name, optional<string> nick, optional<int> age)
: mName{move(name)}, mNick{move(nick)}, mAge{age}
{ }
friend ostream& operator << (ostream& stream, const UserRecord& user);
private:
string mName;
optional<string> mNick;
optional<int> mAge;
};
ostream& operator << (ostream& os, const UserRecord& user) {
os << user.mName;
if (user.mNick)
os << ' ' << *user.mNick;
if (user.mAge)
os << ' ' << "age of " << *user.mAge;
return os;
}
int main() {
UserRecord tim { "Tim", "SuperTim", 16 };
UserRecord nano { "Nathan", nullopt, nullopt };
cout << tim << '\n';
cout << nano << '\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
上述示例展示了一个带有可选字段的简单类。其中名字是必填项,而其他属性 “昵称” 和 “年龄” 是可选的。
# 从命令行解析整数
Chapter Optional/optional_parsing.cpp
#include <optional>
#include <iostream>
#include <string>
std::optional<int> ParseInt(const char* arg) {
try {
return { std::stoi(std::string(arg)) };
} catch (...) {
std::cerr << "cannot convert '" << arg << " ' to int!\n";
}
return { };
}
int main(int argc, const char* argv[]) {
if (argc >= 3) {
auto oFirst = ParseInt(argv[1]);
auto oSecond = ParseInt(argv[2]);
if (oFirst && oSecond) {
std::cout << "sum of " << *oFirst << " and " << *oSecond;
std::cout << " is " << *oFirst + *oSecond << '\n';
}
}
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
上述代码使用optional
来表示转换是否成功。请注意,我们实际上将异常处理转换为了optional
,抑制了转换过程中可能出现的异常。这种技术可能看起来存在争议。
代码中使用了stoi
,它可以被新的底层函数from_chars
替代。你可以在 “字符串转换” 章节中了解更多关于新转换工具的信息。
# 其他示例
这里还有一些可以使用std::optional
的场景:
- 表示可选的配置值
- 几何与数学:判断对象之间是否存在交集
Find*()
函数的返回值(假设你不关心诸如连接中断、数据库错误之类的问题)
你可能会在“A Wall of Your std::optional Examples (opens new window)”这篇文章中发现其他有趣的用法。这篇博客文章包含了许多读者提交的示例。
# 总结
关于std::optional
需要了解的几个核心要点:
std::optional
是一种包装类型,用于表示 “可空” 类型。std::optional
不会使用任何动态内存分配。std::optional
要么包含一个值,要么为空。- 可以使用
operator *
、operator->
、value()
或value_or()
来访问其内部的值。
- 可以使用
std::optional
可以隐式转换为bool
,这样你可以轻松检查它是否包含值。
# 编译器支持
特性 | GCC | Clang | MSVC |
---|---|---|---|
std::optional | 7.1 | 4.0 | VS 2017 15.0 |