其他词汇类型
词汇类型(vocabulary types)是一类你很可能会高频使用的类型,使用频率几乎不亚于 int、double 这类基础类型。它们经常被拿来构建更复杂的类型。合理使用这类类型,可以让代码更安全、更高效,也更容易编写、阅读和维护。本书前面已经讨论过的一些词汇类型包括 vector、optional、string、unique_ptr、shared_ptr 等。
本章先从另外两种词汇类型开始:variant 和 any。接着会更深入地讨论 tuple,它可以看作 pair 的泛化,以及它所支持的各种操作。随后会介绍 optional 对单子操作(monadic operations)的支持,这让你在 optional 上链式组合操作时轻松很多,因为你不需要在每一步之前都先判断该 optional 是否为空。本章最后会介绍 expected。这是一种既可以保存某个期望类型的值,也可以保存错误值的数据类型;其中用于表示错误的类型,可以与表示正常值的类型完全不同。
variant
Section titled “variant”定义在 <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;实际上,这里的 Foo 和 Bar 都不支持默认构造。如果你仍然希望这样的 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: 1Contains 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::stringException: 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::stringRetrieved 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::stringvariant 不能保存数组;而且和第 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 = falseanInt.has_value = true
anInt wrapped type = intaString wrapped type = class std::basic_string<char,struct std::char_traits<char>,class std::allocator<char>>
3Exception: 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]));和 optional、variant 一样,你也不能在 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
Section titled “拆解 tuple”把 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,其中分别保存了对 i、str 和 b 的引用。接着通过赋值运算符,把 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 = falseAfter: 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 >= t2tuple 比较还能被用来非常轻松地为自定义类型实现字典序比较运算符。尤其是当一个自定义类型中有多个数据成员时,这会很方便。假设你有这样一个 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 operator(operator<=>),这件事又会立刻变得很容易,甚至只要一行代码。下面就是为 Foo 实现 operator<=> 的例子:它只比较 m_int 和 m_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 trueprintln("{}", (f2 > f1)); // Outputs truemake_from_tuple
Section titled “make_from_tuple”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::array 和 pair 也同样满足这些要求。
这个函数在日常编码里不算特别常用,但在编写使用模板与 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 编写泛型代码时,通常会比在日常场景中更有价值。
optional:单子操作
Section titled “ optional:单子操作”第 1 章已经介绍了 std::optional 的基础。C++23 又为 optional 增加了三个新的成员函数,统称为 monadic operations。借助它们,你可以在 optional 上链式组合多个操作,而不必在每一步之前都先检查当前 optional 是否有值。
可用的 monadic operations 如下:
transform(F): 如果*this有值,则用该值作为参数调用F,并返回一个包含调用结果的optional;否则返回一个空的optionaland_then(F): 如果*this有值,则用该值作为参数调用F,并返回调用结果(该结果本身必须是一个optional);否则返回一个空的optionalor_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: 42Enter an integer (q to stop): Test > Result: No Integer expected
Section titled “ expected”正如第 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 类型的值隐式构造出来。而如果你想创建一个保存错误类型 E 的 expected<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 = 123456789result1 = 123456789result1 = 123456789result2 contains an error: stoi argument out of rangeresult3 contains an error: invalid stoi argument此外,expected 也支持 monadic operations:and_then()、transform()、or_else() 和 transform_error()。前三个与 optional 支持的 monadic operations 含义相近。
transform(F): 如果*this保存着期望值,就用该值作为参数调用F,并返回一个包含调用结果的expected;否则直接原样返回当前expectedand_then(F): 如果*this保存着期望值,就用该值作为参数调用F,并返回调用结果(该结果本身必须是一个expected);否则直接原样返回当前expectedor_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。例如,与其返回一个简单的 string,parseInteger() 也可以针对两种不同错误情况返回两种不同错误类型。下面这个版本会返回两种自定义错误类型:OutOfRange 和 InvalidArgument:
expected<int, variant<OutOfRange, InvalidArgument>> parseInteger(const string& str) { … }说到底,很明显 optional 与 expected 是有一定关联的。你可以用下面这条经验规则来决定特定场景下该用哪一个。
异常、错误返回码与 expected
Section titled “异常、错误返回码与 expected”函数中处理错误,大体上有三种主流方案:抛出异常(第 14 章已详细讨论)、返回错误码,或者返回 expected。三者各有优点。下面这张表基于 std::expected 的官方提案 P0323R12,总结了它们之间的差异:
| EXCEPTION | ERROR RETURN CODE | EXPECTED | |
|---|---|---|---|
| VISIBILITY | 除非阅读函数文档或分析代码,否则看不出来。 | 从函数原型上一眼可见,但返回值很容易被忽略。 | 从函数原型上一眼可见;而且它包含函数结果,因此不容易被忽略。 |
| DETAILS | 可携带尽可能丰富的错误细节。 | 往往只是一个简单整数。 | 可携带尽可能丰富的错误细节。 |
| CODE NOISE | 允许用比较干净的方式把正常逻辑与错误处理分开。 | 错误处理与正常流程交织在一起,代码更难读也更难维护。 | 同样允许代码保持整洁;借助 monadic operations,错误处理不必与正常流程纠缠在一起。 |
本章概览了 C++ 标准库提供的更多 vocabulary types。你学习了如何使用 variant 和 any 这两种 vocabulary data type,也了解了 tuple 及其作为 pair 泛化后的各种操作。你还看到了 optional 上 monadic operations 的威力:它们可以让你非常自然地在 optional 上做链式操作。本章最后介绍了 expected,一种既能保存特定类型的值,也能保存错误的 vocabulary type。
本章至此结束了本书的第 3 部分。下一部分会进入一些更高级的话题,并从一个新章节开始:它将介绍如何通过实现符合标准库约定的算法和数据结构,来定制并扩展 C++ 标准库提供的功能。
通过完成下面这些练习,你可以巩固本章讨论的内容。所有练习的参考解答都包含在本书网站 www.wiley.com/go/proc++6e 提供的代码下载包中。不过,如果你在某道题上卡住了,建议先回过头重读本章相关部分,尽量自己找到答案,再去看网站上的解答。
- 练习 24-1: 第 14 章“错误处理”介绍了 C++ 中的错误处理,并说明基本上有两种主要方案:要么使用错误码,要么使用异常。我更推荐使用异常来做错误处理,但这道题请你使用错误码。请编写一个简单的
Error类,它只保存一条消息,具有用于设置消息的构造函数,以及用于取回消息的 getter。然后再编写一个getData()函数,它接收一个名为fail的布尔参数。如果fail为false,该函数返回一个装有某些数据的vector;否则返回一个Error实例。你不能使用“引用到非const”的输出参数。请尽量想出一个尚未使用 C++23std::expected类模板的解决方案,并在main()中测试你的实现。 - 练习 24-2: 修改你在练习 24-1 中的解法,改用 C++23 的
std::expected类模板,并体会它如何让方案更容易阅读和理解。 - 练习 24-3: 大多数命令行应用程序都会接收命令行参数。本书中的大多数示例代码都把
main简写为main();不过,main()其实也可以接收参数:main(int argc, char** argv),其中argc表示命令行参数个数,argv则是一个字符串数组,每个参数对应其中一个字符串。对这道题,假设命令行参数都满足name=value这种形式。请编写一个函数,用于解析单个参数,并返回一个pair:第一个元素是参数名,第二个元素是一个variant,其中保存参数值;如果该值能被解析为布尔值(true或false),则保存为布尔值;如果能解析为整数,则保存为整数;否则保存为string。你可以使用 regular expression(见第 21 章“字符串本地化与正则表达式”)来拆分name=value字符串;解析整数时,可以使用第 2 章“字符串与字符串视图”中介绍过的某个函数。在main()中,请遍历全部命令行参数,逐个解析,并用holds_alternative()把解析结果输出到标准输出。 - 练习 24-4: 修改你在练习 24-3 中的解法。不要使用
holds_alternative(),改用 visitor 把解析结果输出到标准输出。 - 练习 24-5: 再修改你在练习 24-4 中的解法,把
pair改成tuple。