在 《错误检测与处理》中,我们讨论了函数遇到无法自行处理的错误时的情形。例如,考虑一个计算并返回值的函数:
int doIntDivision(int x, int y)
{
return x / y;
}
若调用者传入语义上无效的值(如 y = 0
),该函数无法计算出返回值(因为除以 0 在数学上是未定义的)。此时该怎么做?由于计算结果的函数不应有副作用,因此该函数无法自行解决此错误。在这种情况下,通常的做法是让函数检测到错误后,将错误传递回调用者,由其以适当的方式处理。
在前面提到的课程中,我们介绍了两种让函数向调用者返回错误的方法:
- 将返回类型为
void
的函数改为返回bool
(表示成功或失败)。 - 对于返回值的函数,返回一个哨兵值(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;
}
虽然这种方法看起来颇具吸引力,但也存在一些潜在的缺点:
- 程序员必须知道函数用于表示错误的哨兵值是什么(且每个使用此方法返回错误的函数可能使用不同的值)。
- 同一函数的不同版本可能使用不同的哨兵值。
- 对于所有可能的哨兵值均为有效返回值的函数,此方法不适用。
考虑前面的 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
值返回给调用者,以表明函数失败了。
虽然这种方法大多可行,但它有两个缺点:
- 每次调用该函数时,我们都需检测返回值是否等于
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> // 用于 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
是完全不同的。
- 指针具有引用语义,意味着它引用其他对象,赋值时复制的是指针本身,而非对象。若按地址返回指针,指针被复制回调用者,而非被指向的对象。这意味着我们不能按地址返回局部对象,因为局部对象的地址被复制回调用者后,该对象将被销毁,留下返回的