C++17 std::optional:处理可选值的现代方法

在 《错误检测与处理》中,我们讨论了函数遇到无法自行处理的错误时的情形。例如,考虑一个计算并返回值的函数:

int doIntDivision(int x, int y)
{
    return x / y;
}

若调用者传入语义上无效的值(如 y = 0),该函数无法计算出返回值(因为除以 0 在数学上是未定义的)。此时该怎么做?由于计算结果的函数不应有副作用,因此该函数无法自行解决此错误。在这种情况下,通常的做法是让函数检测到错误后,将错误传递回调用者,由其以适当的方式处理。

在前面提到的课程中,我们介绍了两种让函数向调用者返回错误的方法:

  1. 将返回类型为 void 的函数改为返回 bool(表示成功或失败)。
  2. 对于返回值的函数,返回一个哨兵值(sentinel value,一个在函数可能返回值集合中不会出现的特殊值)以表示错误。

以下是一个示例,reciprocal() 函数在用户传入语义上无效的 x 参数时,返回值 0.0(该值在正常情况下不会出现):

#include <iostream>

// x 的倒数是 1/x,若 x = 0 则返回 0.0
double reciprocal(double x)
{
    if (x == 0.0) // 若 x 语义上无效
        return 0.0; // 返回 0.0 作为哨兵值,表示发生错误

    return 1.0 / x;
}

void testReciprocal(double d)
{
    double result{ reciprocal(d) };
    std::cout << "The reciprocal of " << d << " is ";
    if (result != 0.0)
        std::cout << result << '\n';
    else
        std::cout << "undefined\n";
}

int main()
{
    testReciprocal(5.0);
    testReciprocal(-4.0);
    testReciprocal(0.0);

    return 0;
}

虽然这种方法看起来颇具吸引力,但也存在一些潜在的缺点:

  1. 程序员必须知道函数用于表示错误的哨兵值是什么(且每个使用此方法返回错误的函数可能使用不同的值)。
  2. 同一函数的不同版本可能使用不同的哨兵值。
  3. 对于所有可能的哨兵值均为有效返回值的函数,此方法不适用。

考虑前面的 doIntDivision() 函数。若用户传入 y = 0,该函数应返回什么值?我们不能使用 0,因为任何数除以其他数得到 0 均为有效结果。实际上,没有任何值是我们可以返回的、不会自然出现的。

那该怎么办呢?

首先,我们可以选择某个(希望是)不常见的返回值作为哨兵值,并用它来表示错误:

#include <limits> // 用于 std::numeric_limits

// 失败时返回 std::numeric_limits<int>::lowest()
int doIntDivision(int x, int y)
{
    if (y == 0)
        return std::numeric_limits<int>::lowest();
    return x / y;
}

std::numeric_limits<T>::lowest() 是一个返回类型 T 的最小负值的函数。它是我们在 9.5 课《std::cin 与无效输入处理》中介绍的 std::numeric_limits<T>::max() 函数(返回类型 T 的最大正值)的对应物。

在上述示例中,若 doIntDivision() 无法继续执行,我们返回 std::numeric_limits<int>::lowest(),将最小的 int 值返回给调用者,以表明函数失败了。

虽然这种方法大多可行,但它有两个缺点:

  1. 每次调用该函数时,我们都需检测返回值是否等于 std::numeric_limits<int>::lowest(),以判断函数是否失败。这既繁琐又难看。
  2. 这是一个半谓词问题的示例:若用户调用 doIntDivision(std::numeric_limits<int>::lowest(), 1),返回的结果 std::numeric_limits<int>::lowest() 将令人困惑,无法确定函数是成功还是失败。这可能(也可能不)是个问题,取决于函数的实际使用方式,但它是我们需要担心的另一个问题,也是错误可能潜入程序的另一种方式。

其次,我们可以放弃使用返回值来返回错误,改用其他机制(例如异常)。然而,异常有其自身的复杂性和性能成本,可能并不适合或不被期望。对于这种情况,这可能有些小题大做。

第三,我们可以放弃返回单个值,改为返回两个值:一个(类型为 bool)表示函数是否成功,另一个(为期望的返回类型)包含实际返回值(若函数成功)或不确定值(若函数失败)。这可能是最好的选择。

在 C++17 之前,选择最后一种方案需要自己实现。尽管 C++ 提供了多种实现方式,但任何自行实现的方法都不可避免地会导致不一致性和错误。

返回 std::optional

C++17 引入了 std::optional,这是一个类模板类型,用于实现可选值。也就是说,std::optional<T> 可以包含类型为 T 的值,也可以不包含值。我们可以用它来实现上述第三种方案:

#include <iostream>
#include <optional> // 用于 std::optional(C++17)

// 我们的函数现在可选地返回一个 int 值
std::optional<int> doIntDivision(int x, int y)
{
    if (y == 0)
        return {}; // 或 return std::nullopt
    return x / y;
}

int main()
{
    std::optional<int> result1{ doIntDivision(20, 5) };
    if (result1) // 若函数返回了值
        std::cout << "Result 1: " << *result1 << '\n'; // 获取值
    else
        std::cout << "Result 1: failed\n";

    std::optional<int> result2{ doIntDivision(5, 0) };

    if (result2)
        std::cout << "Result 2: " << *result2 << '\n';
    else
        std::cout << "Result 2: failed\n";

    return 0;
}

输出:

Result 1: 4
Result 2: failed

使用 std::optional 相当简单。我们可以用或不用值来构造 std::optional<T>

std::optional<int> o1{ 5 };            // 用值初始化
std::optional<int> o2{};               // 无值初始化
std::optional<int> o3{ std::nullopt }; // 无值初始化

要检查 std::optional 是否有值,可选择以下方法之一:

if (o1.has_value()) // 调用 has_value() 检查 o1 是否有值
if (o2)             // 使用隐式布尔转换检查 o2 是否有值

要从 std::optional 中获取值,可选择以下方法之一:

std::cout << *o1;             // 解引用获取 o1 中存储的值(若 o1 无值则为未定义行为)
std::cout << o2.value();      // 调用 value() 获取 o2 中存储的值(若 o2 无值则抛出 std::bad_optional_access 异常)
std::cout << o3.value_or(42); // 调用 value_or() 获取 o3 中存储的值(若 o3 无值则返回值 `42`)

std::optional 与指针的比较

需要注意的是,std::optional 的使用语法与指针基本相同:

行为指针std::optional
不包含值初始化/赋值为 {}nullptr初始化/赋值为 {}std::nullopt
包含值初始化/赋值为地址初始化/赋值为值
检查是否包含值隐式转换为布尔值隐式转换为布尔值或调用 has_value()
获取值解引用解引用或调用 value()

然而,从语义上讲,指针和 std::optional 是完全不同的。

  • 指针具有引用语义,意味着它引用其他对象,赋值时复制的是指针本身,而非对象。若按地址返回指针,指针被复制回调用者,而非被指向的对象。这意味着我们不能按地址返回局部对象,因为局部对象的地址被复制回调用者后,该对象将被销毁,留下返回的

关注公众号,回复"cpp-tutorial"

可领取价值199元的C++学习资料

公众号二维码

扫描上方二维码或搜索"cpp-tutorial"