处理错误就是取消操作

这篇文章也是《使用错误代码对象进行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。
  • 析构函数只用于释放资源。
  • 如果释放资源失败,不要启动取消级联。
  • 不要停止取消级联,除非你确信取消与否对后续操作不影响。
  • 确保任何操作发生错误时,对象都处于可以安全销毁和重置的状态。
  • 无论您使用什么技术来处理错误,都要应用上面的建议。

欢迎关注我的公众号【林哥哥的编程札记】,也欢迎赞赏,谢谢!

posted @ 2020-04-19 15:50  qinwanlin  阅读(255)  评论(0编辑  收藏  举报