第十五章 std::optional<>

编程的时候我们经常遇到的一个情况是可能需要返回/传递/使用某个对象。也就是说,我们可能要获取某个类型的值,也可能完全不获取。因此,我们需要一种方式来模拟这种类似指针的语义,当不需要值的时候给它传nullptr。处理这种情况的通常方式是定义一个类型,里面包含了值本身,同时还带一个布尔值的成员(或者说flag)来标示值是否存在。

**可空对象(Optional objects)**就是这样的,它的内部内存主要包含一个对象加上一个布尔类型的flag。因此,它的大小通常是比包含的对象要大一个字节。对于某些包含的对象,鉴于额外的信息可以放到包含的对象里面,可空对象本身甚至可以做到零额外开销。不会分配内存。可空对象和被包含的对象完全一致。

然而,可空对象不仅仅是在结构布局上比普通对象多了个布尔值的flag。举个例子,如果没有值,那么被包含对象的构造函数也不会被调用(因此,这种情况下你可以给这些对象填充一个默认的状态)。

如同std::variant<>std::any对象一样,可控对象也有值语义。也即是说,拷贝操作的底层实现是深拷贝,会创建一个完全不同的被包含的对象的副本以及布尔flag。拷贝不带包含值的std::optional<>可选对象开销非常小。拷贝一个带包含值的std::optional<>开销大不大完全取决于拷贝这个包含值。另外可空对象也支持移动语义。

15.1 使用std::optional<>

std::optional<>塑造了一个可容纳类型的、可空的对象。这个对象可以是成员,也可以是实参,又或者返回值。你也可以说std::optional<>是一个容器,容纳最多一个元素(译注:最少零个)。

15.1.1 可空的返回值

下面的程序演示了std::optional<>用来作为返回值的能力:

#include <optional>
#include <string>
#include <iostream>

// convert string to int if possible:
std::optional<int> asInt(const std::string& s)
{
    try {
        return std::stoi(s);
    }
    catch (...) {
        return std::nullopt;
    }
}
int main()
{
    for (auto s : {"42", " 077", "hello", "0x33"} ) {
        // try to convert s to int and print the result if possible:
        std::optional<int> oi = asInt(s);
        if (oi) {
            std::cout << "convert '" << s << "' to int: " << *oi << "\n";
        }
        else {
            std::cout << "can't convert '" << s << "' to int\n";
        }
    }
}

程序里面asInt()这个函数将传过来的字符串转换为一个int值。然而,这可能会失败。出于这个原因,我们用std::optional<>,这样我们就可以返回“无int”并且避免了用一个特殊int值表示失败或者给调用者抛一个异常。

因此,我们既可以调用stoi()来初始化可空对象,也可以返回std::nullopt,告诉调用者我们没有int要返回给你。我们可以用下面的代码实现相同的行为:

std::optional<int> asInt(const std::string& s)
{
    std::optional<int> ret; // initially no value
    try {
        ret = std::stoi(s);
    }
    catch (...) {
    }
    return ret;
}

main()中我们调用这个函数,然后传给它不同的字符串:

for (auto s : {"42", " 077", "hello", "0x33"} ) {
    // convert s to int and use the result if possible:
    std::optional<int> oi = asInt(s);
    ...
}

对于每个返回的std::optional<int> oi,我们都要检查一下是否有值(通过查布尔flag得知)然后通过对可空对象“解引用”来访问被包含的对象:

if (oi) {
    std::cout << "convert '" << s << "' to int: " << *oi << "\n";
}

注意字符串“0x33”调用asInt()拿到了0,因为stoi()不对十六进制字符串做解析。 还有一只可选的方式来实现返回值处理:

if (oi.has_value()) {
    std::cout << "convert '" << s << "' to int: " << oi.value() << "\n";
}

这里,has_value()用于检查可空对象是否包含值,然后用value()访问值。value()operator *更安全:如果没有值,它抛出异常(译注:而不是对空对象解引用产生段错误)。operator *应该只被用于那种你非常确信可空对象不是空的的场景。否则你的程序将产生未定义行为(undefined behavior)。 注意我们也可以使用新标准库类型std::string_view来优化asInt()

15.1.2 可空对象作为实参和对象成员

另一个使用std::optional<>的例子是传实参并且/或者将它作为对象的数据成员:

#include <string>
#include <optional>
#include <iostream>

class Name
{
    private:
        std::string first;
        std::optional<std::string> middle;
        std::string last;
    public:
        Name (std::string f,
              std::optional<std::string> m,
              std::string l)
          : first{std::move(f)}, middle{std::move(m)}, last{std::move(l)} {
        }
        friend std::ostream& operator << (std::ostream& strm, const Name& n) {
            strm << n.first << ' ';
            if (n.middle) {
                strm << *n.middle << ' ';
            }
            return strm << n.last;
        }
};

int main()
{
    Name n{"Jim", std::nullopt, "Knopf"};
    std::cout << n << '\n';
    Name m{"Donald", "Ervin", "Knuth"};
    std::cout << m << '\n';
}

Name表示名字,由first namemiddle namelast name三个成员组成。由于middle name有可能不存在,因此它被定义为可空对象,这样构造函数就可以传一个std::nullopt来表示没有middle name。这和middle name是空字符串是不一样的。

注意通常类型都带值语义,定义一个初始化对应成员的构造函数的最好方式是实参值传递,然后构造函数里面移动形参到成员。

还有一点也要注意,std::optional<>改变了middle name的访问方式。将middle作为布尔表达式可以知道是否存在middle name,不过要想访问它的值还是得*middle(如果有的话)。

另一种访问可空对象包含的值的方式是使用成员函数value_or(),它可以指定一个备选值,当可空对象真的空的时候这个备选值将作为结果。举个例子,我们可以这样做:

std::cout << middle.value_or(""); // print middle name or nothing

15.2 std::optional<>类型和操作

这一小节描述了std::optional<>的类型和操作细节。

15.2.1 std::optional<>类型

在C++标准库头文件<optional>使用下面的形式定义std::optional<>

namespace std{
    template<typename T> class optional;
}

另外,还定义了下面的对象和对象:

  • 定义了nullopt对象,它的类型是std::nullopt_t,它表示可空对象没有值
  • 定义了异常类std::bad_optional_access,继承自std::exception,如果访问空的可空对象将引发此异常。

15.2.2 std::optional<>操作

下表列出了针对std::optional<>的所有操作。

构造

特殊的构造函数可以直接传递包含的值作为构造函数参数:

  • 你可以创建一个空的可空对象。
std::optional<int> o1;
std::optional<int> o2(std::nullopt);
  • 你可以传值来初始化可空对象里面包含的对象。因为类模板参数推导规则,你不用非得指定包含对象的类型:
std::optional o3{42}; // deduces optional<int>
std::optional<std::string> o4{"hello"};
std::optional o5{"hello"}; // deduces optional<const char*>
操作效果
构造创建一个可空对象(可能会调用包含的对象的构造函数)
make_optional<>()创建一个可空对象(传值初始化它
析构函数销毁可空对象
=赋新值
emplace()给包含的对象赋新值
reset()销毁值(让可空对象变空)
has_value()可空对象是否为空
强制类型转换为bool可空对象是否为空
*访问被包含的值(如果可空对象为空的时候执行该操作会产生未定义行为)
->访问被包含的值(如果可空对象为空的时候执行该操作会产生未定义行为)
value()访问被包含的值(如果可空对象为空的时候执行该操作会引发异常)
value_or()访问被包含的值(如果可空对象为空的时候执行该操作会返回备选值)
swap()交换两个可空对象
==,!=,<,<=,>,>=比较两个可空对象
hash<>计算两个可空对象的哈希值
  • 要用多个值初始化可空对象,你必须直接创建该对象,或者std::in_place作为第一个参数然后传递剩下的值(因为被包含的类型不能推导):
std::optional o6{std::complex{3.0, 4.0}};
std::optional<std::complex<double>> o7{std::in_place, 3.0, 4.0};

注意第二种形式避免了临时对象的创建。使用这种形式你甚至可以传递一个initializer list再加上额外的参数:

// initialize set with lambda as sorting criterion:
auto sc = [] (int x, int y) {
    return std::abs(x) < std::abs(y);
};
std::optional<std::set<int,decltype(sc)>> o8    {std::in_place,
                                                {4, 8, -7, -2, 0, 5},
                                                sc};
  • 你可以拷贝可空对象
std::optional o5{"hello"}; // deduces optional<const char*>
std::optional<std::string> o9{o5}; // OK

注意还有一个便捷函数make_optional<>(),它允许你用一个或者多个参数初始化可空对象(不需要in_place作为第一个参数)。通常用make...系列函数都会导致类型退化(译注:decay):

auto o10 = std::make_optional(3.0); // optional<double>
auto o11 = std::make_optional("hello"); // optional<const char*>
auto o12 = std::make_optional<std::complex<double>>(3.0, 4.0);

但是请注意没有构造函数可以根据一个参数来推导他的类型,不管可空对象初始化带不带值。因此,必须使用operator ?。举个例子:

std::multimap<std::string, std::string> englishToGerman;
...
auto pos = englishToGerman.find("wisdom");
auto o13 = pos != englishToGerman.end()
            ? std::optional{pos->second}
            : std::nullopt;

因为类模板参数推导规则std::optional{pos->second}o13被初始化为std::optional<std::string>类型。对于std::nullopt,模板类型推导无法正常工作,但是在推导表达式最终类型的时候operator ?可以将它转换为这个类型。

访问值

要检查可空对象是不是空的,你可以将它作为布尔表达式或者调用has_value()函数:

std::optional o{42};

if (o) ... // true
if (!o) ... // false
if (o.has_value()) ... // true

接下来要访问值,可以用类似指针的语法。你可以直接用operator *访问包含的对象,用operator->访问包含的对象的成员:

std::optional o{std::pair{42, "hello"}};

auto p = *o; // initializes p as pair<int,string>
std::cout << o->first; // prints 42

注意这些操作都要求可空对象本身不为空。如果可空对象为空又执行这些操作将会产生未定义行为:

std::optional<std::string> o{"hello"};
std::cout << *o; // OK: prints "hello"
o = std::nullopt;
std::cout << *o; // undefined behavior

虽然第二个是未定义行为,但是实践中它很可能会通过编译并且执行结果和第一个一样,都输出"hello",因为可空对象管理的内存没有被修改。 然而,你不能,也不应该依赖这个。如果你不知道可空对象是不是空的,那么请事先检查:

if (o) std::cout << *o; // OK (might output nothing)

或者你可以用value()检查,它会跑抛出std::bad_optional_access_exception

std::cout << o.value(); // OK (throws if no value)

std::bad_optional_access_exception直接继承自std::exception。 最后,你可以在检查是否为空的时候传一个备选值,如果可空对象真的是空的那么将返回这个备选值:

std::cout << o.value_or("fallback"); // OK (outputs fallback if no value)

备选值是通过右值引用的方式传递的,所以如果备选值没有被使用,整个传递过程零开销,如果被使用,走的是移动语义。

请注意operator *value()一样都是返回的被包含的对象的引用。因此,你必须小心操作这些临时返回值。比如:

std::optional<std::string> getString();
...
auto a = getString().value(); // OK: copy of contained object
auto b = *getString(); // ERROR: undefined behavior if std::nullopt
const auto& r1 = getString().value(); // ERROR: reference to deleted contained object
auto&& r2 = getString().value(); // ERROR: reference to deleted contained object

有时候你可能会像下面一样把它用于range-based循环中:

std::optional<std::vector<int>> getVector();
...
for (int i : getVector().value()) { // ERROR: iterate over deleted vector
    std::cout << i << '\n';
}

返回int的vector,然后迭代它是可以的。所以不要轻易的将foo()返回值类型改变成对应的可空类型,而应该调用foo().value()

比较

你可以使用普通的比较运算符。操作数可以是一个可空对象、被包含的对象、std::nullopt

  • 如果操作数都是不为空的可空对象,将会比较被包含的值
  • 如果操作数都是空的可空对象,那么比较运算会认为它们相等(==产生true值,其他比较运算符产生false值)
  • 如果一个操作数为空,一个不为空,为空的那个操作数将会被认为是小于不为空的那个操作数

比如:

std::optional<int> o0;
std::optional<int> o1{42};

    o0 == std::nullopt // yields true
    o0 == 42 // yields false
    o0 < 42 // yields true
    o0 > 42 // yields false
    o1 == 42 // yields true
    o0 < o1 // yields true

这意味着对于包含unsigned int的可空对象,它可以小于零,对于包含bool的可空对象,它也可以小于零:

std::optional<unsigned> uo;
    uo < 0 // yields true
std::optional<bool> bo;
    bo < false // yields true

再次强调,包含类型的隐式类型转换是支持的:

std::optional<int> o1{42};
std::optional<double> o2{42.0};

o2 == 42 // yields true
o1 == o2 // yields true

另外包含bool或者原生指针的可空对象在这里会产生一些令人意外的结果。

修改值

赋值操作和emplace()操作与初始化对应:

std::optional<std::complex<double>> o; // has no value
std::optional ox{77}; // optional<int> with value 77

o = 42; // value becomes complex(42.0, 0.0)
o = {9.9, 4.4}; // value becomes complex(9.9, 4.4)
o = ox; // OK, because int converts to complex<double>
o = std::nullopt; // o no longer has a value
o.emplace(5.5, 7.7); // value becomes complex(5.5, 7.7)

给可空对象赋std::nullopt会移除原来的包含值,即调用原包含值的析构函数。你可以用reset()实现一样的效果:

o.reset();  // o no longer has a value

或者赋给它一个{}

o = {}; // o no longer has a value

最后,我们也可以用operator *修改值,因为它产生包含值的引用。然而注意前提是可空对象得有值存在:

std::optional<std::complex<double>> o;
*o = 42; // undefined behavior
...
if (o) {
    *o = 88; // OK: value becomes complex(88.0, 0.0)
    *o = {1.2, 3.4}; // OK: value becomes complex(1.2, 3.4)
}

移动语义

std::optional<>也支持移动语义。如果你将整个对象移动,状态会随之被复制,被包含的值(如果有的话)也会被移动。 结果就是,移动后的对象状态仍然还保留,但是被包含的值已经不在了。 但是你可以将一个值移动到被包含对象里面,或者从被包含对象里面移出去。比如:

std::optional<std::string> os;
std::string s = "a very very very long string";
os = std::move(s); // OK, moves
std::string s2 = *os; // OK copies
std::string s3 = std::move(*os); // OK, moves

执行完最后一行,os仍然还有字符串的值,但是通常来说移动后的对象的值都是不存在的(译注:原文是unspecified)。因此, 你仍然可以使用它,前提是你不要对它是什么做任何假设。你甚至可以给它赋一个新字符串。

哈希

可空对象的哈希值是被包含对象的哈希值(如果存在的话)。

15.3 特殊情况

可空对象包含特定的类型可能产生令人意想不到的结果甚至未定义行为。

15.3.1 包含布尔值或原生指针

对可空对象使用比较运算符和将它视作布尔值有不同的雨衣。如果可空对象包含布尔值或者原生指针可能会产生一些困扰:比如:

std::optional<bool> ob{false}; // has value, which is false
if (!ob) ... // yields false
if (ob == false) ... // yields true

std::optional<int*> op{nullptr};
if (!op) ... // yields false
if (op == nullptr) ... // yields true

15.3.2 可空对象里面包含可空对象

原则上,你可以定义包含可空对象的可空对象:

std::optional<std::optional<std::string>> oos1;
std::optional<std::optional<std::string>> oos2 = "hello";
std::optional<std::optional<std::string>>
    oos3{std::in_place, std::in_place, "hello"};

std::optional<std::optional<std::complex<double>>>
    ooc{std::in_place, std::in_place, 4.2, 5.3};

你也可以借助隐式转换给它赋一个新值:

oos1 = "hello"; // OK: assign new value
ooc.emplace(std::in_place, 7.2, 8.3);

两层可空对象都没有值,但是最外层可空对象和最内层可空对象有没有值有待商榷:

*oos1 = std::nullopt; // inner optional has no value
oos1 = std::nullopt; // outer optional has no value

你必须小心谨慎处理这些特殊例子:

if (!oos1) std::cout << "no value\n";
if (oos1 && !*oos1) std::cout << "no inner value\n";
if (oos1 && *oos1) std::cout << "value: " << **oos1 << '\n';

因为这个语义上不仅仅是说一个有两种状态的值表示没有值,使用std::variant<>包裹两个bool或者用std::monostate可能是更合适的选择。

15.4 后记

可空对象首先由Fernando Cacciola在2005的https://wg21.link/n1878中提出。Fernando Cacciola和Andrzej Krzemienski提出新提案https://wg21.link/n3793被Library Fundamentals TS接受

Beman Dawes和Alisdair Meredith的新提案https://wg21.link/p0220r1被其他C++17组件接受。

Tony van Eerd极大的改进了比较操作的语义,提案参见[https:// wg21.link/n3765](https:// wg21.link/n3765)和https://wg21.link/p0307r2。 Vicente J. Botet Escriba 优化了std::optional<> 、std::variant<>和std::anyAPI,提案参见https://wg21.link/p0032r3。Jonathan Wakely修复了in_place的行为,提案参见https://wg21.link/p0504r0。