现代C++:处理数据类型变化和错误:optional、variant、expected和Herbception

📅 2026/7/27 10:03:25
现代C++:处理数据类型变化和错误:optional、variant、expected和Herbception
引用我们之前已经讨论了异常是推荐的 C 错误处理方式。不过C 里有另外一些结构也很适合进行错误处理今天我们就来讨论一下。optional在面向对象引用语义的语言里我们有时候会使用空值 null 表示没有找到需要的对象。也有人推荐使用一个特殊的空对象来避免空值带来的一些问题。可不管是空值还是空对象对于一个返回普通对象值语义的 C 函数都是不适用的——空值和空对象只能用在返回引用 / 指针的场合一般情况下需要堆内存分配在 C 里会引致额外的开销。C17 引入的 optional 模板可以部分解决这个问题。语义上来说optional 代表一个“也许有效”“可选”的对象。语法上来说一个 optional 对象有点像一个指针但它所管理的对象是直接放在 optional 里的没有额外的内存分配。构造一个 optional 对象有以下几种方法不传递任何参数或者使用特殊参数 std::nullopt可以和 nullptr 类比可以构造一个“空”的 optional 对象里面不包含有效值。第一个参数是 std::in_place后面跟构造 T 所需的参数可以在 optional 对象上直接构造出 T 的有效值。如果 T 类型支持拷贝构造或者移动构造的话那在构造 optional 时也可以传递一个 T 的左值或右值来将 T 对象拷贝或移动到 optional 中。对于上面的第 1 种情况optional 对象里是没有值的在布尔值上下文里会得到 false类似于空指针的行为。对于上面的第 2、3 两种情况optional 对象里是有值的在布尔值上下文里会得到 true类似于有效指针的行为。类似的在 optional 对象有值的情况下你可以用 * 和 - 运算符去解引用没值的情况下结果是未定义行为。虽然 optional 是 C17 才标准化的但实际上这个用法更早就通行了。因为 optional 的实现不算复杂有些库里就自己实现了一个版本。比如 cpptoml就给出了下面这样的示例进行了翻译和重排版用法跟标准的 optional 完全吻合auto val config- get_asint64_t(my-int); // val 是 cpptoml::optionint64_t if (val) { // *val 是 my-int 键下的整数值 } else { // my-int 不存在或不是整数 }cpptoml 里只是个缩微版的 optional实现只有几十行也不支持我们上面说的所有构造方式。标准库的 optional 为了方便程序员使用除了我目前描述的功能还支持下面的操作安全的析构行为显式的 has_value 成员函数判断 optional 是否有值value 成员函数行为类似于 *但在 optional 对象无值时会抛出异常 std::bad_optional_accessvalue_or 成员函数在 optional 对象无值时返回传入的参数swap 成员函数和另外一个 optional 对象进行交换reset 成员函数清除 optional 对象包含的值emplace 成员函数在 optional 对象上构造一个新的值不管成功与否原值会被丢弃make_optional 全局函数产生一个 optional 对象类似 make_pair、make_unique 等全局比较操作等等如果我们认为无值就是数据无效应当跳过剩下的处理我们可以写出下面这样的高阶函数template typename T constexpr bool has_value( const optionalT x) noexcept { return x.has_value(); } template typename T, typename... Args constexpr bool has_value( const optionalT first, const optional Args... other) noexcept { return first.has_value() has_value(other...); } template typename F auto lift_optional(F f) { return [f forwardF(f)]( auto... args) { typedef decay_tdecltype(f( forwarddecltype(args)(args) .value()...)) result_type; if (has_value(args...)) { return optionalresult_type( f(forwarddecltype(args)( args) .value()...)); } else { return optional result_type(); } }; }has_value 比较简单它可以有一个或多个 optional 参数并在所有参数都有值时返回真否则返回假。lift_optional 稍复杂些它接受一个函数返回另外一个函数。在返回的函数里参数是一个或多个 optional 类型result_type 是用参数的值value()去调用原先函数时的返回值类型最后返回的则是 result_type 的 optional 封装。函数内部会检查所有的参数是否都有值通过调用 has_value有值时会去拿参数的值去调用原先的函数否则返回一个空的 optional 对象。这个函数能把一个原本要求参数全部有效的函数抬升lift成一个接受和返回 optional 参数的函数并且只在参数全部有效时去调用原来的函数。这是一种非常函数式的编程方式。使用上面函数的示例代码如下#include iostream #include functional #include optional #include type_traits #include utility using namespace std; // 需包含 lift_optional 的定义 constexpr int increase(int n) { return n 1; } // 标准库没有提供 optional 的输出 ostream operator(ostream os, optionalint(x)) { if (x) { os ( *x ); } else { os (Nothing); } return os; } int main() { auto inc_opt lift_optional(increase); auto plus_opt lift_optional(plusint()); cout inc_opt(optionalint()) endl; cout inc_opt(make_optional(41)) endl; cout plus_opt( make_optional(41), optionalint()) endl; cout plus_opt( make_optional(41), make_optional(1)) endl; }输出结果是(Nothing)(42)(Nothing)(42)variantoptional 是一个非常简单而又好用的模板很多情况下使用它就足够解决问题了。在某种意义上可以把它看作是允许有两种数值的对象要么是你想放进去的对象要么是 nullopt再次提醒联想 nullptr。如果我们希望除了我们想放进去的对象还可以是 nullopt 之外的对象怎么办呢比如某种出错的状态又比如如果我希望有三种或更多不同的类型呢这种情况下variant可能就是一个合适的解决方案。在没有 variant 类型之前你要达到类似的目的恐怕会使用一种叫做带标签的联合tagged union的数据结构。比如下面就是一个可能的数据结构定义struct FloatIntChar { enum { Float, Int, Char } type; union { float float_value; int int_value; char char_value; }; };这个数据结构的最大问题就是它实际上有很多复杂情况需要特殊处理。对于我们上面例子里的 POD 类型这么写就可以了但我们仍需小心保证我们设置的 type 和实际使用的类型一致。如果我们把其中一个类型换成非 POD 类型就会有复杂问题出现。比如下面的代码是不能工作的struct StringIntChar { enum { String, Int, Char } type; union { string string_value; int int_value; char char_value; }; };编译器会很合理地看到在 union 里使用 string 类型会带来构造和析构上的问题所以会拒绝工作。要让这个代码工作我们得手工加上析构函数并且在析构函数里得小心地判断存储的是什么数值来决定是否应该析构否则默认不调用任何 union 里的析构函数从而可能导致资源泄漏~StringIntChar() { if (type String) { string_value.~string(); } }这样我们才能安全地使用它还是很麻烦StringIntChar obj{ .type StringIntChar::String, .string_value Hello world}; cout obj.string_value endl;这里用到了按成员初始化的语法把类型设置成了字符串同时设置了字符串的值。不用说这是件麻烦、容易出错的事情。同时细查之后我发现这个语法虽然在 C99 里有但在 C 里要在 C20 才会被标准化因此实际是有兼容性问题的——老版本的 MSVC或最新版本的 MSVC 在没有开启 C20 支持时就不支持这个语法。所以目前的主流建议是应该避免使用“裸” union 了。替换方式就是这一节要说的 variant。上面的例子如果用 variant 的话会非常的干净利落variantstring, int, char obj{ Hello world}; cout getstring(obj) endl;可以注意到我上面构造时使用的是 const char*但构造函数仍然能够正确地选择 string 类型这是因为标准要求实现在没有一个完全匹配的类型的情况下会选择成员类型中能够以传入的类型来构造的那个类型进行初始化有且只有一个时。string 类存在形式为 string(const char*) 的构造函数不精确地说所以上面的构造能够正确进行。跟 tuple 相似variant 上可以使用 get 函数模板其模板参数可以是代表序号的数字也可以是类型。如果编译时可以确定序号或类型不合法我们在编译时就会出错。如果序号或类型合法但运行时发现 variant 里存储的并不是该类对象我们则会得到一个异常 bad_variant_access。variant 上还有一个重要的成员函数是 index通过它我们能获得当前的数值的序号。就我们上面的例子而言obj.index() 即为 0。正常情况下variant 里总有一个有效的数值缺省为第一个类型的默认构造结果但如果 emplace 等修改操作中发生了异常variant 里也可能没有任何有效数值此时 index() 将会得到 variant_npos。从基本概念来讲variant 就是一个安全的 union相当简单我就不多做其他介绍了。你可以自己看文档来了解进一步的信息。其中比较有趣的一个非成员函数是 visit文档里展示了一个非常简洁的、可根据当前包含的变量类型进行函数分发的方法。平台细节在老于 Mojave 的 macOS 上编译含有 optional 或 variant 的代码需要在文件开头加上#if defined(__clang__) defined(__APPLE__) #include __config #undef _LIBCPP_AVAILABILITY_BAD_OPTIONAL_ACCESS #undef _LIBCPP_AVAILABILITY_BAD_VARIANT_ACCESS #define _LIBCPP_AVAILABILITY_BAD_OPTIONAL_ACCESS #define _LIBCPP_AVAILABILITY_BAD_VARIANT_ACCESS #endif原因是苹果在头文件里把 optional 和 variant 在早期版本的 macOS 上禁掉了而上面的代码去掉了这几个宏里对使用 bad_optional_access 和 bad_variant_access 的平台限制。我真看不出使用这两个头文件跟 macOS 的版本有啥关系。expected和前面介绍的两个模板不同expected 不是 C 标准里的类型。但概念上这三者有相关性因此我们也放在一起讲一下。我前面已经提到optional 可以作为一种代替异常的方式在原本该抛异常的地方我们可以改而返回一个空的 optional 对象。当然此时我们就只知道没有返回一个合法的对象而不知道为什么没有返回合法对象了。我们可以考虑改用一个 variant但我们此时需要给错误类型一个独特的类型才行因为这是 variant 模板的要求。比如enum class error_code { success, operation_failure, object_not_found, … }; variantObj, error_code get_object(…);这当然是一种可行的错误处理方式我们可以判断返回值的 index()来决定是否发生了错误。但这种方式不那么直截了当也要求实现对允许的错误类型作出规定。Andrei Alexandrescu 在 2012 年首先提出的 Expected 模板提供了另外一种错误处理方式。他的方法的要点在于把完整的异常信息放在返回值并在必要的时候可以“重放”出来或者手工检查是不是某种类型的异常。他的概念并没有被广泛推广最主要的原因可能是性能。异常最被人诟病的地方是性能而他的方式对性能完全没有帮助。不过后面的类似模板都汲取了他的部分思想至少会用一种显式的方式来明确说明当前是异常情况还是正常情况。在目前的 expected 的标准提案里用法有点是 optional 和 variant 的某种混合模板的声明形式像 variant使用正常返回值像 optional。下面的代码展示了一个 expected 实现的基本用法。#include climits #include iostream #include string #include tl/expected.hpp using namespace std; using tl::expected; using tl::unexpected; // 返回 expected 的安全除法 expectedint, string safe_divide(int i, int j) { if (j 0) return unexpected( divide by zeros); if (i INT_MIN j -1) return unexpected( integer divide overflowss); if (i % j ! 0) return unexpected( not integer divisions); else return i / j; } // 一个测试函数 expectedint, string caller(int i, int j, int k) { auto q safe_divide(j, k); if (q) return i *q; else return q; } // 支持 expected 的输出函数 template typename T, typename E ostream operator( ostream os, const expectedT, E exp) { if (exp) { os exp.value(); } else { os unexpected: exp.error(); } return os; } // 调试使用的检查宏 #define CHECK(expr) \ { \ auto result (expr); \ cout result; \ if (result \ unexpected( \ divide by zeros)) { \ cout \ : Are you serious?; \ } else if (result 42) { \ cout : Ha, I got you!; \ } \ cout endl; \ } int main() { CHECK(caller(2, 1, 0)); CHECK(caller(37, 20, 7)); CHECK(caller(39, 21, 7)); }输出是unexpected: divide by zero: Are you serious?unexpected: not integer division42: Ha, I got you!一个 expected 差不多可以看作是 T 和 unexpected 的 variant。在学过上面的 variant 之后我们应该很容易看明白上面的程序了。下面是几个需要注意一下的地方如果一个函数要正常返回数据代码无需任何特殊写法如果它要表示出现了异常则可以返回一个 unexpected 对象。这个返回值可以用来和一个正常值或 unexpected 对象比较可以在布尔值上下文里检查是否有正常值也可以用 * 运算符来取得其中的正常值——与 optional 类似在没有正常值的情况下使用 * 是未定义行为。可以用 value 成员函数来取得其中的正常值或使用 error 成员函数来取得其中的错误值——与 variant 类似在 expected 中没有对应的值时产生异常 bad_expected_access。返回错误跟抛出异常比较相似但检查是否发生错误的代码还是要比异常处理啰嗦。Herbception上面的用法初看还行但真正用起来你会发现仍然没有使用异常方便。这只是为了解决异常在错误处理性能问题上的无奈之举。大部分试图替换 C 异常的方法都是牺牲编程方便性来换取性能。只有 Herb Sutter 提出了一个基本兼容当前 C 异常处理方式的错误处理方式被戏称为 Herbception。上面使用 expected 的示例代码如果改用 Herbception 的话可以大致如下改造示意尚无法编译int safe_divide(int i, int j) throws { if (j 0) throw arithmetic_errc:: divide_by_zero; if (i INT_MIN j -1) throw arithmetic_errc:: integer_divide_overflows; if (i % j ! 0) throw arithmetic_errc:: not_integer_division; else return i / j; } int caller(int i, int j, int k) throws { return i safe_divide(j, k); } #define CHECK(expr) \ try { \ int result (expr); \ cout result; \ if (result 42) { \ cout : Ha, I got you!; \ } \ } \ catch (error e) { \ if (e arithmetic_errc:: \ divide_by_zero) { \ cout \ Are you serious? ; \ } \ cout An error occurred; \ } \ cout endl int main() { CHECK(caller(2, 1, 0)); CHECK(caller(37, 20, 7)); CHECK(caller(39, 21, 7)); }我们可以看到上面的代码和普通使用异常的代码非常相似区别有以下几点函数需要使用 throws注意不是 throw进行声明。抛出异常的语法和一般异常语法相同但抛出的是一个 std::error 值。捕捉异常时不需要使用引用因为 std::error 是个“小”对象且使用一般的比较操作来检查异常“类型”不再使用开销大的 RTTI。虽然语法上基本是使用异常的样子但 Herb 的方案却没有异常的不确定开销性能和使用 expected 相仿。他牺牲了异常类型的丰富但从实际编程经验来看越是体现出异常优越性的地方——异常处理点和异常发生点距离较远的时候——越不需要异常有丰富的类型。因此总体上看这是一个非常吸引人的方案。不过由于提案时间较晚争议颇多这个方案要进入标准至少要 C23 了。我们目前稍稍了解一下就行。更多技术细节请查看参考资料。内容小结本讲我们讨论了两个 C 标准库的模板 optional 和 variant然后讨论了两个标准提案 expected 和 Herbception。这些结构都可以使用在错误处理过程中——前三者当前可用但和异常相比有不同的取舍Herbception 当前还不可用但有希望在错误处理上达到最佳的权衡点。