处理错误就是取消操作
这篇文章也是《使用错误代码对象进行C++错误处理》中提到的文章,不过干货不多,特别后半段写得不够清楚,或者我水平有限,硬着头皮翻译完了。以后除非遇到比较好的文章,否则还是不翻译了。
原文链接 https://akrzemi1.wordpress.com/2019/04/25/handling-errors-is-canceling-operations/
前言
实际上,我之前已经讨论过这个话题了(这篇文章)。但鉴于我最近的经历,我觉得需要重申一下,并进行一些调整。归根结底,我所遇到的任何错误处理(error codes, errno, exceptions, error monad),都是在函数发生错误时,直接或间接取消它所依赖的操作。这对我们如何看待我们的程序流程,以及我们在应对程序中的错误时应该遵循哪些原则,都有一定的影响。
取消级联操作
让我们看一看如何用C编写HTTP客户端的在线教程。在示例程序中client.c我们可以看到的一种模式,从概念上讲,这个程序是这样的:
open_socket();
if (failed)
die();
resolve_host();
if (failed)
die();
connect();
if (failed)
die();
send_data();
if (failed)
die();
receive_data();
if (failed)
die();
此代码实际上如下所示:
- 除非打开套接字成功,否则不要尝试解析主机。
- 除非主机解析成功,否则不要尝试连接到它。
- 除非连接到主机成功,……
这反映了操作之间依赖性:
- resolve_host()取决于成功执行open_socket().
- connect()取决于成功执行resolve_host().
- send_data()取决于成功执行connect(),……
此依赖关系将进一步传播到函数调用链的更高级别。这个玩具示例直接反映main函数的内部实现,它可以通过关闭应用程序来处理错误,同时也导致资源泄漏。但是一台本应运行数周或几个月的商业服务器,是承受不起的宕机的。在重要的程序中,我们必须关闭我们打开的所有套接字(并清理所有其他资源),并报告全部的函数失败,例如:
Status get_data_from_server(HostName host)
{
open_socket();
if (failed)
return failure();
resolve_host();
if (failed)
return failure();
connect();
if (failed)
return failure();
send_data();
if (failed)
return failure();
receive_data();
if (failed)
return failure();
close_socket(); // 可能资源泄漏
return success();
}
如果任何操作失败(以报告错误结尾),不仅函数内的后续操作被取消,而且整个函数报告失败,并导致较高级别的下一个函数的取消。我们的功能get_data_from_server()可在下列情况下调用:
Status do_the_task()
{
get_server_name_from_user();
if (failed)
return failure();
get_data_from_server(serverName);
if (failed)
return failure();
process_data();
if (failed)
return failure();
return success();
}
这种级联取消会继续进行,因为我们不希望在依赖函数失败时还调用它们:这样做将是一个错误。
这种操作依赖在几乎每一个程序中都是普遍存在的;在C++引入语法和特性时就反映了这种天生的依赖性:程序员不得不显式地使用if语句。我们展开堆栈:输入指令B在A的下面,已经表达了这种关系:“除非A成功,否则不要试图调用B”。这程序代码非常短,更清晰地描述了流程,如果它的依赖项失败了,就不可能无视它而让操作被调用。
取消依赖于失败操作的操作:这是处理错误的核心。这就是返回代码和if语句的情况,异常也是如此。在函数式编程语言中,error monads也表达了在失败时跳过依赖操作(并传播失败)的概念。实际上,我们可以说C++异常处理机制是一个内置于命令式语言中的(error monads):它跳过后续的操作,并把错误从throw处传到异常处理catch处。如果出于某种原因不想用异常,还有很多供选择的取消级联操作的替代方案。例如,Boost.Outcome库,函数返回类型可以表示成功的结果或关于失败的信息,而if语句隐藏在宏后面,这些宏能取消控制流。使用这个库,函数do_the_task()将改为:
result<int> do_the_task()
{
BOOST_OUTCOME_TRY(name, get_server_name_from_user());
BOOST_OUTCOME_TRY(data, get_data_from_server(name));
BOOST_OUTCOME_TRY(value, process_data(data));
return value;
}
但是核心思想仍然是一样的:如果它们所依赖的操作失败了,就不要执行操作。我们可以称之为取消级联。与这一观点有关的意见有很多。
有些操作不能取消
您可能已经注意到了这一点,在第二个示例中get_data_from_server()函数的实现,我已经植入了一个资源泄漏:因为任何操作的失败,如果函数需要提前退出,则套接字将不会关闭。这种模式在代码中也经常出现:如果获取了资源,取消操作之前或之后,都必须释放资源。换句话说,资源的释放取决于资源的成功获取,而不依赖于两者之间的任何其他操作。为了在程序中反映这一点,我们需要一个获取资源的构造,如果这个获取成功,则在资源不再使用的地方释放资源,即使在取消级联操作的情况下也要释放。C++提供了一种解决方案:称为RAII,它就是这样的语义。你需要以一种确定方式设计资源管理类型:构造函数获取资源(如果它不工作则表示失败),析构函数释放资源。现在,当你在作用域中创建这样的自动对象来管理资源时,我们得到了我们所需要的:
Status get_data_from_server(HostName host)
{
Socket socket {}; // opens the socket
if (failed) return failure();
resolve_host();
if (failed) return failure();
connect();
if (failed) return failure();
send_data();
if (failed) return failure();
receive_data();
if (failed) return failure();
return success();
} // closes the socket
注意:在本例中我们没有使用异常。在任何取消操作的错误处理中,都存在着一个问题:不应该被取消的操作被无意识地取消。这就是为什么C程序员经常被告知,在一个函数中只有一个返回语句。但是对于RAII来说,只有一个返回语句的动机已经不再那么强烈了。RAII不仅用于堆栈展开,还可用于任何基于取消级联的错误处理。
后续函数不依赖于成功地释放资源
有了析构函数,你就可以将它用于任何事情,但是你必须遵守析构函数的指导方针:析构函数只用于释放资源,许多其他事情就变得容易,明确和自然。获取或释放资源实际上从来不是函数的目标。函数的目标是产生某种价值或副作用,而资源只是实现目标的手段。示例中get_data_from_server()函数:它的目标是从服务器获取一些数据。它使用一个套接字来获取这些数据,但它只是一个实现细节。如果套接字无法打开,则无法从服务器获取数据:需要该数据的人现在必须被取消。同样,如果从服务器发送或接收数据失败,则需要取消数据的使用者。但是,如果我们从服务器接收到数据并准备将其返回给使用者,但在此之前关闭套接字的尝试失败,就没有必要启动取消级联:使用者将获得他们所需的数据,随后的函数将能够完成他们的任务。我们可能泄露了资源,但这并不妨碍后续函数的继续。稍后,资源的泄漏可能会导致其他操作的失败,但那将是它的问题,就会开始它的取消级联。
在异常处理上的一个建议,在析构函数(用于释放资源)即使由于某些原因而无法释放资源,也不应该抛出异常。这个建议可能会让人不舒服:它看起来就像隐藏关于失败的信息。但是,必须注意:异常处理不是用于广播任何系统中的故障;它是一个工具,用于声明操作之间的成功和失败依赖关系,并控制取消级联是如何进行的。如果不需要取消后续操作,则不要抛出,可使用其他方式广播有关故障的信息,例如日志记录或某种全局状态。
不要过早地停止取消级联
大多数情况下,如果在源代码中的操作B在操作A之后,这意味着B依赖于A的结果。如果A失败了B就不能执行,否则我们将在没有满足先决条件的情况下调用B,这将是一个错误。这意味着取消级联不能在A和B之间。只有当B不取决于A的成功时,才能停止A和B之间的取消级联。这种情况多久发生一次?答案是:在项目中的少数地方。例如,如果服务器正在接收请求并在主循环中处理它们。即使一个请求的处理失败,服务器也可以继续处理下一个请求。
另一种情况是某些函数需要返回一组记录。它从三个服务器获取记录:每个服务器返回记录的一部分。理想情况下,来自三台服务器的所有记录都应该返回,但如果只有一台服务器返回其记录,则可以接受。因此,如果我们将从每个服务器获取数据的操作称为A1,A2和A3,以及将记录作为B,我们可以说,虽然有一些依赖性,B不依赖A1(在A1单独失败的情况下要取消),不依赖于A2,不依赖于A3。因此,我们期望在程序代码中,开始于A1的取消级联能在到达B之前停止。
当应用到异常处理机制时,我们会得到这样的建议:不要捕获异常,除非你确信后续操作不再依赖于try内的操作成功与否。我向你保证,在项目中这样的情况不多。
基本的故障安全
通过取消级联来处理错误意味着我们一个接一个地退出作用域,放弃操作并销毁自动对象(调用它们的析构函数)。这就设定了一定的期望。对某个类对象的转变操作可能会失败,并可能使对象处于不希望的或其他意外状态。这不是什么大问题,因为失败(在正确处理错误的程序中)将启动取消级联和涉及对象的后续操作将被取消。如果观察到上一节中的规则,并且未过早停止级联,则对象将超出作用域,再也不会被看到。然而,在此之前,仍然需要调用一个操作:析构函数。因此,对对象的操作的设计提出了重要的要求:如果失败,至少应该使对象处于可以“安全”销毁的状态(不引起UB、bug、资源泄漏)。(尽管如前所述,并将在以后的文章中讨论,但有时我们可能无法防止资源泄漏。)这就是我们可以称之为基本的故障安全保障。在C++环境中,它通常被称为“基本的异常安全保障”,但应该指出:同样适用于每个错误处理技术。我们期望每一个变异操作都能提供这样的保证,否则我们就不能在遇到故障时考虑程序的正确性。我们必须假设每一个变异操作都满足它。
事实上,尽管以上是类能运行的最低要求,但是基本的故障安全保证要求更多。尽管如此,还是有可能有人会在对象超出作用域之前停止取消级联。在这种情况下,对句还存在,将有可能仍然使用它。在这种情况下,应该可以将对象重置到一个很好理解的状态。重置对象的最通用和最常见的方法是复制或为对象赋值一个新值。因此,如果类提供拷贝或移动赋值函数,则对对象任何突变操作失败了都是有保障的,对象不会引起UB、bug、资源泄漏。
其他类型可以提供重置其状态的其他方法,例如,stl容器提供成员函数clear()。
事实上,基本的故障安全保证还需要一件事:状态对象是有效,虽然没有指定它可以是什么特定的状态,它可以是任何状态只要是有效。但处于有效状态意味着什么呢?答案是:对于每种类类型,它的作者决定它处于有效状态意味着什么。最起码的是,处于这种状态的对象可以被销毁或赋值(只要赋值运算符不被删除),而不会导致UB、bug、资源泄漏。但是一个类类型可以并且通常会保证更多:例如,它可以保证处于这种状态的对象可以相互比较,或者它们可以被复制,或者它们上的每一个操作都能工作,或者它们进入默认构造的状态。但实际上,担保的最后一部分并不能买到多少。在实践中,与此相关的是,在发生故障后,可以安全地销毁和重置对象。有了这一保证,取消级联可以安全工作,而不会对程序造成损害。
今天就到此为止。总结一下建议:
- 在程序中执行资源管理的地方使用RAII。
- 析构函数只用于释放资源。
- 如果释放资源失败,不要启动取消级联。
- 不要停止取消级联,除非你确信取消与否对后续操作不影响。
- 确保任何操作发生错误时,对象都处于可以安全销毁和重置的状态。
- 无论您使用什么技术来处理错误,都要应用上面的建议。
欢迎关注我的公众号【林哥哥的编程札记】,也欢迎赞赏,谢谢!