跳转到内容

其他词汇类型

词汇类型(vocabulary types)是一类你很可能会高频使用的类型,使用频率几乎不亚于 intdouble 这类基础类型。它们经常被拿来构建更复杂的类型。合理使用这类类型,可以让代码更安全、更高效,也更容易编写、阅读和维护。本书前面已经讨论过的一些词汇类型包括 vectoroptionalstringunique_ptrshared_ptr 等。

本章先从另外两种词汇类型开始:variantany。接着会更深入地讨论 tuple,它可以看作 pair 的泛化,以及它所支持的各种操作。随后会介绍 optional 对单子操作(monadic operations)的支持,这让你在 optional 上链式组合操作时轻松很多,因为你不需要在每一步之前都先判断该 optional 是否为空。本章最后会介绍 expected。这是一种既可以保存某个期望类型的值,也可以保存错误值的数据类型;其中用于表示错误的类型,可以与表示正常值的类型完全不同。

定义在 <variant> 中的 std::variant,可以在一组给定类型中任选其一来保存单个值。定义 variant 时,必须先明确列出它可能保存的所有类型。例如,下面这段代码定义了一个 variant,它可以保存整数、字符串或浮点值,但任意时刻只能保存其中一种:

variant<int, string, float> v;

variant 的模板类型参数必须互不重复;例如,variant<int,int> 是非法的。默认构造的 variant 会保存其第一个类型的一个默认构造值;对这里的 variant v 来说,就是默认构造的 int。因此,如果你希望某个 variant 可以默认构造,就必须确保它的第一个类型本身支持默认构造。比如,下面这段代码就无法通过编译,因为 Foo 不可默认构造:

class Foo { public: Foo() = delete; Foo(int) {} };
class Bar { public: Bar() = delete; Bar(int) {} };
variant<Foo, Bar> v;

实际上,这里的 FooBar 都不支持默认构造。如果你仍然希望这样的 variant 可以默认构造,那么可以把 std::monostate 作为 variant 的第一个类型。它是一个行为良好的空备选项:

variant<monostate, Foo, Bar> v;

你可以使用赋值运算符往 variant 里存入内容:

variant<int, string, float> v;
v = 12;
v = 12.5f;
v = "An std::string"s;

variant 在任意时刻只能保存一个值。因此,在这三条赋值语句中,它先保存整数 12,接着被改成保存一个浮点值,最后又被改成保存一个 string 值。

你可以用成员函数 index() 获取当前保存在 variant 中的值所属类型的零基索引,也可以使用函数模板 std::holds_alternative() 判断 variant 当前是否保存着某种特定类型的值:

println("Type index: {}", v.index());
println("Contains an int: {}", holds_alternative<int>(v));

输出如下:

Type index: 1
Contains an int: false

可以使用 std::get<index>()get<T>()variant 中取出值,其中 index 是你要获取的类型对应的零基索引,T 则是你要获取的具体类型。如果你使用的索引或类型与 variant 当前保存的值不匹配,这两个函数都会抛出 bad_variant_access 异常:

println("{}", get<string>(v));
try {
println("{}", get<0>(v));
} catch (const bad_variant_access& ex) {
println("Exception: {}", ex.what());
}

输出如下:

An std::string
Exception: bad variant access

如果你想避免异常,可以使用辅助函数 std::get_if<index>()get_if<T>()。这些函数接收一个指向 variant 的指针,并返回一个指向所请求值的指针;如果失败,则返回 nullptr

string* theString { get_if<string>(&v) };
int* theInt { get_if<int>(&v) };
println("Retrieved string: {}", (theString ? *theString : "n/a"));
println("Retrieved int: {}", (theInt ? to_string(*theInt) : "n/a"));

输出如下:

Retrieved string: An std::string
Retrieved int: n/a

标准库还提供了辅助函数 std::visit(),可用于把 visitor pattern 应用到 variant 上。visitor 必须是一个 callable,例如函数、lambda 表达式或函数对象,并且它要能接受 variant 中可能出现的任意类型。下面这个最简单的例子,直接把一个 generic lambda 作为传给 visit() 的第一个参数;generic lambda 可以接受任意类型:

visit([](auto&& value) { println("Value = {}", value); }, v);

输出如下:

Value = An std::string

如果你希望对 variant 中不同类型的值采取不同处理方式,那么可以自己编写 visitor 类。假设你有下面这样一个 visitor 类:它定义了多个重载的函数调用运算符,每一种可能保存在 variant 中的类型各对应一个。这个实现把所有函数调用运算符都标记成 static(C++23 起可行),因为它们不需要访问 MyVisitor 的任何非静态成员函数或数据成员。

class MyVisitor
{
public:
static void operator()(int i) { println("int: {}", i); }
static void operator()(const string& s) { println("string: {}", s); }
static void operator()(float f) { println("float: {}", f); }
};

配合 std::visit() 的用法如下:

visit(MyVisitor{}, v);

这样一来,程序会根据 variant 当前保存的值,自动调用对应的重载函数调用运算符。本例输出如下:

string: An std::string

variant 不能保存数组;而且和第 1 章介绍过的 optional 一样,它也不能保存引用。你可以保存指针,或者保存 reference_wrapper<T>reference_wrapper<const T> 的实例(参见第 18 章“标准库容器”)。

定义在 <any> 中的 std::any 是一个类,它可以保存任意类型的单个值。你可以使用 any 的构造函数创建实例,也可以使用辅助函数 std::make_any()。对象构造完成后,你可以询问某个 any 实例当前是否保存着值,以及该值的类型是什么。要访问其中保存的值,需要使用 any_cast();如果转换失败,它会抛出 bad_any_cast 类型的异常。下面是一个例子:

any empty;
any anInt { 3 };
any aString { "An std::string."s };
println("empty.has_value = {}", empty.has_value());
println("anInt.has_value = {}\n", anInt.has_value());
println("anInt wrapped type = {}", anInt.type().name());
println("aString wrapped type = {}\n", aString.type().name());
int theInt { any_cast<int>(anInt) };
println("{}", theInt);
try {
int test { any_cast<int>(aString) };
println("{}", test);
} catch (const bad_any_cast& ex) {
println("Exception: {}", ex.what());
}

输出如下。需要注意,aString 所包装类型的显示结果依赖具体编译器:

empty.has_value = false
anInt.has_value = true
anInt wrapped type = int
aString wrapped type = class std::basic_string<char,struct std::char_traits<char>,class std::allocator<char>>
3
Exception: Bad any_cast

你可以为某个 any 实例重新赋值,甚至赋一个不同类型的新值:

any something { 3 }; // 现在它包含一个整数。
something = "An std::string"s; // 现在同一个实例包含一个字符串。

any 的实例可以保存在标准库容器中。这使得你可以在同一个容器里存放异构数据。唯一的代价是:在取出具体值时,你必须显式执行 any_cast,下面的例子展示了这一点:

vector<any> v;
v.push_back(42);
v.push_back("An std::string"s);
println("{}", any_cast<string>(v[1]));

optionalvariant 一样,你也不能在 any 实例中保存引用。这里同样可以保存指针,或保存 reference_wrapper<T>reference_wrapper<const T> 的实例。

定义在 <utility> 中、并已在第 1 章介绍过的 std::pair 类,恰好可以保存两个值,而且每个值的类型都是确定的。这两个值的类型都必须在编译期已知。先快速回顾一下:

pair<int, string> p1 { 16, "Hello World" };
pair p2 { true, 0.123f }; // 使用 CTAD。
println("p1 = ({}, {})", p1.first, p1.second);
println("p2 = ({}, {})", p2.first, p2.second);

输出如下:

p1 = (16, Hello World)
p2 = (true, 0.123)

从 C++23 开始,std::format()print() 系列函数已经完整支持 pair。例如,前面代码片段中的两条 println() 语句也可以写成这样:

println("p1 = {}", p1);
println("p2 = {}", p2);

输出如下,字符串会被双引号括起来:

p1 = (16, "Hello World")
p2 = (true, 0.123)

定义在 <tuple> 中的 std::tuple,可以看作 pair 的泛化。它允许你保存任意多个值,并且每个值都可以有各自不同的类型。与 pair 一样,tuple 的大小和各元素类型都是固定的,并在编译期确定。

你可以通过 tuple 构造函数创建一个 tuple,同时显式写出模板类型和实际值。例如,下面的代码创建了一个 tuple:第一个元素是整数,第二个元素是 string,最后一个元素是布尔值:

using MyTuple = tuple<int, string, bool>;
MyTuple t1 { 16, "Test", true };

pair 一样,从 C++23 开始,std::format()print() 系列函数也完整支持 tuple

println("t1 = {}", t1);
// 输出:t1 = (16, "Test", true)

std::get<i>() 用于从 tuple 中取出第 i 个元素,其中 i 是零基索引;也就是说,<0> 表示第一个元素,<1> 表示第二个元素,以此类推。返回值的类型会自动匹配该索引在 tuple 中对应元素的类型:

println("t1 = ({}, {}, {})", get<0>(t1), get<1>(t1), get<2>(t1));
// Outputs: t1 = (16, Test, true)

你还可以借助来自 <typeinfo>typeid() 验证 get<i>() 返回的确实是正确类型。下面这段代码的输出说明,get<1>(t1) 返回的确实是一个 std::string(和前面一样,typeid().name() 返回的具体字符串依赖编译器):

println("Type of get<1>(t1) = {}", typeid(get<1>(t1)).name());
// Outputs: Type of get<1>(t1) = class std::basic_string<char,
// struct std::char_traits<char>,class std::allocator<char> >

你也可以使用类模板 std::tuple_element,在编译期根据元素索引获得对应元素的类型。和 tuple_size 类似,tuple_element 需要的是 tuple 的类型(本例里是 MyTuple),而不是实际的 tuple 实例,例如 t1。例如:

println("Type of element with index 2 = {}",
typeid(tuple_element<2, MyTuple>::type).name());
// Outputs: Type of element with index 2 = bool

你也可以用 std::get<T>() 按类型而不是按索引从 tuple 里取值,其中 T 就是你要获取的元素类型。如果 tuple 中存在多个同类型元素,编译器会直接报错。例如,可以这样从 t1 中取出那个 string 元素:

println("String = {}", get<string>(t1));
// Outputs: String = Test

遗憾的是,遍历 tuple 中的各个值并不直接。你不能简单写一个循环,再调用类似 get<i>(mytuple) 的东西,因为 i 的值必须在编译期已知。一个可行方案是使用 template metaprogramming;相关内容会在第 26 章“高级模板”中详细讨论,那里也会给出一个打印 tuple 值的示例。

tuple 的大小可以通过类模板 std::tuple_size 查询。与 tuple_element 一样,tuple_size 需要你提供的是 tuple 的类型,而不是某个实际的 tuple 对象:

println("Tuple Size = {}", tuple_size<MyTuple>::value);
// Outputs: Tuple Size = 3

如果你并不知道某个 tuple 的确切类型,也可以借助 decltype() 来查询其类型:

println("Tuple Size = {}", tuple_size<decltype(t1)>::value);
// Outputs: Tuple Size = 3

通过 class template argument deduction(CTAD),在构造 tuple 时你可以省略模板类型参数,让编译器根据传给构造函数的参数类型自动推导。例如,下面这行代码定义出的 t1 tuple 与前面的完全相同:它由整数、string 和布尔值组成。要注意的是,这里你需要把 "Test" 写成 "Test"s,从而确保它是 std::string

tuple t1 { 16, "Test"s, true };

使用 CTAD 时,你没有显式写出保存在 tuple 中的类型,因此也就不能直接用 & 来声明引用类型。如果你想借助 CTAD 生成一个包含“引用到非 const”或“引用到 const”的 tuple,那么就需要分别使用定义在 <functional> 中的 ref()cref()。它们会创建 reference_wrapper<T>reference_wrapper<const T> 的实例。比如,下面这些语句会生成一个类型为 tuple<int, double&, const double&, string&>tuple

double d { 3.14 };
string str1 { "Test" };
tuple t2 { 16, ref(d), cref(d), ref(str1) };

为了验证 t2 中保存的 double 引用,下面的代码先把变量 d 的值输出到控制台。对 get<1>(t2) 的调用会返回对 d 的引用,因为 tuple 的第二个元素(索引 1)使用了 ref(d)。第二条语句修改了这个被引用变量的值;最后一条语句则表明,d 的值确实通过保存在 tuple 中的引用被改掉了。注意,第三行无法通过编译,因为第三个元素使用的是 cref(d),也就是对 d 的“引用到 const”形式:

println("d = {}", d);
get<1>(t2) *= 2;
//get<2>(t2) *= 2; // ERROR because of cref().
println("d = {}", d);
// Outputs: d = 3.14
// d = 6.28

如果不使用 class template argument deduction,你也可以用函数模板 std::make_tuple() 来创建 tuple。因为它本身就是函数模板,所以天然支持函数模板参数推导,也就是说你只需要写实际值即可,类型会在编译期自动推导出来。例如:

auto t2 { make_tuple(16, ref(d), cref(d), ref(str1)) };

tuple 分解成各个独立元素,主要有两种方式:Structured Bindings 和 std::tie()

自 C++17 起可用的 Structured Bindings,让把一个 tuple 拆成多个独立变量变得非常方便。例如,下面的代码先定义了一个由整数、string 和布尔值组成的 tuple,然后使用 Structured Bindings 把它拆成三个独立变量:

tuple t1 { 16, "Test"s, true };
auto [i, str, b] { t1 };
println("Decomposed: i = {}, str = \"{}\", b = {}", i, str, b);

你也可以把 tuple 分解成引用,从而通过这些引用来修改 tuple 的内容。例如:

auto& [i2, str2, b2] { t1 };
i2 *= 2;
str2 = "Hello World";
b2 = !b2;

使用 Structured Bindings 时,不能跳过某些不想分解的元素。如果你的 tuple 有三个元素,那么对应的 Structured Bindings 就必须提供三个变量。

如果你不想使用 Structured Bindings,也可以使用工具函数 std::tie() 来分解 tuple;它会生成一个“由引用组成的 tuple”。下面的例子先创建了一个由整数、string 和布尔值组成的 tuple。然后又创建了三个变量——一个整数、一个 string 和一个布尔值——并先把它们的值输出到控制台。tie(i, str, b) 会创建一个 tuple,其中分别保存了对 istrb 的引用。接着通过赋值运算符,把 tuple t1 赋值给 tie() 的结果。因为 tie() 的结果本质上是“一个由引用组成的 tuple”,所以这次赋值实际上会修改那三个独立变量的值;这点也会体现在赋值前后的输出中:

tuple t1 { 16, "Test"s, true };
int i { 0 };
string str;
bool b { false };
println("Before: i = {}, str = \"{}\", b = {}", i, str, b);
tie(i, str, b) = t1;
println("After: i = {}, str = \"{}\", b = {}", i, str, b);

结果如下:

Before: i = 0, str = "", b = false
After: i = 16, str = "Test", b = true

使用 tie() 时,你可以忽略某些不想分解的元素。做法是:把原本应放变量名的位置,换成特殊值 std::ignore。例如,若想忽略 t1 tuple 中的 string 元素,只需把前一个例子中的 tie() 语句改成:

tie(i, ignore, b) = t1;

可以使用 std::tuple_cat() 把两个 tuple 拼接成一个。在下面的例子里,t3 的类型是 tuple<int, string, bool, double, string>

tuple t1 { 16, "Test"s, true };
tuple t2 { 3.14, "string 2"s };
auto t3 { tuple_cat(t1, t2) };
println("t3 = {}", t3);

输出如下:

t3 = (16, "Test", true, 3.14, "string 2")

tuple 支持全部比较运算符。当然,要想这些比较运算真正可用,保存在 tuple 中的各个元素类型本身也必须支持相应比较。示例如下:

tuple t1 { 123, "def"s };
tuple t2 { 123, "abc"s };
if (t1 < t2) { println("t1 < t2"); }
else { println("t1 >= t2"); }

输出如下:

t1 >= t2

tuple 比较还能被用来非常轻松地为自定义类型实现字典序比较运算符。尤其是当一个自定义类型中有多个数据成员时,这会很方便。假设你有这样一个 class,其中包含三个数据成员:

class Foo
{
public:
explicit Foo(int i, string s, bool b)
: m_int { i }, m_str { move(s) }, m_bool { b } { }
private:
int m_int;
string m_str;
bool m_bool;
};

如果你希望比较 Foo 时要比较其全部数据成员,那么通过显式默认化 operator<=>,实现整套比较运算符几乎是零成本的:

auto operator<=>(const Foo& rhs) const = default;

这样会自动比较所有数据成员。然而,如果类的语义决定了“两个对象之间的比较只应考虑其中一部分数据成员”,那么正确实现整套比较运算符就没那么简单了。不过,借助 std::tie()three-way comparison operatoroperator<=>),这件事又会立刻变得很容易,甚至只要一行代码。下面就是为 Foo 实现 operator<=> 的例子:它只比较 m_intm_str,忽略 m_bool

auto operator<=>(const Foo& rhs) const
{
return tie(m_int, m_str) <=> tie(rhs.m_int, rhs.m_str);
}

使用示例如下:

Foo f1 { 42, "Hello", false };
Foo f2 { 42, "World", false };
println("{}", (f1 < f2)); // Outputs true
println("{}", (f2 > f1)); // Outputs true

std::make_from_tuple<T>() 用于构造一个给定类型 T 的对象,并把给定 tuple 中的各元素作为构造函数参数传给 T。例如,假设你有下面这个类:

class Foo
{
public:
explicit Foo(string str, int i) : m_str { move(str) }, m_int { i } { }
private:
string m_str;
int m_int;
};

那么可以这样使用 make_from_tuple()

tuple myTuple { "Hello world.", 42 };
auto foo { make_from_tuple<Foo>(myTuple) };

从技术上说,传给 make_from_tuple() 的参数并不一定非得是 tuple;只要它支持 std::get<>()tuple_size 就可以。std::arraypair 也同样满足这些要求。

这个函数在日常编码里不算特别常用,但在编写使用模板与 template metaprogramming 的泛型代码时会很方便。

std::apply() 会调用某个给定 callable,并把某个给定 tuple 中的元素依次作为参数传进去。示例如下:

int add(int a, int b) { return a + b; }
println("{}", apply(add, tuple { 39, 3 }));

make_from_tuple() 一样,这个函数在使用模板与 template metaprogramming 编写泛型代码时,通常会比在日常场景中更有价值。

第 1 章已经介绍了 std::optional 的基础。C++23 又为 optional 增加了三个新的成员函数,统称为 monadic operations。借助它们,你可以在 optional 上链式组合多个操作,而不必在每一步之前都先检查当前 optional 是否有值。

可用的 monadic operations 如下:

  • transform(F): 如果 *this 有值,则用该值作为参数调用 F,并返回一个包含调用结果的 optional;否则返回一个空的 optional
  • and_then(F): 如果 *this 有值,则用该值作为参数调用 F,并返回调用结果(该结果本身必须是一个 optional);否则返回一个空的 optional
  • or_else(F): 如果 *this 有值,则返回 *this;否则返回调用 F 所得到的结果(该结果也必须是一个 optional

来看一个例子。下面这个函数会尝试把给定字符串解析成整数,并将结果作为 optional 返回。如果字符串无法解析成整数,则返回一个空的 optional

optional<int> Parse(const string& str)
{
try { return stoi(str); }
catch () { return {}; }
}

下面这个循环会反复要求用户输入内容。程序调用 Parse() 解析用户输入;如果输入能成功解析为整数,就通过 and_then() 把该整数翻倍,再通过 transform() 把它转回字符串;如果解析失败,则通过 or_else() 返回字符串 “No Integer”。得益于 monadic operations,你不需要在每一步显式检查 Parse()and_then() 返回的 optional 是否有值,错误处理会自动帮你接管;这些不同操作可以直接链在一起。

while (true) {
print("Enter an integer (q to stop): ");
string str;
if (!getline(cin, str) || str == "q") { break; }
auto result { Parse(str)
.and_then([](int value) -> optional<int> { return value * 2; })
.transform([](int value) { return to_string(value); })
.or_else([] { return optional<string> { "No Integer" }; }) };
println(" > Result: {}", *result);
}

下面是一组示例输出:

Enter an integer (q to stop): 21
> Result: 42
Enter an integer (q to stop): Test
> Result: No Integer

正如第 14 章“错误处理”所解释的那样,C++ 中的函数只能返回单一类型。如果一个函数可能失败,那么它应当把失败这件事告知调用方。过去,常见做法大致有几种:你可以抛出一个携带错误细节的异常;也可以想办法约定一个“特殊返回值”,用来表示出错。

例如,如果函数返回的是指针,那么出错时可以返回 nullptr;如果函数在正常情况下只返回正整数,那么你可以用负值来表示不同错误,等等。但问题在于,这种“特殊值”并不总是找得到。如果某个函数的返回类型是 int,而合法返回值又覆盖整个整数范围,那么你就根本没有额外整数可拿来代表错误。在这种场景下,可以使用 std::optional 这种 vocabulary type。它要么保存某种类型的值,要么为空。于是函数就可以通过返回空的 optional 来表示错误。

这么做当然也有问题:当调用方拿到一个空的 optional 时,它根本无法知道到底哪里出错了;换句话说,函数并不能把真正的错误原因返回出去。std::expected 正是为了解决这个问题而出现的。它定义在 <expected> 中,并于 C++23 引入。它是一个类模板,接受两个模板类型参数:

  • T: 期望的正常值类型
  • E: 错误值类型,也叫 unexpected value 的类型

expected 永远不会为空;它总是保存着两者之一:类型为 T 的值,或者类型为 E 的值。这一点与 optional 最大不同:optional 可以为空,而一旦为空,你就无从得知它为什么为空。因此,返回 expected 的函数要么返回一个期望类型的值,要么返回一个错误类型的值,从而准确表达失败原因。错误类型完全由你决定:它可以只是一个简单整数,也可以是一个复杂类。很多时候,最好把错误编码到一个能够尽可能承载丰富细节的类中,例如记录某个数据文件解析失败时的文件名、行号和列号等信息。

expected<T,E> 的实例可以像 optional<T> 一样,由一个 T 类型的值隐式构造出来。而如果你想创建一个保存错误类型 Eexpected<T,E>,则必须使用 std::unexpected<E>。默认构造的 expected<T,E> 会包含一个默认构造的期望值类型 T;这与 optional 不同。默认构造的 optional 是空的!换句话说,默认构造的 expected 表示成功,而默认构造的 optional 则表示错误。

来看一个例子。下面这个函数接收一个 string,并尝试把它解析成整数。stoi() 在字符串不是合法整数时会抛出 invalid_argument,而在解析出的整数超出 int 可表示范围时会抛出 out_of_range。假设你不希望 parseInteger() 抛出这些异常,而是改为返回一个 expected。这个函数会捕获这两种异常,并把它们转换成 string;这里的 string 就是返回的 expected 的错误类型。

expected<int, string> parseInteger(const string& str)
{
try { return stoi(str); }
catch (const invalid_argument& e) { return unexpected { e.what() }; }
catch (const out_of_range& e) { return unexpected { e.what() }; }
}

expected 提供了下列成员函数。除 error() 之外,其余函数都与 optional 中同名成员函数的含义相近。

  • has_value()operator bool: 如果 expected 中保存的是类型 T 的值,则返回 true;否则返回 false
  • value(): 返回类型 T 的值。如果在一个保存着类型 E 值的 expected 上调用它,会抛出 std::bad_expected_access
  • operator*->: 访问类型 T 的值。如果该 expected 并不保存 T 类型值,其行为是未定义的。
  • error(): 返回类型 E 的错误值。如果该 expected 不包含类型 E 的值,其行为是未定义的。
  • value_or(): 返回类型 T 的值;若不存在,则返回你给定的另一个备选值。

下面的示例演示了其中大部分成员函数:

auto result1 { parseInteger("123456789") };
if (result1.has_value()) { println("result1 = {}", result1.value()); }
if (result1) { println("result1 = {}", *result1); }
println("result1 = {}", result1.value_or(0));
auto result2 { parseInteger("123456789123456") };
if (!result2) { println("result2 contains an error: {}", result2.error()); }
auto result3 { parseInteger("abc") };
if (!result3) { println("result3 contains an error: {}", result3.error()); }

输出如下:

result1 = 123456789
result1 = 123456789
result1 = 123456789
result2 contains an error: stoi argument out of range
result3 contains an error: invalid stoi argument

此外,expected 也支持 monadic operations:and_then()transform()or_else()transform_error()。前三个与 optional 支持的 monadic operations 含义相近。

  • transform(F): 如果 *this 保存着期望值,就用该值作为参数调用 F,并返回一个包含调用结果的 expected;否则直接原样返回当前 expected
  • and_then(F): 如果 *this 保存着期望值,就用该值作为参数调用 F,并返回调用结果(该结果本身必须是一个 expected);否则直接原样返回当前 expected
  • or_else(F): 如果 *this 保存着期望值,则返回 *this;否则,用 unexpected value 作为参数调用 F,并返回其结果(该结果本身必须是一个 expected
  • transform_error(F): 如果 *this 保存着期望值,则返回 *this;否则,用 unexpected value 作为参数调用 F,并返回一个包含转换后错误值的 expected

下面是一个在 expected 上使用 and_then() 的例子。和在 optional 上使用 monadic operations 一样,你不需要先显式检查 parseInteger() 的结果是否包含期望值,再决定要不要继续应用后续操作;错误处理会自动帮你接住。

auto transformedResult { parseInteger("123456789")
.and_then([](int value) -> expected<int, string> { return value * 2; }) };

expected 的错误类型可以是你想要的任意类型。如果需要返回多种错误类型,也可以借助本章前面讨论过的 variant 这种 vocabulary type。例如,与其返回一个简单的 stringparseInteger() 也可以针对两种不同错误情况返回两种不同错误类型。下面这个版本会返回两种自定义错误类型:OutOfRangeInvalidArgument

expected<int, variant<OutOfRange, InvalidArgument>>
parseInteger(const string& str) {}

说到底,很明显 optionalexpected 是有一定关联的。你可以用下面这条经验规则来决定特定场景下该用哪一个。

函数中处理错误,大体上有三种主流方案:抛出异常(第 14 章已详细讨论)、返回错误码,或者返回 expected。三者各有优点。下面这张表基于 std::expected 的官方提案 P0323R12,总结了它们之间的差异:

EXCEPTIONERROR RETURN CODEEXPECTED
VISIBILITY除非阅读函数文档或分析代码,否则看不出来。从函数原型上一眼可见,但返回值很容易被忽略。从函数原型上一眼可见;而且它包含函数结果,因此不容易被忽略。
DETAILS可携带尽可能丰富的错误细节。往往只是一个简单整数。可携带尽可能丰富的错误细节。
CODE NOISE允许用比较干净的方式把正常逻辑与错误处理分开。错误处理与正常流程交织在一起,代码更难读也更难维护。同样允许代码保持整洁;借助 monadic operations,错误处理不必与正常流程纠缠在一起。

本章概览了 C++ 标准库提供的更多 vocabulary types。你学习了如何使用 variantany 这两种 vocabulary data type,也了解了 tuple 及其作为 pair 泛化后的各种操作。你还看到了 optional 上 monadic operations 的威力:它们可以让你非常自然地在 optional 上做链式操作。本章最后介绍了 expected,一种既能保存特定类型的值,也能保存错误的 vocabulary type。

本章至此结束了本书的第 3 部分。下一部分会进入一些更高级的话题,并从一个新章节开始:它将介绍如何通过实现符合标准库约定的算法和数据结构,来定制并扩展 C++ 标准库提供的功能。

通过完成下面这些练习,你可以巩固本章讨论的内容。所有练习的参考解答都包含在本书网站 www.wiley.com/go/proc++6e 提供的代码下载包中。不过,如果你在某道题上卡住了,建议先回过头重读本章相关部分,尽量自己找到答案,再去看网站上的解答。

  1. 练习 24-1: 第 14 章“错误处理”介绍了 C++ 中的错误处理,并说明基本上有两种主要方案:要么使用错误码,要么使用异常。我更推荐使用异常来做错误处理,但这道题请你使用错误码。请编写一个简单的 Error 类,它只保存一条消息,具有用于设置消息的构造函数,以及用于取回消息的 getter。然后再编写一个 getData() 函数,它接收一个名为 fail 的布尔参数。如果 failfalse,该函数返回一个装有某些数据的 vector;否则返回一个 Error 实例。你不能使用“引用到非 const”的输出参数。请尽量想出一个尚未使用 C++23 std::expected 类模板的解决方案,并在 main() 中测试你的实现。
  2. 练习 24-2: 修改你在练习 24-1 中的解法,改用 C++23 的 std::expected 类模板,并体会它如何让方案更容易阅读和理解。
  3. 练习 24-3: 大多数命令行应用程序都会接收命令行参数。本书中的大多数示例代码都把 main 简写为 main();不过,main() 其实也可以接收参数:main(int argc, char** argv),其中 argc 表示命令行参数个数,argv 则是一个字符串数组,每个参数对应其中一个字符串。对这道题,假设命令行参数都满足 name=value 这种形式。请编写一个函数,用于解析单个参数,并返回一个 pair:第一个元素是参数名,第二个元素是一个 variant,其中保存参数值;如果该值能被解析为布尔值(truefalse),则保存为布尔值;如果能解析为整数,则保存为整数;否则保存为 string。你可以使用 regular expression(见第 21 章“字符串本地化与正则表达式”)来拆分 name=value 字符串;解析整数时,可以使用第 2 章“字符串与字符串视图”中介绍过的某个函数。在 main() 中,请遍历全部命令行参数,逐个解析,并用 holds_alternative() 把解析结果输出到标准输出。
  4. 练习 24-4: 修改你在练习 24-3 中的解法。不要使用 holds_alternative(),改用 visitor 把解析结果输出到标准输出。
  5. 练习 24-5: 再修改你在练习 24-4 中的解法,把 pair 改成 tuple