“未来的C ++”中的确定性异常和错误处理


奇怪的是,在Habrt上却没有提及C ++标准的一个叫“零开销确定性异常”的提议。 纠正这一烦人的遗漏。


如果您担心异常的开销,或者您不得不在没有异常支持的情况下编译代码,或者只是想知道C ++ 2b中的错误处理会发生什么(参考最近的一篇文章 ),我请猫。 您正在等待从该主题上现在可以找到的所有内容以及几次民意测验中被挤出来。


下面的讨论不仅会涉及静态异常,还将讨论与标准有关的建议,以及有关处理错误的各种其他方式。 如果您去这里查看语法,那么这里是:


double safe_divide(int x, int y) throws(arithmetic_error) { if (y == 0) { throw arithmetic_error::divide_by_zero; } else { return as_double(x) / y; } } void caller() noexcept { try { cout << safe_divide(5, 2); } catch (arithmetic_error e) { cout << e; } } 

如果特定类型的错误不重要/未知,则可以简单地使用throws and catch (std::error e)


很高兴知道


std::optionalstd::expected


让我们确定该函数中可能发生的错误不够严重,无法对其引发异常。 传统上,使用out参数返回错误信息。 例如, 文件系统TS提供了许多类似的功能:


 uintmax_t file_size(const path& p, error_code& ec); 

(由于找不到该文件而不会引发异常吗?)但是,错误代码的处理很麻烦并且容易出错。 错误代码很容易忘记检查。 现代代码样式禁止使用输出参数;相反,建议返回包含整个结果的结构。


一段时间以来,Boost一直提供一种优雅的解决方案来处理此类“非致命”错误,在某些情况下,正确的程序中可能会发生数百种错误:


 expected<uintmax_t, error_code> file_size(const path& p); 

expected类型与variant类似,但是它提供了一个方便的界面来处理“结果”和“错误”。 默认情况下, expected结果存储在expectedfile_size实现可能看起来像这样:


 file_info* info = read_file_info(p); if (info != null) { uintmax_t size = info->size; return size; // <== } else { error_code error = get_error(); return std::unexpected(error); // <== } 

如果错误的原因对我们而言不重要,或者错误可能仅包含结果的“缺失”,则可以使用optional


 optional<int> parse_int(const std::string& s); optional<U> get_or_null(map<T, U> m, const T& key); 

在Boost的C ++ 17中,std是optional<T&> (不支持optional<T&> ); 在C ++ 20中,它们可能会添加预期的内容 (这只是建议,感谢RamzesXI的更正)。


合约


契约 (不要与概念混淆)是在C ++ 20中添加的对函数参数施加限制的新方法。 添加了3个注释:


  • 期望检查功能参数
  • 确保检查函数的返回值(将其作为参数)
  • 断言 -宏断言的文明替代

 double unsafe_at(vector<T> v, size_t i) [[expects: i < v.size()]]; double sqrt(double x) [[expects: x >= 0]] [[ensures ret: ret >= 0]]; value fetch_single(key e) { vector<value> result = fetch(vector<key>{e}); [[assert result.size() == 1]]; return v[0]; } 

您可以配置违约:


  • 称为未定义行为,或
  • 它检查并调用了用户出口,此后std::terminate

违反合同后就无法继续运行程序,因为编译器会使用合同保证来优化功能代码。 如果丝毫怀疑是否会履行合同,则值得增加一张额外支票。


std :: error_code


C ++ 11中添加的<system_error>库使您可以标准化程序中错误代码的处理。 std :: error_code由类型为int的错误代码和指向某些后代类std :: error_category的对象的指针组成 。 实际上,此对象充当虚拟函数表的角色,并确定给定std::error_code的行为。


要创建std::error_code ,必须定义std::error_category子代std::error_category并实现虚拟方法,其中最重要的是:


 virtual std::string message(int c) const = 0; 

您还必须为std::error_category创建一个全局变量。 使用error_code +预期的错误处理看起来像这样:


 template <typename T> using result = expected<T, std::error_code>; my::file_handle open_internal(const std::string& name, int& error); auto open_file(const std::string& name) -> result<my::file> { int raw_error = 0; my::file_handle maybe_result = open_internal(name, &raw_error); std::error_code error{raw_error, my::filesystem_error}; if (error) { return unexpected{error}; } else { return my::file{maybe_result}; } } 

重要的是在std::error_code值0表示没有错误。 如果您的错误代码不是这种情况,那么在将系统错误代码转换为std::error_code ,必须将代码0替换为SUCCESS,反之亦然。


errcsystem_category中描述了所有系统错误代码。 如果在某个阶段手动转发错误代码变得太乏味,那么您始终可以将错误代码包装在std::system_error并丢弃。


破坏性举动/微不足道的可重定位


让您需要创建拥有一些资源的另一类对象。 最有可能的是,您将使其不可复制但可移动,因为使用不可移动的对象很不方便(在C ++ 17之前它们无法从函数返回)。


但这是麻烦所在:无论如何,都需要删除移动的对象。 因此,“移出”的特殊状态是必需的,即,不删除任何内容的“空”对象。 事实证明,每个C ++类都必须具有一个空状态,也就是说,从构造函数到析构函数,不可能创建一个具有正确性(保证)正确性的类。 例如,不可能为整个生命周期中打开的文件创建正确的open_file类。 在积极使用RAII的几种语言中观察到这一点很奇怪。


另一个问题是移动时将旧对象归零会增加开销:填充std::vector<std::unique_ptr<T>>速度可能比std::vector<T*>慢2倍,因为移动时将旧指针归零,然后移除假人。


C ++开发人员长期以来一直对Rust情有独钟,Rust不会在重定位的对象上调用析构函数。 此功能称为破坏性移动。 不幸的是,提议琐碎可重定位没有提供将其添加到C ++中的功能。 但是开销的问题将得到解决。


如果执行以下两项操作,则一个类被视为可重定位:移动和删除旧对象等效于从旧对象到新对象的memcpy。 旧对象并未删除,作者称其为“放在地板上”。


如果满足以下(递归)条件之一,则从编译器的角度来看,一个类型可重定位:


  1. 它可微动+可微毁(例如int或POD结构)
  2. 这是标记有[[trivially_relocatable]]属性的类[[trivially_relocatable]]
  3. 这是一个所有成员都可轻松重定位的类。

您可以将此信息与std::uninitialized_relocate ,该命令以通常的方式执行move init + delete,或者在可能的情况下进行加速。 建议将标准库的大多数类型标记为[[trivially_relocatable]] ,包括std::stringstd::vectorstd::unique_ptr 。 考虑到这一点的开销std::vector<std::unique_ptr<T>>提案将消失。


现在异常有什么问题?


C ++异常机制于1992年开发。 已经提出了各种实施方案。 其中,选择了一个例外表机制,以保证程序执行主路径没有开销。 因为从它们创建的那一刻起,就假定应该很少抛出异常


动态(即常规)异常的缺点:


  1. 在引发异常的情况下,开销平均约为10,000-100,000个CPU周期,在最坏的情况下,开销可以达到毫秒级
  2. 二进制文件大小增加15-38%
  3. 与C编程接口不兼容
  4. 隐式异常抛出支持除noexcept外的所有函数。 几乎可以在程序中的任何地方引发异常,即使函数作者不希望看到该异常

由于这些缺点,例外的范围受到很大限制。 当例外无法适用时:


  1. 确定性很重要的地方,也就是说,有时“代码”的工作速度比平时慢10、100、1000倍是不可接受的
  2. 例如,当ABI不支持它们时
  3. 当大部分代码用C编写时
  4. 在拥有大量遗留代码的公司中( Google样式指南Qt )。 如果代码中至少有一个非异常安全的函数,那么根据卑鄙的规律,迟早会在该异常中抛出一个异常并创建一个错误
  5. 在公司中聘请对异常安全一无所知的程序员

根据调查,在52%(!)开发人员的工作场所中,公司规则禁止例外。


但是异常是C ++不可或缺的一部分! 通过包含-fno-exceptions标志,开发人员将失去使用标准库很大一部分的能力。 这进一步刺激了公司建立自己的“标准库”,并且,是的,发明了自己的字符串类。


但这还不是终点。 异常是取消在构造函数中创建对象并引发错误的唯一标准方法。 当它们关闭时,会出现类似两阶段初始化的可憎行为。 操作员也不能使用错误代码,因此它们被诸如assign功能所取代。


提案:未来的例外


新的异常转移机制


P709中的Herb Sutter描述了一种新的异常传输机制。 原则上,该函数返回std::expected ,但是,不是单独的bool类型的标识符(与对齐方式一起使用,它将在堆栈中占用多达8个字节),而是以更快的方式将这部分信息传输给例如Carry Flag。


不涉及CF的函数(大多数函数)将有机会免费使用静态异常-在正常返回的情况下以及在引发异常的情况下! 强制保存和恢复它的函数将获得最小的开销,并且仍然比std::expected和任何普通错误代码要快。


静态异常如下所示:


 int safe_divide(int i, int j) throws(arithmetic_errc) { if (j == 0) throw arithmetic_errc::divide_by_zero; if (i == INT_MIN && j == -1) throw arithmetic_errc::integer_divide_overflows; return i / j; } double foo(double i, double j, double k) throws(arithmetic_errc) { return i + safe_divide(j, k); } double bar(int i, double j, double k) { try { cout << foo(i, j, k); } catch (erithmetic_errc e) { cout << e; } } 

在备用版本中,建议在与throws函数调用相同的表达式中使用try关键字: try i + safe_divide(j, k) 。 这样可以将在throws情况下不安全的代码中使用throws函数的情况减少到几乎为零。 无论如何,与动态异常不同,IDE能够以某种方式突出显示引发异常的表达式。


抛出的异常没有单独存储,而是直接放置在返回值的位置,这一事实对异常的类型施加了限制。 首先,它必须微不足道。 其次,其大小不应太大(但可以像std::unique_ptr ),否则所有函数将在堆栈上保留更多空间。


status_code


由Niall Douglas开发的<system_error2>库将包含status_code<T> -“更好的新功能” error_code 。 与error_code的主要区别:


  1. status_code一种模板类型,可用于存储几乎所有可能的错误代码(以及指向status_code_category的指针),而无需使用静态异常
  2. T应该是可重定位和可复制的(后者,恕我直言,不应是强制性的)。 复制和删除时,从status_code_category调用虚拟函数
  3. status_code不仅可以存储错误数据,还可以存储有关成功完成操作的其他信息
  4. “虚拟”函数code.message()不返回std::string ,但string_refstd::string一种相当繁重的类型,它是虚拟的“可能拥有” std::string_view 。 在那里,您可以string_viewstring ,或std::shared_ptr<string> ,或其他拥有字符串的疯狂方式。 Niall声称#include <string>会使标头<system_error2>变得“沉重”

接下来,输入errored_status_code<T> -使用以下构造函数对status_code<T>进行包装:


 errored_status_code(status_code<T>&& code) [[expects: code.failure() == true]] : code_(std::move(code)) {} 

错误


默认的异常类型(不带类型的throws )以及所有其他类型的基本异常类型(例如std::exception )为error 。 定义如下:


 using error = errored_status_code<intptr_t>; 

也就是说, error就是这样的“错误” status_code ,其中值( value )放在1个指针中。 由于status_code_category机制可确保正确删除,移动和复制,因此从理论上讲,任何数据结构都可以error保存。 实际上,这将是以下选项之一:


  1. 整数(int)
  2. std::exception_handle ,即指向抛出动态异常的指针
  3. status_code_ptr ,即对任意status_code<T> unique_ptr

问题在于,案例3并未计划给将error带回到status_code<T>的机会。 您唯一可以做的就是获取打包的status_code<T> message() 。 为了能够将返回的值包装回error ,请将其作为动态异常(!)抛出,然后捕获并包装在error 。 通常,Niall认为,只有错误代码和字符串消息应该存储在error ,这对于任何程序来说都是足够的。


为了区分不同类型的错误,建议使用“虚拟”比较运算符:


 try { open_file(name); } catch (std::error e) { if (e == filesystem_error::already_exists) { return; } else { throw my_exception("Unknown filesystem error, unable to continue"); } } 

使用多个catch块或dynamic_cast选择异常类型将失败!


与动态异常的交互


一个功能可能具有下列规格之一:


  • noexcept :不抛出异常
  • throws(E) :仅引发静态异常
  • (无):仅引发动态异常

throws意味着没有noexcept 。 如果从“静态”函数引发了动态异常,则将其包装在error 。 如果从“动态”函数引发了静态异常,则将其包装在status_error异常中。 一个例子:


 void foo() throws(arithmetic_errc) { throw erithmetic_errc::divide_by_zero; } void bar() throws { //  arithmetic_errc   intptr_t //     error foo(); } void baz() { // error    status_error bar(); } void qux() throws { // error    status_error baz(); } 

C中的异常?!


该提议为将来的C标准之一提供了例外,并且这些例外将与C ++静态例外具有ABI兼容性。 尽管可以使用宏删除冗余,但用户必须独立声明类似于std::expected<T, U>的结构。 语法包括(为简单起见,我们假设)关键字fail,failure,catch。


 int invert(int x) fails(float) { if (x != 0) return 1 / x; else return failure(2.0f); } struct expected_int_float { union { int value; float error; }; _Bool failed; }; void caller() { expected_int_float result = catch(invert(5)); if (result.failed) { print_error(result.error); return; } print_success(result.value); } 

同时,在C ++中,也有可能从C调用fails函数,并在extern C块中声明它们。 因此,在C ++中,将有大量用于处理异常的关键字:


  • throw() -在C ++ 20中删除
  • noexcept函数说明符,该函数不会引发动态异常
  • noexcept(expression) -函数说明符,该函数不会抛出提供的动态异常
  • noexcept(expression) - noexcept(expression)是否引发动态异常?
  • throws(E) -函数说明符,该函数引发静态异常
  • throws = throws(std::error)
  • fails(E) -从C导入的函数引发静态异常

因此,在C ++中,他们引入(或更确切地说,交付了)大量用于错误处理的新工具。 接下来,出现一个逻辑问题:


什么时候使用什么?


一般方向


错误分为几个级别:


  • 程序员错误。 使用合同进行处理。 它们导致了日志的收集并根据快速失败的概念终止了程序。 示例:空指针(当此无效时); 除以零; 程序员未预见到的内存分配错误。
  • 程序员提供的致命错误。 与正常的函数返回相比,抛出该异常的频率要少一百万倍,这使使用动态异常为它们辩解。 通常,在这种情况下,您需要重新启动程序的整个子系统或在执行操作时给出错误。 示例:突然失去与数据库的连接; 程序员提供的内存分配错误。
  • 某些事情阻止该函数完成其任务,但调用函数可能知道如何处理时,将导致可恢复的错误。 由静态异常处理。 示例:使用文件系统; 其他输入/输出(IO)错误; 用户数据不正确 vector::at()
  • 该函数成功完成了任务,尽管结果出乎意料。 std::optionalstd::expectedstd::variant 。 示例: stoi() ; vector::find() ; map::insert

在标准库中,完全放弃使用动态异常以使编译“无异常”合法是最可靠的。


埃尔诺


使用errno快速轻松地处理C和C ++错误代码的函数应分别替换为throws(std::errc) fails(int)throws(std::errc) 。 在一段时间内,标准库功能的新旧版本将共存,然后旧版本将被宣布作废。


内存不足


内存分配错误由new_handler全局挂钩处理,该挂钩可以:


  1. 消除内存不足并继续执行
  2. 引发异常
  3. 崩溃程序

现在默认情况下std::bad_alloc抛出std::bad_alloc 。 建议默认调用std::terminate() 。 如果您需要旧的行为,请将处理程序替换为main()开头所需的处理程序。


标准库的所有现有功能将变为noexcept并在std::bad_alloc时使程序崩溃。 同时,将添加新的API,例如vector::try_push_back ,这会导致内存分配错误。


logic_error


异常std::logic_error std::domain_errorstd::logic_errorstd::domain_error std::invalid_argumentstd::length_errorstd::future_errorstd::future_error报告违反了函数前提条件。 新的错误模型应改为使用合同。 列出的异常类型不会被弃用,但是在标准库中使用它们的几乎所有情况都将由[[expects: …]]代替。


目前的提案状态


提案现在处于草稿状态。 它已经发生了很大的变化,并且仍然可以发生很多变化。 某些开发没有成功发布,因此建议的API <system_error2>并不完全相关。


该提案在3个文档中进行了描述:


  1. P709-萨特徽章的原始文件
  2. P1095 -Niall Douglas Vision中确定的异常,已更改了一些时刻,增加了C语言兼容性
  3. P1028-来自std::error测试实现的 API

当前没有支持静态异常的编译器。 因此,尚无法确定其基准。


C++23. , , , C++26, , , .


结论


, , . , . .


, ^^

Source: https://habr.com/ru/post/zh-CN430690/


All Articles