12-15 std::optional

在第 9.4 课——检测和处理错误中,我们讨论了函数遇到自身无法合理处理的错误的情况。例如,考虑一个计算并返回值的函数:

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

如果调用者传入的值在语义上无效(例如y = 0),则此函数无法计算返回值(因为除以 0 在数学上是未定义的)。在这种情况下我们该怎么办?由于计算结果的函数不应产生副作用,因此此函数本身无法合理地解决错误。在这种情况下,通常的做法是让函数检测到错误,然后将错误返回给调用者,由调用者以某种适合程序的方式进行处理。

在之前链接的课程中,我们介绍了让函数向调用者返回错误的两种不同方法:

  • 将返回值为 void 的函数改为返回布尔值(表示成功或失败)。
  • 让返回值的函数返回一个哨兵值(一个特殊值,它不属于该函数可以返回的可能值集合),以指示错误。

例如,如果用户为以下函数参数x传入一个语义无效的参数,则该函数reciprocal()会返回一个值0.0(否则永远不会发生) :

#include <iostream>

// The reciprocal of x is 1/x, returns 0.0 if x=0
double reciprocal(double x)
{
    if (x == 0.0) // if x is semantically invalid
       return 0.0; // return 0.0 as a sentinel to indicate an error occurred

    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;
}

image

虽然这是一个相当有吸引力的解决方案,但也存在一些潜在的缺点:

  • 程序员必须知道函数使用哪个哨兵值来指示错误(并且对于使用此方法返回错误的每个函数,此值可能不同)。
  • 同一函数的不同版本可能使用不同的哨兵值。
  • 对于所有可能的哨兵值都是有效返回值的函数,此方法无效。

考虑doIntDivision()上面的函数。如果用户传入一个值0作为参数,它会返回什么值?我们不能使用0,因为0除以任何数都会得到0。事实上,我们无法返回任何自然界中不存在的值。

那我们该怎么办?

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

#include <limits> // for std::numeric_limits

// returns std::numeric_limits<int>::lowest() on failure
int doIntDivision(int x, int y)
{
    if (y == 0)
        return std::numeric_limits<int>::lowest();
    return x / y;
}

image

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

在上面的示例中,如果doIntDivision()无法继续,我们将返回std::numeric_limits<int>::lowest(),该返回值将为负的整数值返回给调用者,以表明函数失败。

虽然这种方法大部分情况下有效,但它有两个缺点:

  • 每次调用这个函数时,我们都需要检查返回值是否相等,以std::numeric_limits<int>::lowest()确定是否失败。这既冗长又麻烦。
  • 这是一个半谓词问题的例子:如果用户调用某个函数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> // for std::optional (C++17)

// Our function now optionally returns an int value
std::optional<int> doIntDivision(int x, int y)
{
    if (y == 0)
        return {}; // or return std::nullopt
    return x / y;
}

int main()
{
    std::optional<int> result1 { doIntDivision(20, 5) };
    if (result1) // if the function returned a value
        std::cout << "Result 1: " << *result1 << '\n'; // get the value
    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;
}

打印出来的内容:

image

使用起来std::optional非常简单。我们可以构造一个std::optional<T>带值或不带值的元素:

std::optional<int> o1 { 5 };            // initialize with a value
std::optional<int> o2 {};               // initialize with no value
std::optional<int> o3 { std::nullopt }; // initialize with no value

要判断 std::optional 是否有值,我们可以选择以下方法之一:

if (o1.has_value()) // call has_value() to check if o1 has a value
if (o2)             // use implicit conversion to bool to check if o2 has a value

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

std::cout << *o1;             // dereference to get value stored in o1 (undefined behavior if o1 does not have a value)
std::cout << o2.value();      // call value() to get value stored in o2 (throws std::bad_optional_access exception if o2 does not have a value)
std::cout << o3.value_or(42); // call value_or() to get value stored in o3 (or value `42` if o3 doesn't have a value)

image

请注意,它std::optional的使用语法与指针基本相同:

Behavior Pointer std::optional
Hold no value initialize/assign {} or std::nullptr initialize/assign {} or std::nullopt
Hold a value initialize/assign an address initialize/assign a value
Check if has value implicit conversion to bool implicit conversion to bool or has_value()
Get value dereference dereference or value()

然而,从语义上讲,指针和 astd::optional却截然不同。

  • 指针具有引用语义,这意味着它指向另一个对象,赋值操作复制的是指针本身,而不是对象。如果我们按地址返回指针,指针会被复制回调用者,而不是指针指向的对象。这意味着我们不能按地址返回局部对象,因为我们会将该对象的地址复制回调用者,然后该对象会被销毁,导致返回的指针悬空。
  • std::optional具有值语义,这意味着它实际上包含其值,赋值操作会复制该值。如果我们按值返回一个std::optional ,则std::optional(包括其包含的值)将被复制回调用者。这意味着我们可以使用std::optional.

考虑到这一点,让我们来看看示例是如何工作的。doIntDivision()现在,我们的函数返回的是一个 std::optional<int>而不是一个 int 。在函数体内部,如果检测到错误,我们返回 {},这隐式地返回一个不包含值的 std::optional。如果检测到值,我们返回该值,这隐式地返回一个包含该值的 std::optional

在函数内部main(),我们使用隐式转换为布尔值来检查返回的std::optional是否有值。如果有值,我们就解引用该std::optional对象来获取值。如果没有值,我们就执行错误处理。就是这样!

返回 std::optional 的优点和缺点

返回 std::optional值有很多好处:

  • 使用std::optional有效的文档说明函数可能返回值也可能不返回值。
  • 我们不必记住哪个值会被作为哨兵返回。
  • 它的使用语法std::optional简单易懂。

返回std::optional确实存在一些缺点:

  • 我们必须先确保数组std::optional包含值才能获取值。如果我们解引用一个std::optional不包含值的数组,就会出现未定义行为。
  • std::optional没有提供将函数失败原因信息传递回去的方法。
  • 除非你的函数需要返回有关其失败原因的额外信息(以便更好地理解失败原因,或区分不同类型的失败),否则std::optional对于可能返回值或失败的函数来说,这是一个极好的选择。

最佳实践
对于可能失败的函数,返回一个std::optional(而不是哨兵值),除非你的函数需要返回有关其失败原因的额外信息。

相关内容
std::expected(C++23 中引入)旨在处理函数可能返回预期值或意外错误代码的情况。有关更多信息,请参阅std::expected 参考文档。

std::optional 用作可选函数参数

在第12.11 课——按地址传递(第二部分)中,我们讨论了如何使用按地址传递来允许函数接受“可选”参数(也就是说,调用者可以传入一个空值nullptr来表示“无参数”,也可以传入一个对象)。然而,这种方法的一个缺点是,非空参数必须是左值(这样才能将其地址传递给函数)。

顾名思义,std::optional 函数提供了一种接受可选参数(仅用作输入参数)的替代方法。例如:

#include <iostream>

void printIDNumber(const int *id=nullptr)
{
    if (id)
        std::cout << "Your ID number is " << *id << ".\n";
    else
        std::cout << "Your ID number is not known.\n";
}

int main()
{
    printIDNumber(); // we don't know the user's ID yet

    int userid { 34 };
    printIDNumber(&userid); // we know the user's ID now

    return 0;
}

image

你可以这样做:

#include <iostream>
#include <optional>

void printIDNumber(std::optional<const int> id = std::nullopt)
{
    if (id)
        std::cout << "Your ID number is " << *id << ".\n";
    else
        std::cout << "Your ID number is not known.\n";
}

int main()
{
    printIDNumber(); // we don't know the user's ID yet

    int userid { 34 };
    printIDNumber(userid); // we know the user's ID now

    printIDNumber(62); // we can also pass an rvalue

    return 0;
}

image

这种方法有两个优点:

  1. 它有效地说明了该参数是可选的。
  2. 我们可以传入一个右值(因为std::optional会创建一个副本)。

然而,由于 std::optional 会复制其参数,当参数T是复制成本很高的类型(例如 std::string)时,就会出现问题。对于普通的函数参数,我们通过将参数设为 const lvalue reference 来解决这个问题,这样就不会进行复制。遗憾的是,从 C++23 开始, std::optional 不再支持引用。

因此,我们建议仅在通常按值传递std::optional时才将其用作可选参数。否则,请使用const T*

适合高级读者

虽然std::optional它不直接支持引用,但你可以使用std::reference_wrapper(我们将在第17.5 课——通过 std::reference_wrapper 创建引用数组中std::reference_wrapper介绍)来模拟引用。让我们看看使用std::stringstd::reference_wrapper 后上面的程序会是什么样子:

#include <functional>  // for std::reference_wrapper
#include <iostream>
#include <optional>
#include <string>

struct Employee
{
    std::string name{}; // expensive to copy
    int id;
};

void printEmployeeID(std::optional<std::reference_wrapper<Employee>> e=std::nullopt)
{
    if (e)
        std::cout << "Your ID number is " << e->get().id << ".\n";
    else
        std::cout << "Your ID number is not known.\n";
}

int main()
{
    printEmployeeID(); // we don't know the Employee yet

    Employee e { "James", 34 };
    printEmployeeID(e); // we know the Employee's ID now

    return 0;
}

image

为了进行比较,这里给出指针版本:

#include <iostream>
#include <string>

struct Employee
{
    std::string name{}; // expensive to copy
    int id;
};

void printEmployeeID(const Employee* e=nullptr)
{
    if (e)
        std::cout << "Your ID number is " << e->id << ".\n";
    else
        std::cout << "Your ID number is not known.\n";
}

int main()
{
    printEmployeeID(); // we don't know the Employee yet

    Employee e { "James", 34 };
    printEmployeeID(&e); // we know the Employee's ID now

    return 0;
}

image
这两个程序几乎完全相同。我们认为前者并不比后者更易读或更易维护,因此没有必要为了它而在程序中引入两种额外的类型。

在许多情况下,函数重载提供了一种更优的解决方案:

#include <iostream>
#include <string>

struct Employee
{
    std::string name{}; // expensive to copy
    int id;
};

void printEmployeeID()
{
    std::cout << "Your ID number is not known.\n";
}

void printEmployeeID(const Employee& e)
{
    std::cout << "Your ID number is " << e.id << ".\n";
}

int main()
{
    printEmployeeID(); // we don't know the Employee yet

    Employee e { "James", 34 };
    printEmployeeID(e); // we know the Employee's ID now

    printEmployeeID( { "Dave", 62 } ); // we can even pass rvalues

    return 0;
}

最佳实践
建议std::optional使用可选的返回类型。
对于可选函数参数,应尽可能使用函数重载。否则,当 T 通常按值传递时,请使用 std::Optional<T> 作为可选参数。当T复制成本较高时,也应优先使用 const T*

posted @ 2025-12-19 12:07  游翔  阅读(24)  评论(0)    收藏  举报