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::optionalstd::anystd::variantstd::string_view一样,是C++的标准库类型。

# 使用场景

通常可以在以下场景中使用可选包装器:

  1. 如果你想表示一个可空类型。
    • 而不是使用特殊值(如-1、nullptrNO_VALUE等)。
    • 例如,用户的中间名是可选的。你可能认为空字符串在这里可行,但了解用户是否输入了内容可能很重要。std::optional<std::string>可以提供更多信息。
  2. 返回某些计算(处理)的结果,当计算未能产生值且并非错误时。
    • 例如,在字典中查找元素:如果某个键下没有元素,这不是错误,但我们需要处理这种情况。
  3. 实现资源的延迟加载。
    • 例如,如果资源类型的构造开销很大,或者没有默认构造函数,你可以将其定义为std::optional<Resource>。以这种形式,你可以在系统中传递它,然后在应用程序首次访问时对其进行初始化(加载资源)。
  4. 向函数传递可选参数。

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);
1
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;
1
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};
1
2

你也可以不写构造函数来编写上述代码:

std::optional<std::string> ostr{std::string{"Hello World"}};
std::optional<int> oi{int {10}};
1
2

因为std::optional有一个构造函数,它接受U&&(对一种类型的通用引用,该类型可转换为optional中存储的类型)。在我们的例子中,它被识别为const char*,而字符串可以由此初始化。

那么在std::optional中使用std::in_place_t有什么好处呢?至少有两个关键原因:

  • 默认构造函数
  • 对有多个参数的构造函数进行高效构造
  • 默认构造:如果你有一个带有默认构造函数的类,例如:
class UserName {
public:
    UserName() : mName("Default") {
    }
    // ...
};
1
2
3
4
5
6

你要如何创建一个包含UserName{}optional对象呢?你可以这样写:

std::optional<UserName> u0;   // 空的optional
std::optional<UserName> u1{}; // 也是空的
// 包含默认构造对象的optional:
std::optional<UserName> u2{UserName()};
1
2
3
4

这样做是可行的,但它会创建一个额外的临时对象。如果我们追踪每个不同的构造函数和析构函数调用,会得到以下输出:

UserName::UserName('Default')
UserName::UserName(move 'Default')  // 移动临时对象
UserName::~UserName('')             // 删除临时对象
UserName::~UserName('Default')
1
2
3
4

这段代码创建了一个临时对象,然后将其移动到optional中存储的对象里。在这里,我们可以使用一种更高效的构造函数——利用std::in_place_t

std::optional<UserName> opt{std::in_place};
1

追踪构造函数和析构函数时,你会得到以下输出:

UserName::UserName('Default')
UserName::~UserName('Default')
1
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}}; // 创建了临时对象!
1
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 );
1
2
3
4
5
6
7
8

你的代码可以这样写:

std::optional<Point> opt{std::in_place_t, 0, 0};
1

第二种选择虽然冗长,但避免了创建临时对象。对于容器或较大的对象来说,临时对象的效率不如就地构造。

  • std::make_optional():如果你不喜欢std::in_place,那么可以看看make_optional工厂函数。下面的代码:
auto opt = std::make_optional<UserName>();
auto opt = std::make_optional<Point>(0, 0);
1
2

与下面的代码效率相同:

std::optional<UserName> opt{std::in_place};
std::optional<Point> opt{std::in_place_t, 0, 0};
1
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{...});
1
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{...});
1
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;   // 这会进行移动操作
}
1
2
3
4
5

根据标准,如果将返回值用大括号{}括起来,就会阻止移动操作发生,返回的对象只会被拷贝。

这与不可拷贝类型的情况类似:

std::unique_ptr<int> foo() {
    std::unique_ptr<int> p;
    return {p};  // 尝试拷贝unique_ptr,编译会失败
    // return p;  // 这会进行移动操作,所以对于unique_ptr来说没问题
}
1
2
3
4
5

³感谢JFT指出这个问题。

标准在[class.copy.elision]/3⁴中规定:

在以下复制初始化上下文中,可以使用移动操作代替复制操作:
- 如果return语句([stmt.return])中的表达式是(可能带括号的)标识表达式,该表达式命名了在最内层封闭函数或lambda表达式的函数体或形参声明子句中声明的具有自动存储期的对象;
- 如果throw表达式的操作数是一个非易失性自动对象(不是函数或catch子句参数)的名称,且其作用域不超出最内层封闭try块(如果有的话)的末尾

可以试着运行位于Chapter Optional/optional_return.cpp中的示例代码。该代码展示了一些使用std::unique_ptrstd::vectorstd::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 ';
1
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 ";
1
2
3
4
5
6
7

# std::optional的操作

让我们看看这个类型还有哪些其他操作。

# 修改值和对象生命周期

如果你已经有一个std::optional对象,可以通过emplaceresetswapassign等操作快速修改其中包含的值。如果用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");
}
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

每次对象发生改变时,当前存储的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 ';
}
1
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)
1
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: // 操作
};
1
2
3
4
5
6

std::optional的对齐规则在optional.optional⁵中定义如下:

所包含的值应分配在std::optional存储区中适合类型T对齐的区域内

例如,假设sizeof(double) = 8sizeof(int) = 4

std::optional<double> od; // sizeof = 16字节
std::optional<int> oi;    // sizeof = 8字节
1
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; 
};
1
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_tin_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!";
1
2
3
4
5
6
7
8

在上面的示例中,你必须检查opi来判断optional是否为空,但opi的值也可能是nullptrint指针本身就是 “可空的”,将其包装在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';
}
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

上述示例展示了一个带有可选字段的简单类。其中名字是必填项,而其他属性 “昵称” 和 “年龄” 是可选的。

# 从命令行解析整数

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';
        }
    }
}
1
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
上次更新: 2025/04/01, 13:21:34

社区交流

讨论与留言

前往 GitHub Issues →

本留言区仅对应当前文章,欢迎补充观点、提出问题或帮助修正文中疏漏。 留言由 GitHub/Gitalk 提供,需要使用 GitHub 登录。

最近更新
第 1 课:专栏导论与安全边界
07-01
第 3 课:安装编译与调试环境
07-01
第 4 课:第三方库下载、编译与依赖管理
07-01
更多文章>