错误模型(二)
Bug是不可恢复的错误
我们早期所做的一个重要区别是可恢复错误和错误之间的区别:
- 可恢复的错误通常是编程数据验证的结果。一些法典审查了世界状况,认为这种情况不可接受,无法取得进展。可能是一些正在解析的标记文本、来自网站的用户输入,或者是暂时的网络连接失败。在这些情况下,程序有望恢复。编写这段代码的开发人员必须考虑在失败时应该做什么,因为无论您做什么,它都会发生在构造良好的程序中。响应可能是将情况告知最终用户、重试或完全放弃操作,但是这是一种可预测的、经常是计划好的情况,尽管被称为“错误”
- Bug是一种程序员没想到的错误。输入没有正确验证,逻辑写错了,或者出现了许多问题。这样的问题通常都不会被及时发现;需要一段时间才能间接观察到“二次效应”,这时可能会对程序的状态造成重大损害。因为开发商没想到会发生这种事,所以所有的赌注都没有了。此代码可以访问的所有数据结构现在都是可疑的。因为这些问题不一定能及时发现,事实上,更多的问题值得怀疑。依赖于语言的隔离保证,也许整个过程都被污染了。
这种区别是最重要的。令人惊讶的是,大多数系统并不能产生一个,至少在原则上不是这样的!如上所述,Java、C#和动态语言只对所有内容使用异常;C和Go使用返回代码。C++使用的是一个取决于观众的混合,但通常的故事是一个项目选择一个单独的并且到处使用它。但是,您通常听不到语言建议使用两种不同的错误处理技术。
考虑到bug本质上是不可恢复的,我们没有尝试。在运行时检测到的所有错误都会导致所谓的终止,也就是所谓的“快速失败”。
上述每个系统都提供了类似于放弃的机制。C#有环境.FailFast,C++有std::terminate;等等。每一个都突然而迅速地撕开周围的背景。这个上下文的范围取决于系统——例如,C和C++终止进程。
尽管我们确实以一种比普通人更规范、更普遍的方式使用放弃,但我们肯定不是第一个认识到这种模式的人。哈斯克尔的这篇文章很好地阐明了这一区别:
我参与了一个用C++编写的图书馆的开发。一位开发人员告诉我,开发人员分为喜欢异常的和喜欢返回代码的两类。在我看来,返回码的朋友赢了。然而,我得到的印象是,他们争论了错误的一点:异常和返回代码同样具有表现力,但是它们不应该用来描述错误。实际上,返回代码包含数组索引超出范围等定义。但是我想知道:当我的函数从一个子例程获取这个返回代码时,它应该如何反应?它要给它的程序员发一封信吗?它可以依次将此代码返回给调用方,但也不知道如何处理它。更糟糕的是,由于我无法对函数的实现进行假设,我不得不期望每个子例程都有一个超出范围的数组索引。我的结论是数组索引超出范围是一个(编程)错误。它不能在运行时处理或修复,只能由开发人员修复。因此不应该有相应的返回代码,而是应该有断言。
放弃细粒度的可变共享内存作用域是可疑的,比如Goroutines或threads之类的,除非您的系统以某种方式保证所造成的潜在损害的作用域。不过,这些机制是伟大的,我们有使用!这意味着在这些语言中使用废弃规则确实是可能的。
然而,这种方法要在规模上取得成功,有一些架构元素是必要的。我敢肯定你在想“如果我每次在我的C#程序中出现空引用时都抛出整个过程,我会有一些非常恼火的客户”;同样地,“那根本就不可靠!”!“事实证明,可靠性可能不是你想的那样。
可靠性、容错性和隔离性
在我们进一步讨论之前,我们需要陈述一个中心信念:史无前例。
建立一个可靠的系统
普遍的看法是,你通过系统地保证失败永远不会发生来建立一个可靠的系统。直觉上,这很有道理。有一个问题:在极限,这是不可能的。如果你能像许多任务关键型实时系统一样,仅在这处房产上就花费数百万美元,那么你就可以取得重大进展。或许可以使用SPARK这样的语言(Ada的一组基于契约的扩展)来正式证明所写每一行的正确性。然而,经验表明,即使是这种方法也不是万无一失的。
我们没有反抗生活的现实,而是拥抱它。显然,你会尽可能地消除失败。错误模型必须使它们透明且易于处理。但更重要的是,你设计你的系统,这样即使个别部分出现故障,整个系统仍能正常工作,然后教你的系统优雅地恢复那些故障部分。这在分布式系统中是众所周知的。那为什么是小说呢?
最重要的是,操作系统只是一个由协作进程组成的分布式网络,就像一个由微服务或互联网本身组成的分布式集群。主要的区别包括延迟、可以建立的信任级别和容易程度,以及关于位置、标识等的各种假设,但是在高度异步、分布式和I/O密集型系统中,失败是必然发生的。我的印象是,在很大程度上,由于单片内核的持续成功,整个世界还没有实现“操作系统作为分布式系统”的飞跃。然而,一旦你这样做了,很多设计原则就会变得显而易见。
与大多数分布式系统一样,我们的体系结构假定过程失败是不可避免的。我们花了大量的时间来防止层叠故障,定期记录日志,并实现程序和服务的可重启性。
当你假设这一点的时候,你会以不同的方式构建事物。
特别是,隔离是至关重要的。系统的流程模型鼓励轻量级细粒度隔离。因此,程序和现代操作系统中通常的“线程”是独立的独立实体。防止一个这样的连接失败比在地址空间中共享可变状态要容易得多。
可靠性不可避免的代价是简单。(C.霍尔)。
通过将程序分成更小的部分(每个部分都可以独立失败或成功),其中的状态机保持简单。因此,从失败中恢复更容易。在我们的语言中,可能的失败点是明确的,这进一步有助于保持那些内部状态机的正确性,并指出那些与外部世界更混乱的联系。在这个世界上,个人失败的代价并没有那么可怕。我不能过分强调这一点。我以后描述的语言特征都不会在没有廉价的、永远存在的隔离的基础上工作得很好。
Erlang以一种基本的方式成功地将这个属性构建到语言中。它与Midori一样,利用通过消息传递连接的轻量级进程,并鼓励容错体系结构。一种常见的模式是“主管”,其中一些流程负责监视,如果出现故障,则重新启动其他流程。本文很好地阐述了这一理念——“让它崩溃”,并在实践中推荐了构建可靠Erlang程序的技术。
那么,关键不在于防止失败本身,而在于知道如何和何时处理失败。
一旦你建立了这个体系结构,你就要拼命确保它能正常工作。对我们来说,这意味着一周的压力运行,过程会来来去去去,有些是由于失败,以确保整个系统继续取得良好的进展。这让我想起了像Netflix的Chaos Monkey这样的系统,它只是随机杀死集群中的所有机器,以确保整个服务保持健康。
随着向更分布式计算的转变,我期望世界上更多的人采用这种理念。例如,在一个微服务集群中,单个容器的故障通常由封闭的集群管理软件(Kubernetes、Amazon EC2 container Service、Docker Swarm等)无缝处理。因此,我在这篇文章中描述的内容可能有助于编写更可靠的Java,Node.js/JavaScript节点,Python,甚至Ruby服务。不幸的是,你很可能会为了达到目的而与语言斗争。在你的过程中,很多代码都会非常努力地工作,当事情出错时,它们会一瘸一拐地继续前进。
遗弃
即使流程是廉价的、孤立的、易于重新创建的,但仍有理由认为,面对bug放弃整个流程是一种过度反应。让我试着说服你。
当你试图构建一个健壮的系统时,面对一个bug继续工作是危险的。如果一个程序员不希望出现给定的情况,谁知道代码是否会做正确的事情了。关键数据结构可能处于错误状态。作为一个极端(可能有点傻)的例子,一个为了银行业的目的而把你的数字四舍五入的程序可能会开始把它们四舍五入。
你可能会想把放弃的粒度缩小到比过程更小的程度。但这很棘手。举个例子,假设进程中的一个线程遇到了一个bug,并且失败了。此错误可能是由存储在静态变量中的某个状态触发的。即使其他线程看起来没有受到导致失败的条件的影响,您也不能得出这个结论。除非系统的某些属性(语言中的隔离、暴露给独立线程的对象根集的隔离或其他属性)否则最安全的假设是,除了将整个地址空间扔出窗口之外,其他任何东西都是危险和不可靠的。
由于系统进程的轻量级特性,放弃进程更像是放弃经典系统中的单个线程,而不是整个进程。但我们的隔离模式让我们可以可靠地做到这一点。
我承认范围界定的话题是一个棘手的问题。也许世界上所有的数据都已经被破坏了,那么你怎么知道抛开这个过程就足够了?!这里有一个重要的区别。过程状态在设计上是瞬态的。在一个设计良好的系统中,它可以被扔掉,并在一时兴起的情况下重新创建。一个bug确实会破坏持久状态,但是你的手上有一个更大的问题,这个问题必须以不同的方式处理。
在一定的背景下,我们可以研究容错系统的设计。终止(fail fast)已经是这个领域中的一种常见技术,我们可以将对这些系统的了解应用到普通程序和进程中。也许最重要的技术是定期记录和检查宝贵的持久状态。吉姆·格雷1985年的论文,为什么电脑会停止工作,对此能做些什么?,很好地描述了这个概念。随着程序继续向云端移动,并积极地分解成更小的独立服务,这种暂时状态和持久状态的清晰分离就更加重要了。由于软件编写方式的这些转变,在现代架构中,放弃比以前要容易得多。事实上,放弃可以帮助您避免数据损坏,因为在下一个检查点之前检测到的错误会阻止坏状态的转义。
系统内核中的错误处理方式不同。例如,微内核中的bug与用户模式进程中的bug完全不同。可能的损害范围更大,最安全的反应是放弃整个“域”(地址空间)。谢天谢地,您认为经典的“内核”功能(调度器、内存管理器、文件系统、网络堆栈,甚至设备驱动程序)大部分都是在用户模式下的独立进程中运行的,在这种模式下,故障可以通过上述常见方式得到控制。
Bug:终止、断言和契约
系统中的许多错误可能会触发终止:
- 一个不正确的类型转换。
- 试图取消对空指针的引用。
- 试图访问超出其边界的数组。
- 除以零。
- 意外的数学溢出/下溢。
- 内存不足。
- 堆栈溢出。
- 明确放弃。
- 合约失败。
- 断言失败。
我们的基本信念是,每一种情况都是程序无法恢复的。让我们分别讨论一下。
普通的旧bug
其中一些情况无疑是程序错误的征兆。不正确的强制转换、试图取消对null的引用、数组越界访问或除以0显然是程序逻辑的问题,因为它试图执行不可否认的非法操作。正如我们稍后将看到的,有很多解决方法(例如,您可能希望DbZ使用NaN风格的传播)。但默认情况下,我们认为这是一个bug。
大多数程序员都愿意毫无疑问地接受这一点。以这种方式将它们作为bug进行处理,会导致内部开发循环的废弃,在开发过程中可以快速发现并修复bug。放弃确实有助于提高人们编写代码的效率。一开始这对我来说是个惊喜,但这是有道理的。
另一方面,有些情况是主观的。我们必须对违约行为做出决定,通常会引起争议,有时还会提供程序控制。
算术溢出/下溢
说一个意外的算术溢出/下溢代表了一个错误,这无疑是一个有争议的立场。然而,在一个不安全的系统中,这样的事情经常导致安全漏洞。我鼓励你审查国家脆弱性数据库,看看其中的数量。
事实上,我们移植到Windows TrueType字体解析器(性能有所提高)仅在过去几年就遭受了十几次的损失。(解析器往往是这样的安全漏洞的服务器。)这就产生了SafeInt这样的包,它从本质上把你从你的母语的算术操作转移到选中的库操作。
当然,这些攻击大多还伴随着对不安全内存的访问。因此,您可以合理地争辩说,溢出在安全语言中是无害的,因此应该被允许。但是,根据安全经验,很明显,程序在遇到意外的溢出/溢出时往往会做错误的事情。简单地说,开发人员经常忽略这种可能性,程序会继续执行计划外的任务。这就是错误的定义,这正是放弃要抓住的东西。棺材上的最后一个钉子是,在庸俗的方面,当有任何关于正确性的问题时,我们倾向于在明确意图方面犯错误。
因此,所有未注释的溢出/不足流都被视为错误并导致放弃。这与使用/checked开关编译C#类似,只是我们的编译器积极地优化了冗余检查。(由于很少有人认为在C++中抛出这个开关,代码生成器在删除插入的检查方面几乎不做积极的工作)。得益于这种语言和编译器的共同开发,结果比大多数C++编译器在安全算术面前所产生的结果要好得多。与C#一样,未检查的作用域构造也可以用于预期的溢出/下溢位置。
虽然我对大多数C和C++开发者的初步反应是否定的,但是我们的经验是,在10的9倍中,这种方法有助于避免程序中的错误。剩下的1次通常是在我们72小时的一次压力测试中的某个晚些时候放弃的——在测试中,当一些无害的计数器溢出时,我们用浏览器和多媒体播放器以及其他任何可以折磨系统的方法破坏了整个系统。我总是觉得有趣的是,我们花了时间来解决这些问题,而不是传统的产品通过压力程序成熟的方式,也就是说死锁和种族状况。在你和我之间,我会接受多余的放弃!
内存不足和堆栈溢出
内存不足(OOM)很复杂。总是这样。我们在这里的立场当然也有争议。在手动管理内存的环境中,错误代码类型的检查是最常见的方法:
X* x = (X*)malloc(...); if (!x) { // Handle allocation failure. }
这有一个微妙的好处:分配是痛苦的,需要考虑,因此使用这一技术的程序通常更为节约和慎重使用内存的方式。但它有一个巨大的缺点:它容易出错,导致大量经常未经测试的代码路径。当代码路径未经测试时,它们通常不起作用。
一般来说,开发人员在资源耗尽的边缘使他们的软件正常工作的工作非常糟糕。以我在Windows和.NET Framework上的经验,这是犯下严重错误的地方。它还导致了极其复杂的编程模型,比如.NET所谓的受限执行区域。一个程序一瘸一拐地前进,甚至无法分配少量内存,很快就会成为可靠性的敌人。克里斯•布鲁姆(Chris Brumme)在其令人惊叹的可靠性文章中描述了这一点,并在其血腥的荣耀中描述了相关的挑战。
当然,我们系统的某些部分在某种意义上是“硬性”的,就像内核的最低级别,放弃的范围必然比单个进程更广。但我们尽可能少地使用代码。
剩下的呢?是的,你猜对了:终止。又好又简单。
令人惊讶的是,我们竟然逃脱了这么多的惩罚。我把这大部分归因于隔离模型。事实上,由于资源管理政策,我们可以故意让一个进程遭受OOM和随后的放弃,并且仍然相信稳定和恢复是建立在整个体系结构中的。
如果您真的需要,可以选择对单个分配执行可恢复失败。这一点都不常见,但支持它的机制却在那里。也许最好的激励示例是这样的:假设您的程序想要分配大小为1MB的缓冲区。这种情况不同于您通常运行的mill sub-1KB对象分配。开发人员很可能已经准备好考虑并显式地处理这样一个事实,即大小为1MB的连续块可能不可用,并相应地处理它。例如:
var bb = try new byte[1024*1024] else catch; if (bb.Failed) { // Handle allocation failure. }
堆栈溢出是这一原理的简单扩展。堆栈只是一个内存支持的资源。事实上,由于我们的异步链接堆栈模型,堆栈耗尽与堆内存耗尽在物理上是相同的,因此处理堆栈的一致性对开发人员来说并不奇怪。现在很多系统都是这样处理堆栈溢出的。
断言
断言是在代码中手动检查某个条件是否为真,如果不是,则触发放弃。与大多数系统一样,我们有只调试和发布代码断言,但是与大多数其他系统不同,我们有比调试更多的发布代码断言。事实上,我们的代码充满了断言。大多数方法有多种。
这符合这样一种理念:在运行时找到一个bug要比在一个bug面前继续前进要好。当然,我们的后端编译器也被教导了如何像其他任何东西一样积极地优化它们。这种断言密度的级别与高可靠性系统的指南所建议的类似。例如,在美国宇航局的论文中,十条规则的力量:制定安全关键代码:
规则:代码的断言密度应该平均到每个函数至少有两个断言。断言用于检查在实际执行中不应该发生的异常情况。断言必须始终是无副作用的,并且应该定义为布尔测试。
理由:工业编码工作的统计数据表明,单元测试通常在每编写10到100行代码时至少发现一个缺陷。截获缺陷的几率随着断言密度的增加而增加。断言的使用通常也被推荐为强防御性编码策略的一部分。
为了表示断言,您只需调用Debug.Assert
or Release.Assert
:
void Foo() { Debug.Assert(something); // Debug-only assert. Release.Assert(something); // Always-checked assert. }
合约
在系统里契约是捕获bug的中心机制。尽管我们从奇点(Singularity)开始,它使用了Sing#,一种Spec#的变体,但我们很快就转向了vanilla C#,不得不重新发现我们想要的东西。在与模特生活多年后,我们最终来到了一个完全不同的地方。
由于我们的语言对不变性和副作用的理解,所有的契约和断言都被证明是无副作用的。这也许是语言创新的最大领域,所以我很快会写一篇关于它的文章。
与其他领域一样,我们受到许多其他系统的启发和影响。规范是显而易见的。埃菲尔是非常有影响力的,特别是有许多出版的案例研究可以借鉴。研究工作,如基于Ada的SPARK,以及对实时和嵌入式系统的建议。深入到理论兔子洞里,像霍尔公理语义这样的编程逻辑为它提供了基础。然而,对我来说,最具哲学意义的灵感来自CLU和后来的Argus对错误处理的总体方法。
先决条件和后决条件
合约的最基本形式是方法前提。这说明了要发送方法必须具备的条件。这通常用于验证参数。有时它被用来验证目标对象的状态,但是这通常是不受欢迎的,因为模态对于程序员来说是很难解释的。前提条件本质上是调用者向被调用者提供的保证。
在我们的最终模型中,使用requires关键字声明了一个先决条件:
void Register(string name) requires !string.IsEmpty(name) { // Proceed, knowing the string isn't empty. }
一种稍不常见的合同形式是条件后处理方法。这将说明在发送方法后保持的条件。这是被调用者向调用者提供的保证。
在我们的最终模型中,使用确保关键字声明了后置条件:
void Clear() ensures Count == 0 { // Proceed; the caller can be guaranteed the Count is 0 when we return. }
还可以通过特殊的名称return在后置条件中提到返回值。旧值(例如在post条件中提到输入所必需的值)可以通过Old(..)捕获;例如:
int AddOne(int value) ensures return == old(value)+1 { ... }
当然,前后条件可以混合。例如,从内核中的环形缓冲区:
public bool PublishPosition() requires RemainingSize == 0 ensures UnpublishedSize == 0 { ... }
此方法可以在知道RemainingSize为0的情况下安全地执行其主体,并且调用方可以在知道UnpublishedSize也是0的情况下在返回后安全地执行。如果在运行时发现这些契约中的任何一个为false,则会发生放弃。
这是我们不同于其他努力的一个领域。契约作为程序逻辑在高级证明技术中的一种表现形式,最近变得很流行。这些工具通常使用全局分析来证明所述合同的真实性或虚假性。我们采取了一种更简单的方法。默认情况下,在运行时检查合同。如果编译器可以在编译时证明是真是假,则可以分别省略运行时检查或发出编译时错误。
现代编译器有基于约束的分析,在这方面做得很好,就像我在上一篇文章中提到的范围分析一样。它们传播事实并使用它们来优化代码。这包括消除冗余检查:要么在合同中显式编码,要么在正常程序逻辑中。他们被训练在合理的时间内执行这些分析,以免程序员切换到另一个更快的编译器。定理证明技术根本不能满足我们的需求;我们的核心系统模块花了一天时间使用同类最佳的定理证明分析框架进行分析!
此外,方法声明的合同是其签名的一部分。这意味着它们将自动显示在文档、提示工具提示等中。契约和方法的返回和参数类型一样重要。契约实际上只是类型系统的扩展,使用语言中的任意逻辑来控制交换类型的形状。因此,所有常用的子类型要求都适用于它们。当然,这有助于使用标准优化编译器技术在几秒钟内完成模块化局部分析。
在.NET和Java中,90%左右的异常的典型用法成为了先决条件。所有的ArgumentNullException、ArgumentOutOfRangeException和相关类型,更重要的是,手动检查和抛出都不见了。如今,在C#today中,方法中经常会出现这些检查;仅在.NET的CoreFX回购协议中,就有数千个这样的检查。例如,这里是系统IO文本阅读器的读取方法:
/// <summary> /// ... /// </summary> /// <exception cref="ArgumentNullException">Thrown if buffer is null.</exception> /// <exception cref="ArgumentOutOfRangeException">Thrown if index is less than zero.</exception> /// <exception cref="ArgumentOutOfRangeException">Thrown if count is less than zero.</exception> /// <exception cref="ArgumentException">Thrown if index and count are outside of buffer's bounds.</exception> public virtual int Read(char[] buffer, int index, int count) { if (buffer == null) { throw new ArgumentNullException("buffer"); } if (index < 0) { throw new ArgumentOutOfRangeException("index"); } if (count < 0) { throw new ArgumentOutOfRangeException("count"); } if (buffer.Length - index < count) { throw new ArgumentException(); } ... }
这是因为一些原因被打破的。当然,这很费劲。所有的仪式!但是,当开发人员真的不应该捕捉异常时,我们必须不遗余力地记录异常。相反,他们应该在开发过程中找到错误并修复它。所有这些无稽之谈都会助长非常恶劣的行为。
另一方面,如果我们使用Midori风格的契约,则会崩溃为:
/// <summary> /// ... /// </summary> public virtual int Read(char[] buffer, int index, int count) requires buffer != null requires index >= 0 requires count >= 0 requires buffer.Length - index >= count { ... }
这有一些吸引人的地方。首先,它更简洁。然而,更重要的是,它以一种记录自身并易于被调用方理解的方式来自我描述API的契约。不需要程序员用英语表达错误条件,实际的表达式可供调用者阅读,工具可供理解和利用。它用放弃来传达失败。
我还应该提到我们有很多合同助手来帮助开发人员编写共同的先决条件。上面的显式范围检查非常混乱,容易出错。相反,我们可以写:
public virtual int Read(char[] buffer, int index, int count) requires buffer != null requires Range.IsValid(index, count, buffer.Length) { ... }
而且,除了手头的,再加上两个高级特性(作为切片的数组和非空类型的数组),我们可以将代码缩减为以下内容,同时保留相同的保证:
public virtual int Read(char[] buffer) { ... }
可恢复错误:类型定向异常
当然,终止并不是唯一的故事。仍然有很多合法的情况下,程序员可以合理地从错误中恢复。示例包括:
- File I/O.
- Network I/O.
- 数据解析 (e.g., a compiler parser).
- 验证用户数据(e.g., a web form submission).
在每一种情况下,你通常不想在遇到问题时触发终止。相反,程序希望它不时发生,并且需要通过做一些合理的事情来处理它。通常是通过与某人交流:用户键入网页、系统管理员、使用工具的开发人员等。当然,如果这是最合适的操作,放弃是一种方法调用,但对于这些情况,终止往往过于激烈。而且,特别是对于IO,它有使系统变得非常脆弱的风险。想象一下,如果你正在使用的程序决定在每次你的网络连接丢失一个数据包时消失!
异常
我们对可恢复的错误使用异常。不是未检查的类型,也不完全是Java检查的类型。
第一件事是:虽然有异常,但是一个没有注释为throws的方法永远不会抛出异常。从来没有。例如,在Java中就没有类似的偷偷的运行时异常。无论如何,我们并不需要它们,因为Java使用运行时异常的相同情况是在M中使用放弃。
这导致了结果系统的神奇特性。我们系统中90%左右的函数不能抛出异常!事实上,默认情况下,他们不能。这与C++这样的系统形成了鲜明的对比,在这里你必须走出去避免异常,并用NOTE来说明这个事实。当然,api仍然可能由于放弃而失败,但只有在调用方未能满足声明的契约时,这类似于传递错误类型的参数。
我们对例外的选择一开始就有争议。我们在团队中混合了命令式、过程式、面向对象和函数式语言的观点。C程序员想要使用错误代码,他们担心我们会重新创建Java,或者更糟的是,C设计。函数透视图将对所有错误使用数据流,但异常非常面向控制流。最后,我认为我们选择的是所有可用的可恢复错误模型之间的一个很好的折衷。正如我们稍后将看到的,我们确实提供了一种将错误作为第一类值处理的机制,这种情况很少见,在这种情况下,开发人员需要的是更具数据流风格的编程。
然而,最重要的是,我们在这个模型中编写了很多代码,并且它对我们非常有效。即使是功能性语言的人最终也出现了。和C程序员一样,多亏了我们从返回代码中获得的一些提示。
语言和类型系统
在某个时候,我做了一个有争议的观察和决定。正如您不会以零兼容性影响的期望更改函数的返回类型一样,您也不应该以这样的期望更改函数的异常类型。换句话说,异常和错误代码一样,只是一种不同的返回值!
这是针对选中的异常的一个反复论证。我的回答可能听起来很老套,但很简单:太糟糕了。您使用的是静态类型的编程语言,异常的动态特性正是它们糟糕的原因。我们试图解决这些问题,因此我们接受了它,美化了强大的打字,从来没有回头。仅此一点就有助于缩小错误代码和异常之间的差距。
函数抛出的异常成为其签名的一部分,就像参数和返回值一样。请记住,与放弃相比,例外的性质是罕见的,这并不像你想象的那么痛苦。很多直觉的属性都是从这个决定中自然流露出来的。
首先是Liskov替代原则。为了避免C++发现的混乱,所有的“检查”必须在编译时静态地发生。因此,WG21文件中提到的所有性能问题对我们来说都不是问题。然而,这种类型的系统必须是防弹的,没有后门来击败它。因为我们需要通过在优化编译器中抛出注释来解决这些性能挑战,类型安全依赖于这个属性。
我们尝试了许多不同的语法。在我们致力于改变语言之前,我们用C属性和静态分析做了所有的事情。用户体验不是很好,很难做到真正的类型系统那样。而且,它感觉太紧了。我们尝试了Redhawk项目中的方法(最终成为.NET Native和CoreRT)但是,这种方法也没有利用该语言,而是依赖于静态分析,尽管它与我们的最终解决方案有许多相似的原则。
最终语法的基本要点是简单地将方法抛出声明为一个位:
void Foo() throws { ... }
(很多年来,我们实际上是在方法的开始就抛出了一些问题,但那是错误的。)
在这一点上,可替代性的问题相当简单。throws函数不能代替non-throws函数(非法加强)。另一方面,不抛功能可以代替抛功能(法律弱化)。这显然会影响虚拟重写、接口实现和lambdas。
当然,我们做了预期的协变量和反变量替换。例如,如果Foo是虚拟的,并且您重写了它,但是没有抛出异常,那么您不需要声明throws契约。当然,实际上任何调用这样一个函数的人都不能利用它,但是直接调用可以。
例如,这是合法的:
class Base { public virtual void Foo() throws {...} } class Derived : Base { // My particular implementation doesn't need to throw: public override void Foo() {...} }
派生的调用方可以利用缺少throws;而这是完全非法的:
class Base { public virtual void Foo () {...} } class Derived : Base { public override void Foo() throws {...} }
鼓励单一的失败模式是相当自由的。Java的checked异常带来的大量复杂性立即消失了。如果你看看大多数失败的api,它们无论如何都有一个单一的失败模式(一旦所有的bug失败模式都被放弃了):IO失败,解析失败,等等。而且开发人员倾向于编写的许多恢复操作实际上并不依赖于在做IO的时候什么失败的细节。(有些确实是这样,对于那些人来说,keeper模式通常是更好的答案;稍后会有更多关于这个主题的内容。)现代异常中的大多数信息实际上并不用于编程,而是用于诊断。
我们坚持这种“单一故障模式。最终我做出了支持多种故障模式的有争议的决定。这并不常见,但这个请求经常从队友那里合理地弹出,而且场景看起来是合理和有用的。它的确以牺牲类型系统的复杂性为代价,但只是以所有常见的子类型化方式为代价。而更复杂的场景要求我们这样做。
回顾与结论
我们已经走到了这段旅程的终点。正如我在一开始所说,一个相对可预测和温和的结果。但我希望所有这些背景都有助于你在我们梳理错误景观的过程中经历进化。
总之,最终模型的特点是:
- 假定细粒度隔离和故障恢复能力的体系结构。
- 区分错误和可恢复错误。
- 使用契约、断言,以及一般情况下对所有bug的放弃。
- 对可恢复的错误使用精简的checked异常模型,并使用丰富的类型系统和语言语法。
- 采用返回码的某些有限方面(如本地检查),提高了可靠性。
终止,以及我们使用它的程度,在我看来,是我们用错误模型进行的最大、最成功的赌注。我们很早就发现了漏洞,而且经常发现,最容易诊断和修复的地方。基于终止的错误数量超过了可恢复的错误数量,比率接近10:1,这使得检查的异常非常罕见,开发人员可以容忍。
细粒度隔离的体系结构基础是关键的,然而许多系统对这种体系结构有非正式的概念。OOM放弃在浏览器中运行良好的一个原因是,大多数浏览器已经将单独的进程专用于单独的选项卡。浏览器在很多方面模仿操作系统,这里我们也看到了这一点。
当然,我也提到过,Go、Rust和Swift同时为世界提供了一些非常好的适合系统的错误模型。