Midori博客系列翻译(5)——错误模型

Midori是由基于C#,通过AOT编译的且类型安全的语言编写而成的操作系统。除了其微内核部分之外,整个系统都由该语言而成,包括驱动程序、域内核和所有的用户态代码。我在前面的文章中已经提及该语言设计的一些方面,现在是时候正面介绍的时候了。整个语言需要巨大的空间来覆盖,也需要一系列的文章来分析。那从什么地方开始呢?错误模型。传递和处理错误的方式是任何一门语言的基础,尤其是用于编写可靠操作系统的语言。像我们在Midori上所做的许多其他事情一样,需要几年的几次迭代的“整个系统(wholesystem)”方法,在错误模型上也是必要的。然而,我经常听到以前团队成员们说,这是他们关于Midori最怀念的地方,而对我来说也是如此。因此,不用多说了,让我们现在开始吧。

介绍

错误模型试图回答的基本问题是:“错误”是如何传递给程序员和系统用户的。很简单,不是吗?至少它看起来是这样的。

回答这个问题的最大挑战之一是,定义错误究竟是什么。大多数语言将程序bug(缺陷)和可恢复的错误归为同一类,并使用相同的工具来处理它们,使得null指针的解引用或数组越界访问,与网络连接问题或解析错误的处理方式相同。这样的一致性乍一眼看起来似乎很不错,但它有根深蒂固的问题,特别是这种方式具有误导性并经常导致代码的不可靠。

我们的整体解决方案是提供双管齐下的错误模型。一方面,对于编程bug来讲,语言有快速失败机制,我们称之为Abandonment(放弃),另一方面,对于可恢复的错误,语言有静态检查性异常机制。这两者在编程模型和它们背后的机制方面都大相径庭,Abandonment意味着无条件地在瞬间终止整个进程,因此不再运行任何用户代码(还记得么,一个典型的Midori程序由许多小型轻量级的进程组成),而异常当然有助于程序的恢复,同时它也有深层次的类型支持以帮助检查和验证。

这段旅程漫长而曲折,为了便于讲述,我将这篇文章分为六个主要部分:

事后看来,某些结果似乎很明显,特别是对于现代系统语言,如Go和Rust来说,但另外一些结果也出乎我们的意料。无论如何我都会尽力讲述这一切,并且会在此过程中提供充足的背景故事。我们尝试了许多了不起作用的东西,因此我觉得这些尝试比最终已知结果时更加有趣。

雄心和学习

让我们首先从检视我们的架构原则和要求,以及从现有的系统中学习开始。

原则

在开始这段旅程时,我们提出了,对于一个错误模型来讲的其优异体现的几个要求:

  • 可用性:开发者面对错误时必须很容易做出“正确”的事情,几乎就像下意识一样。一位朋友和同事很准确地将其称为“成功之坑(The Pit of Success)”。为了写出符合习惯的代码,模型不应该施加过多的限制,并且在理想情况下,对于目标受众来说,它应该在认知上是熟悉的。

  • 可靠性:错误模型是整个系统可靠性的基础,毕竟,我们正在构建的是一个操作系统,因此可靠性至关重要,你甚至可能会指责我们痴迷于在此方面追求到极致。我们对编程模型开发的大量指导原则就是“通过构造来纠正错误”。

  • 高性能:在正常情况,系统需要高效运行,这意味着成功路径的开销要尽可能地接近于零,并且失败路径的任何增加的开销必须完全是“按使用付费(pay-for-play)”。与许多愿意为失败路径接受过度开销的现代系统不同,我们有几个性能关键的组件,错误的过度开销对于它们来说是不可接受的,因此错误处理也必须要足够高效。

  • 并发:我们的整个系统是分布式且高度并发,所以这会引起在其他错误模型中通常也会遇到的问题,因此这部分需成为我们工作的最前沿和重心。

  • 可诊断:无论是交互式的还是事后进行的调试,都需要高效且简单。

  • 可组合的:错误模型是编程语言的核心功能,位于开发者代码描述的中心,因此,错误模型必须提供常见的正交性和与系统其他功能的可组合性,与单独编写的组件的集成也必须是自然、可靠和可预测的。

这是一个勇敢的要求,但我确实认为我们最终在所有方面上取得了成功。

学习

现有的错误模型并不能满足我们的上述要求,至少不完全满足,某些系统在一个维度上做得很好,但在另一个维度上表现不佳。例如,错误码返回的方式具有良好的可靠性,但许多程序员发现容易错误地使用它们,进而很容易出错,比如说忘记检查某些返回码,这显然违反了“成功之坑”的要求。

鉴于对可靠性极高的追求,我们对大多数模型并不满意这一点上并不令人惊讶。

如果你像使用脚本语言那样,对易用性的优化胜过可靠性,那么得到的结论将会非常不同。而像Java和C#这样的语言很难再优化,是因为它们处于不同使用场景的十字路口上:有时用于系统,有时又用于应用程序,但总体而言,它们的错误模型非常不适合我们的要求。

最后要提醒的是,本篇文章在时间轴上始于2000年代中期,在那之后,Go、Rust和Swift语言在错误模型上做了一些很不错的工作,但在当时,却没有这样的语言可供我们选择。

错误码

错误码可以说是最简单的错误模型。该想法非常基础,甚至不需要语言或运行时的支持,只需要函数返回一个值,这个值通常是一个整数,表示执行成功或失败:

int foo() {
    // <在这里尝试完成某项任务>
    if (failed) {
        return 1;
    }
    return 0;
} 

以上是典型的模式,返回“0”表示成功,非零则表示失败,而调用者必须对其进行检查:

int err = foo();
if (err) {
    // 发生错误!需对其进行处理 
} 

大多数系统提供表示错误码的常量集合,而不仅仅是幻数(magic number),你可以使用或不使用相关函数来获取最近一次错误发生的额外信息(例如标准C语言中的errno和Win32中的GetLastError)。返回码在编程语言中并不特别,它从本质上讲仅仅只是一个返回值而已。

C语言长期使用错误码的方式,因此,大多数基于C的生态系统也都如此。使用返回码规则编写的低层系统代码比任何其他类型的都要多,Linux是这么做的,无数任务关键和实时系统也是如此。因此可以说,它有令人印象深刻的历史记录!

在Windows中,HRESULT与错误码是等效的。HRESULT仅仅是一个整数的“句柄”,在winerror.h中有一堆常量和宏,如S_OKE_FAULTSUCCEEDED()等,用于创建和检查这些返回码。Windows中最重要的代码是使用返回码规则编写,例如在内核中是没有异常的,至少不是故意这么做的。

在手动管理内存的环境中,出错时释放内存是非常困难的一件事,而返回码可以使这种情况(更容易)被容忍。C++通过使用RAII的方式来自动管理内存,但除非你是C++模型(不是C语言那部分)的彻底贯彻者(事实上相当多的系统程序员并非如此),否则并没有好的方法在你的C程序中增量式地使用RAII。

再后来,Go选择了错误码方式,虽然Go的方法与C类似,但它更现代化,并且具有更好的语法和库支持。

许多函数式语言使用monad来封装返回码,并命名为Option<T>Maybe<T>Error<T>,使得它们与数据流编程和模式匹配相结合时感觉更自然,特别是与C相比,这种方法消除了我们即将讨论的几个返回码的主要缺点。Rust已经在很大程度上采用了这个模型,它为系统程序员提供了一些令人兴奋的特性。

虽然它很简单,返回码也确实存在一些缺陷,总的说来包括以下几点:

  • 性能可能会受到影响;
  • 编程模型的可用性会变差;
  • 最重要的一点是,可能会意外地忘记对错误进行检查。

让我们依次用上面提及的语言中的例子来讨论每一个缺点。

性能

错误码未满足所谓“正常情况下零开销,非正常情况下才付费(zero overhead for common cases, pay for play for uncommon cases)”的要求:

  1. 对调用约定会会造成影响。函数现在需要返回两个值(对于非void函数来说):实际的返回值和可能的错误码,从而消耗掉更多寄存器和/或栈空间,导致调用效率降低。当然,内联化调用子集可以弥补这种开销。
  1. 在被调用点可能出现失败的任何地方需要注入分支。我将其称之为“花生酱(peanut butter)”开销,因为检查像花生酱一样被“涂抹”在代码中的各个地方,使得难以直接衡量其影响。在Midori中,我们能够通过试验和测量并确认这种影响——是的,这里的开销是不可忽略的。还有一个次要的影响是,因为函数包含更多的分支,所以更有可能混淆优化器。

这样的事实对某些人来说可能会吃惊,因为每个人毫无疑问都听说过“异常处理很耗时”的说法。事实证明,异常处理并不低效,当正确地执行时,代码会从热点路径上剔除错误处理的代码和数据,与上面提到的返回码开销相比,这种情况增加了I-cache和TLB的性能。

许多高性能系统都是使用返回码构建的,所以你可能会认为我在挑剔,正如我们所做的许多其他事情一样,一种简单的批评方式是我们采取了极端的方法,但事实是性能变得更糟糕。

忘记对它们进行检查

忘记检查返回码的情况是很容易发生的。比如说,考虑如下函数:

int foo() { ... } 

在调用点上,如果现在我们悄悄地忽略掉返回码,然后继续执行,会出现什么情况呢?

foo();
// 继续执行,但foo可能执行失败了! 

此时,你已经掩盖了程序中可能存在的严重错误,这很容易成为错误码最棘手和最具破坏性的问题。正如我稍后将要介绍的那样,选项类型(option)有助于在函数式语言中解决这个问题,但是在基于C的语言,甚至是Go的现代语法中,这是一个真实存在的问题。

这个问题不仅仅是在理论上存在,实际上我遇到了无数忽略返回码导致的错误,我相信你也遇到过。正是在这个错误模型的开发中,我的团队遇到了一些令人大开眼界的问题。例如,当我们将微软的语音服务器移植到Midori时,我们发现80%的繁体中文(zh-tw)请求都失败了,但开发者并不会立即看到失败并对其进行处理,相反地,客户端会得到莫名其妙的回应。起初,我们认为这是Miodri的问题,但后来我们在原始代码中发现了一个悄悄忽略掉的HRESULT。一旦我们把它移植到Midori上,这个bug就立刻出现在我们的眼帘,被找到并在移植后立即得到了修复。毫无疑问地,这样的经验告诉了我们对错误码的上述看法。

令我惊讶的是,Go将未使用的import当成错误对待,但却错过了这个更为关键性的错误,而它们仅仅一步之遥!

没错,你可以增加静态分析检查器,或者像大多数商业C++编译器那样增加“未使用的返回值”等警告方式。 但是,一旦你错过了将其添加到语言核心并作为一项要求的机会,开发者处于对烦人的分析和警告的抱怨,这些技术都不会起到关键的作用。

不管值不值得,在我们的语言中忘记使用返回值会造成编译时错误,因此你必须显式地忽略它们。在早先时候,我们用API实现这个功能,但最终将其设计成语言的语法部分,其功能相当于>/dev/null

ignore foo(); 

虽然我们未选择使用错误码进行错误处理,但无法意外地忽略返回值对于系统的整体可靠性来讲非常重要。试想你多少次在调试一个问题时,才发现其根本原因是忘记使用返回值,更有甚者因为此问题造成了安全漏洞。当然,因为程序员依然可以做坏事,所以让他们使用ignore并不是完全防弹的方法,但它至少是显式的和可审计的。

编程模型的可用性

在具有错误码方式的基于C的语言中,如果在任何的函数调用位置进行检查,那么会导致大量手动的if语句的检查。尤其是如果大量函数调用失败是很常见的情况时,这可能会特别繁琐,因为在C语言的程序中,分配失败也需要通过返回码来交互。另外,多值返回也是一件很笨拙的事情。

警告:这样的抱怨其实是主观的,在许多方面,返回码的可用性实际上是优雅的。你可以重用非常简单的原语——整型,return和if分支,并在无数其他情况下使用。在我看来,错误也是编程的足够重要的一个方面,语言应该需要在这方面对你有所帮助。

Go有很好的语法快捷方式,能够稍微更愉快地对标准的返回码进行检查:

if err := foo(); err != nil {
    // 处理错误 
} 

注意,我们在一行中调用了foo并在检查错误是否为非nil,这种方式相当简约。

然而,可用性的问题并不止于此。

通常,对于给定函数中的许多错误应该共享一些恢复或补救的逻辑代码,许多C程序员使用标签和goto语句来构建这样的代码,比如说:

int error;

// ...

error = step1();
if (error) {
    goto Error;
}

// ...

error = step2();
if (error) {
    goto Error;
}

// ...

// 函数正常退出 
return 0;

// ...
Error:
// 错误相关的处理 
return error; 

不用说,这是只有妈妈才会爱的代码。

在D、C#和Java等语言中,可使用finally块对这种“在作用域退出之前”的模式进行更直接地编码,同样地,即使没有完全采用RAII和异常的方式,微软也对C++提供了专有扩展__finally。D提供scope,Go提供了defer,所有这些都有助于彻底消除goto Error的模式。

接下来,想象一下函数是如何返回真实的值可能的错误码的?由于我们已经破坏了返回槽,所以有两种明显的可能做法:

  1. 可以将返回槽用于两个值中的一个(通常是错误码),而另一个槽,比如指针参数的形式,用于两者中的另一个(通常是实际值),这也是C中的常用做法;
  2. 可以返回一个同时在结构中具有两者的数据结构。在后文中我们将会看到,这种做法在函数式语言中相当常见,但是对于C或Go语言,由于缺少参数多态,会丢失返回值的类型信息,所以这种方式不太常看到。当然由于C++添加模板,因此原则上它也可以做到,但另一方面C++也同时增加了异常机制,所以返回码在生态系统上是匮乏的。

为了支持上面的性能要求,想象一下这两种方法对程序生成的汇编代码有何影响。

从“侧面”返回值

第一种方法在C中的例子如下所示:

int foo(int* out) {
    // <在这里尝试操作>
    if (failed) {
        return 1;
    }
    *out = 42;
    return 0;
} 

真正的返回值必须“在侧面”(在本例中通过指针参数)返回,使得调用变得十分笨拙:

int value;
int ret = foo(&value);
if (ret) {
    // 出现错误,对其进行处理 
}
else {
    // 使用返回值...
} 

除了变得笨拙之外,这种模式还会扰乱编译器的定义赋值分析,削弱编译器对使用未初始化值等情况发出良好警告的能力。

归功于多值返回,Go还可以通过更好的语法来解决这个问题:

func foo() (int, error) {
    if failed {
        return 0, errors.New("Bad things happened")
    }
    return 42, nil
} 

这使得调用点变得更加整洁,结合先前提到的单行if检查错误(一个微小的改变,因为乍一看返回的value不在作用域内)的特性,它确实成为更好的处理方式:

if value, err := foo(); err != nil {
    // 发生错误并处理它 
} else {
    // 使用值 ...
} 

注意这也有助于提醒你对错误进行检查。然而,它也不是完全没有问题,因为函数可以仅仅返回错误码,此时忘记对其进行检查就像在C中一样的容易。

正如我上面提到的,有些人会在可用性上反驳我,我想对于Go的设计师来说,更是如此。因为Go使用错误码的一个巨大吸引力是,这是当今对于过于复杂的语言的一种反叛,我们已经失去了很多C语言的优雅特性——通常情况下,你可以查看任何一行C语言代码并猜测它编译后的机器代码。我不反对这种观点,事实上,我喜欢Go的模型胜过非检查性异常或Java中的检查性异常的化身,并且即使是在我最近写这篇文章的时候,也写了很多Go代码。我看着Go的简单性并问自己,我们是否在所有的tryrequires等你将很快会看到的内容上的走得太远?我不确定。Go的错误模型往往是该语言最具分裂性的方面之一,这很大程度上是因为你不能像大多数语言那样草率地对待错误,但程序员确实喜欢在Midori中编写代码。最后,很难对它们进行比较,但我确信两者都可以用来编写可靠的代码。

从数据结构中返回

函数语言通过将值或错误的可能性打包到单个数据结构的方式解决许多可用性上挑战,因为如果你想对调用点上的返回值做任何有意义的事情,就不得不从返回值中挑出错误。这要归功于数据流风格的编程,使得很容易避免遗忘检查错误这种杀手级的问题。

有关此方法的现代语言示例,请查看Scala种的Option类型。但不幸消息是,有些语言,例如ML系列中的语言,甚至是Scala(由于其JVM的血统),将这种优雅的模型与非检查性异常方法混合在一起,从而玷污了monad数据结构方法的优雅性。

Haskell做的事情更酷,在仍然使用错误值和局部控制流的前提下,提供了类似异常处理的错觉

C++程序员之间就异常还是错误返回码是正确的方式上存在长期的争议。Niklas Wirth认为异常是goto方式的转世,因此在他的语言中消除了这种方式,Haskell以更圆滑的方式解决了这个问题:函数返回错误码,但错误码的处理不会让代码变丑。

这里的技巧是使用monad而不是控制流,以支持所有熟悉的throwcatch模式。

虽然Rust也使用错误码方式,但它也采用函数式错误类型的风格。例如,假设我们在Go中编写了一个名为bar的函数,它调用foo,并只是在调用出错时才将错误传播给调用者:

func bar() error {
    if value, err := foo(); err != nil {
        return err
    } else {
        // 使用值...
    }
} 

而Rust中的速记版本就不那么简洁。虽然这种外来的模式匹配语法(一个关注点而不是问题的死结)可能会让C程序员发昏,然而,任何对函数编程感到满意的人可能甚至都不会眨眼,并且这种方法毫无疑问地会对错误处理进行提醒:

fn bar() -> Result<(), Error> {
    match foo() {
        Ok(value) => /* 使用值... */,
        Err(err) => return Err(err)
    }
} 

但这种方式还可以变得更好,Rust具有try!,可以将上述写法减少到单行的表达式:

fn bar() -> Result<(), Error> {
    let value = try!(foo);
    // 使用值...
} 

这种方式提供了在平衡各种因素下的最佳位置。虽然它确实会遭遇到我之前提到的性能问题,但在所有其他方面都已经做得很好,但仅性能这一点,它便不是完整的解决方案。所以为此我们需要包含对快速失败(也就是Abandonment)的处理,正如我们将会看到的,它远远优于任何当今广泛使用的其他基于异常的模型。

异常

异常的历史是令人着迷的。在错误模型的研发过程中,我花了无数时间来回顾行业在这个领域的发展,其中包括阅读的一些原始论文,如Goodenough在1975年经典文章,Exception Handling: Issues and a Proposed Notation。除此之外,还研究了几种语言的方法:Ada,Eiffel,Modula-2和3,ML以及给人以灵感的CLU。许多论文比我能总结的漫长而艰辛之旅做得更好,所以我不将不在此赘述,相反,我会专注于对于构建可靠系统来讲,哪些工作是有效以及哪些是无效的。

在开发错误模型时,可靠性在我们上述的要求中是最重要的,如果无法对故障做出适当的反应,那么根据定义,系统将不会非常可靠。而通常来说,操作系统需要就是可靠性,但可悲的是,最常见的模型——非检查性异常(unchecked exception),在这个维度上是做的最差的。

出于以上原因,大多数可靠系统使用返回码而不是异常模型,因为错误码使得,在局部进行分析并决定如何最好地对错误条件进行处理成为可能。但是我说过头了,让我们继续深入下去。

非检查性异常

让我们来快速回顾一下,在非检查性异常模型中,代码throwcatch异常,但异常不是类型系统或函数签名的一部分。例如:

// Foo抛出一个未处理的异常: 
void Foo() {
    throw new Exception(...);
}

// Bar调用Foo,并处理该异常: 
void Bar() {
    try {
        Foo();
    }
    catch (Exception e) {
        // 处理错误 
    }
}

// Baz也调用Foo,但不处理该异常: 
void Baz() {
    Foo(); // 让错误逃逸到Baz的调用者 
} 

在这个模型中,任何函数调用(有时是任何语句)都可以抛出异常,并将控制权转移到其他非局部的位置。那么到底转移到何处?没有人知道。因为没有注解或类型系统的制品能辅助你的分析,因此任何人都很难推断程序在抛出时的状态,和异常被捕获或保持未处理时的最终状态。因为当异常在调用栈中向上传播,甚至在并发程序中以跨线程的方式传播时,程序的状态都可能发生更改。

当然这也是可以尝试的,为此需要阅读API文档,对代码进行手动审计,并严重依赖于代码审查以及良好的运气,但语言对此没有提供任何帮助。 因为程序中的故障总是少数,所以这并不像听起来那样完全是灾难性的。我的结论是,这就是为什么业内很多人认为非检查异常已经“足够好”,他们会为了通常的成功路径而避免引入其它的干扰,因为大多数人都不会在非系统程序中编写强大的错误处理代码,而抛出异常通常会让你快速逃离错误引起的困境。捕捉错误然后继续运行通常也是有效的,没有造成伤害,没有不合规定。因此从统计上讲,程序“能够工作”。

也许这种统计的正确性对于脚本语言是可行的,但对于操作系统的最低层次或任何任务关键的应用程序和服务而言,这不是一个合适的解决方案。我希望对此是没有争议的。

在.NET中,由于异步异常的存在,使得情况更加糟糕。C++也有所谓的“异步异常”:由硬件故障触发的故障,例如访问违例。然而,它在.NET中,它却显得非常令人讨厌,因为任意线程几乎可以在代码中的任何一处注入失败,即使在赋值语句的RHS和LHS之间也是如此! 因此,源代码中看起来像原子的操作,在实际中却并不如此。我在10年前写过关于此的文章,尽管现在已普遍意识到.NET的线程中止是有问题的并使得这种风险减弱,但挑战实际上仍然是存在的。新的CoreCLR甚至缺少AppDomains,并且在ASP.NET Core 1.0中,栈肯定不会像过去那样使用线程中止,但它们的API仍然存在

有一个关于C#的首席设计师Anders Hejlsberg的著名采访,名为The Trouble with Checked Exceptions。从系统程序员的角度来看,大部分的内容都会让你感到困惑,但有一点却是可以理解的,没有声明能够肯定C#的目标客户是超过如下内容的快速应用程序开发者:

Bill Venners:但是你不是在这种情况下破坏他们的代码,即使是在缺乏检查性异常的语言中也是如此么? 如果foo的新版本将抛出一个应该处理的新异常类型,那么他们的程序是不是仅仅因为在编写代码时没有预料该异常而导致崩溃呢?

Anders Hejlsberg:不会,因为在很多情况下,人们并不关心,他们不会处理任何这些异常情况。在他们的消息循环中有一个底层的异常处理程序,该处理程序只是打开一个对话框,说明出了什么问题然后继续执行。程序员通过在任何地方编写tryfinally来保护他们的代码,因此如果发生异常他们将正确退出,但他们实际上对异常的处理并不感兴趣。

这让我想起了Visual Basic中的On Error Resume Next,以及Windows Forms自动捕获并透明处理应用程序抛出的错误,然后尝试继续执行的方式。 我在这里并不责怪Anders的观点,由于C#的受欢迎程度,我确信这是当时大背景下的正确选择,但这肯定不是编写操作系统代码的正确方法。

C++至少尝试过使用它的throw异常规范 提供比非检查性异常更好的方式。 但不幸的是,这个功能依赖于动态增强(dynamic enhancement),凭这一点便宣告了它的失败。

如果我写一个函数void f() throw(SomeError)f的函数体仍然可以自由地调用抛出除SomeError之外的异常的函数;类似地,如果我声明f不会抛出异常,但使用void f() throw()仍然可以调用抛出异常的函数。因此,为了实现所述的合约,编译器和运行时必须确保,如果发生这两种情况,则需调用std::unexpected杀死进程以作为响应。

我不是唯一认识到这种设计是错误的人,事实上,throw已经遭到弃用。一篇详细的WG21论文,Deprecating Exception Specifications,阐述了C++是如何沦落于此的,在这里我提供了此文的开场白:

事实证明,异常规范在实践中几乎毫无价值,反而还为程序增加了可见的开销。

作者列举了弃用throw的三个原因。这三个原因中的两个是动态选择的结果:运行时检查(及其关联的opaque故障模式)和运行时性能开销。第三个原因是,虽然泛型代码中缺乏合成,但可以使用适当的类型系统来解决(当然这也是有开销的)。

但最糟糕的是,这种方法依赖于另一个动态强制构造——noexcept分类符,在我看来,这种解决方案与问题本身一样糟糕。

“异常安全性(Exception safety)”是C++社区中经常讨论的一种实践方法。这种方法巧妙地从调用者关于故障,状态转换和内存管理的视角对函数的意图进行分类。函数被分类为四种类型之一:“no-throw”意味着向前执行的程度得到保证且不会出现异常;“strong safety”意味着状态转换以原子的方式发生,而故障不会导致部分提交的状态更改或不变量被破坏;“basic safety”意味着,虽然函数可能发生部分提交的状态更改,但不会破坏不变量并能防止内存泄漏;最后,“no safety”意味着一切皆有可能,无任何保障。这种分类法非常有用,我鼓励任何人对错误行为采取有意和严谨的态度,无论是使用这种方法还是其它类似的方法,即使是你使用的是错误码方式。

但问题是,除了叶节点数据结构调用一组小且易于审计的其他函数之外,在使用非检查性异常的系统中遵循这些指南基本上是不可能的。试想一下:为了保证各处的安全性,你需要考虑所有函数调用的可能性,并相应地对周围的代码进行保护。这意味者要么进行防御性编程(Defensive programming),要么信任所调用函数的(无法被计算机检查的)文档,要么只调用noexcept函数而走运,要么只是希望最好的情况出现。多亏有了RAII,免于泄露的基本安全性变得更容易实现(由于智能指针的出现,现在这些安全也变得很常见),但破坏不变量这一点却也很难避免。“Exception Handling: A False Sense of Security”一文对此进行了很好地总结。

对于C++而言,真正的解决方案很容易猜到,而且非常简单,那便是对于健壮的系统程序,不要使用异常处理。这是嵌入式C++的常用做法,除此之外还有包括NASA喷气推进器实验室在内的C++的大量实时和任务关键指南也建议这么做。因此,火星上的C++代码肯定不会很快地使用上异常

所以如果你可以安全地避免使用异常并坚持使用C++中的类C的返回码方式,那么问题又出在何处呢?

问题出在,整个C++生态系统都在使用异常。为了遵守上述指导原则,你必须避免使用该语言的相当一部分特性。但事实证明,这些重要特性是库生态系统的重要组成部分。想使用标准模板库?太糟糕了,它使用了异常,想使用Boost?太糟糕了,它也使用异常。你的内存分配器也可能会抛出bad_alloc异常等等。甚至会导致创建现有库无异常处理分支一样的神经错乱做法,例如,Windows内核有自己的STL分支,但它不使用异常处理,但这种生态系统的分叉既不愉快也不实用。

这种混乱让我们陷入了困境,特别是因为许多语言使用非检查性异常。很明显,它们不适合编写低层次的可靠的系统代码(我如此会直截了说出来,肯定会招惹几个来自C++的反对者)。在为Midori编写多年代码后,让我回去编写使用非检查性异常的代码,会让我欲哭无泪,即使仅仅对代码进行审查也是一种折磨。但“幸运的是”,我们已经有了来自Java的检查性异常用于学习和借鉴……不是吗?

检查性异常

检查性异常就像,几乎每个Java程序员以及近距离观察过Java的人都喜欢拍打的布娃娃。在我看来,将它与非检查性异常的混乱进行比较是不公平的。

在Java中,因为方法必须进行如下申明,所以你知道大多数方法可能会抛出什么类型异常:

void foo() throws FooException, BarException {
    ...
} 

那么,现在调用者便知道调用foo可能导致抛出FooExceptionBarException类型的异常。因此在调用点中,程序员必须如下决定:1)按原样传播抛出的异常;2)捕获并处理它们;或者3)以某种方式转换抛出的异常类型(甚至可能是“完全遗忘掉”异常类型)。例如:

// 1) 按原样传播异常: 
void bar() throws FooException, BarException {
    foo();
}

// 2) 捕捉并处理它们: 
void bar() {
    try {
        foo();
    }
    catch (FooException e) {
        // 处理FooException的错误情况 
    }
    catch (BarException e) {
        // 处理BarException的错误情况 
    }
}

// 3) 转换抛出的异常类型: 
void bar() throws Exception {
    foo();
} 

这与我们可以使用的东西越来越接近,但它在某些情况下也会失效:

  1. 异常用于传递不可恢复的错误,如null解引用,除零等;
  2. 由于RuntimeException的存在,实际上并不知道可能抛出的所有内容,因为Java对所有错误条件使用异常,甚至对程序中的bug也是如此。设计师也意识到人们会对所有这些异常规范感到厌烦,因此他们引入了一种非检查性异常。也就是说,一个方法可以在不声明的情况下抛出这种异常,使得调用者可以无缝地调用它。
  3. 虽然签名声明了异常类型,但在调用点没有迹象表明调用可能会抛出什么类型的异常;
  4. 人们讨厌这种方式。

最后一项是很有趣的,稍后我将在描述Midori所采用的方法时再回头来看看。总而言之,人们对Java中检查性异常的厌恶主要源于上面其他三项,或者至少在其他三项上得到了增强。由此产生的模型似乎是两种方式之间最糟糕的:它无法帮助你编写完全免于错误的代码,同时也难以实用。最终,你在代码中写下了很多莫名其妙并且几乎没有什么好处的语句。另外,对接口进行版本控制也是一件很痛苦的事。正如我们稍后将会看到的,我们本来可以做得更好。

该版本控制点是值得深思的,如果你坚持使用单一类型的throw,那么其版本控制问题并不比错误码更糟糕,函数要么失败或成功。确实,如果你将API的第一版设计为无故障的模式,然后想要在第二版中添加故障的抛出代码,那么事情就被你搞砸了,在我看来,这种情况可能就会发生。API的故障模式是其设计和与调用者之间的合约的关键部分,正如你不会在调用者未知的情况下静默更改API的返回类型一样,你也不应该以语义上有意义的方式更改其故障模式。稍后将对这个有争议的问题进行讨论。

在CLU中有一种有趣的方法,正如Barbara Liskov在1979年的“Exception Handling in CLU” 一文中所描述的那样。我注意到他们非常关注于“语言学”,换句话说,他们想要一种人们喜欢的编程语言:在callites上检查和重新传播所有错误的感觉更像是返回值,但编程模型对我们现在所知的异常有更丰富和略微声明式的感觉。最重要的是,signal(它们现在的名字式throw)是检查性的,并且如果发生意外signal,还有方便的方法来终止整个程序。

异常的中普遍存在的问题

大多数的异常系统,无论是检查性的还是非检查性的,都会出现一些普遍的问题。

首先,抛出异常开销通常非常大,这基本上总是由收集堆栈跟踪(stack trace)所引起的。在托管系统中,收集堆栈跟踪还需要对元数据进行搜索,以创建函数符号名称的字符串,但是,如果错误得以捕获并处理,你甚至不需要在运行时获取这些信息;诊断能够在日志记录和诊断基础设施中更好地被实现,而不是在异常系统本身中,而上述这些关心点又是正交的。但为了真正取得上述的诊断要求,某些系统需要能够恢复堆栈跟踪;永远不要低估printf调试的强大能力以及堆栈跟踪对此的重要性。

其次,异常会严重影响代码质量。我在上一篇文章中谈到了这个主题,并且有C++环境中关于该主题很好的论文。静态类型系统信息的缺乏使得很难在编译器中对控制流进行建模,从而导致优化器变得过于保守。

大多数异常系统出问题的另一个方面是鼓励对错误进行粗粒度的处理。返回码的支持者喜欢将错误处理本地化为特定的函数调用(,而我也是这样做的)。但在异常处理系统中,很容易在一些巨大的代码块周围加上粗粒度的try/catch块,而不会仔细地对单个故障做出反应。这种方式产生的脆弱代码几乎肯定是会出错的。如果不是今天,那么沿着这条路走,不可避免的重构必将会发生,因此,这与拥有正确的语法有很大程度的关系。

最后,throw的控制流通常是不可见的,即使是使用Java的注解方法签名的方式,也无法对代码体进行审计并准确查看异常的来源。静默的控制流与gotosetjmp/longjmp一样糟糕,并且使编写可靠代码变得非常困难。

我们身在何处?

在继续前行之前,让我们回顾一下我们所处的位置:

如果我们可以带走所有的“Goods(好家伙)”,并舍弃“Bads(坏家伙)”和“Uglies(丑家伙)”,那不是一件很好的事吗?

仅次一点就是向前迈出的一大步,但这还远远不够,这让我想起了我们首个影响未来一切的“欢呼”时刻。对于一大类的错误,表中的所有这些方法都合适!

Bug不是可恢复的错误!

我们早期做出的一个重要区分,是可恢复错误和bug之间的区别:

  • 可恢复的错误通常是程序化的数据验证的结果,一些代码对上下文的状态进行了检查,并认为这种状态对于继续执行是不可接受的。也许是正在解析的标记文本、来自网站的用户输入或易失网络的连接故障等,在这些情况下,程序期望能够正常恢复。编写此代码的开发者必须考虑在发生故障时该怎么做,因为无论你怎么编写代码,这些情况都会在构造良好的程序中发生。响应的方式可能是将情况传达给终端用户、重试或完全放弃的操作,但这是一种可预测的,经常是计划内的情况,尽管它被称为“错误”。
  • bug是程序员没有意料到的一种错误,这包括输入未正确验证、逻辑的编码错误或出现任何问题。这些问题通常甚至不能被及时发现,它的“次要影响”需要一段时间才能间接地观察到,而此时可能会对程序的状态造成重大破坏。因为开发者没料到会发生这种情况,所以一切都结束了。此代码可访问的所有数据结构都是被怀疑的对象,而且因为这些问题不一定能及时被发现,事实上,更多的部分将是不可信的。根据语言的隔离保证,可能使得整个进程都受到了污染。

这种区分至关重要。但令人惊讶的是,大多数系统都没有做到这一点,至少不是以一种原则性的方式进行区分!如上所述,Java,C#和动态语言仅使用异常,而C和Go仅使用返回码。 C++根据受众的不用使用两者混合的方式,但通常的情况是项目选择其中之一,并在代码的任何地方都这样使用。但你通常不会听到某种语言建议使用这两种不同的错误处理技术。

鉴于bug本质上是不可恢复的,我们没有试图对其进行try捕捉。在运行时检测到的所有bug所导致的后果,在Midori的术语中被称为Abandonment(放弃),也就是所谓的“快速失败”

上述每种语言都提供类似Abandonment的机制:C#有Environment.FailFast,C++有std::terminate,Go有panic,以及Rust有panic!等等。每一方式都突然且迅速地销毁掉上下文环境,而此上下文的范围则取决于系统,例如,对于C#和C++来说是终止进程,对于Go来说是Goroutine,而对于Rust来说则是当前线程,并可选择地附加一个panic处理程序来对进程进行抢救。

虽然我们确实以比一般的更有纪律和无处不在的方式使用Abandonment,但我们当然不是第一个认识到这种模式的团队。这篇Haskell文章非常清楚地表达了这种区别:

我参与了用C++编写的库的开发。其中一位开发者告诉我,开发者可以分为喜欢异常和喜欢返回码两种类型。在我看来,使用返回码的朋友赢了。但是,我得到的印象是他们论点是错误的:异常和返回代同有同样的表达能力,但这并不适用于描述bug。实际上,返回码包含ARRAY_INDEX_OUT_OF_RANGE等定义,但我想知道:当程序从子程序获得该返回码时,它将如何对其作出反应?它应该向程序员发送邮件告知吗?它可以依次将此错误码返回给其调用者,但它们其实也都不知道如何对其进行处理。更糟糕的是,由于我不能对函数的实现做出假设,所以不得不认为每个子程序都有可能返回ARRAY_INDEX_OUT_OF_RANGE。我的结论是ARRAY_INDEX_OUT_OF_RANGE是一个(编程性)的错误,无法在运行时对其进行处理或修复,只能由其开发者来修复。因此,这不应该有这样的返回码存在,而是应该采用断言的方式

放弃细粒度的可变共享内存作用域是不可信的,比如Goroutine、线程或其他方式,除非你的系统以某种方式保证潜在的内存破坏范围。但是,很不错的是这些机制是存在的并可以被我们所利用!这也就意味着在这些语言中使用Abandonment机制确实是有可能的。

然而,它还必须具有大规模成功所必需的架构元素。我相信你在想“如果我每次在我的C#程序中进行null解引用时都会抛出整个进程,那么我的一些客户会非常的生气”,和“这根本不可靠!”等相似的想法。事实证明,可靠性可能与你的想法有所不同。

可靠性,容错和隔离

在我们进一步讨论之前,我们需要先表达中心信念:故障会发生。

构建可靠的系统

这里的常识是通过系统地保证故障永不发生的方式,来构建一个可靠的系统。直观上来说,这是很有道理的,但有一个问题:在极限的情况下,这是不可能的。如果你可以单独花费数百万美元来获得这样的可靠性,就像许多任务关键的实时系统一样,那么会给你留下深刻的印象。也许使用像SPARK这样的语言(一组基于合约的Ada扩展)来形式化证明每行代码的正确性。然而,经验表明即使这种方法也不是万无一失的。

我们接受而不是反对这一事实。但如果显然试图在所有可能的情况下消除失败,那么错误模型必须使它们透明且易于处理。更重要的是,系统被构建为即使单个部件出现故障,整个系统仍然可以正常运行,然后再指导系统优雅地恢复那些故障部分。这种原则在分布式系统中是众所周知的,那为什么它又是新颖的呢?

处于这一切的中心的操作系统,只是协作进程们的分布式网络,就像微服务的分布式集群或互联网本身,它与这些系统的主要区别在于延迟,和可以建立什么样的信任程度以及达到的难度如何,以及关于位置,身份的各种假设等。但高度异步,分布式和I/O密集型系统中的故障必然会发生。对此,我的看法是,很大程度上是因为宏内核的持续成功,使得整个世界还没有跃升到“作为分布式系统的操作系统”的洞察力。但是,一旦你这么做了,很多的设计原则就会变得明显起来。

与大多数分布式系统一样,我们的架构假设进程失败是不可避免的,尽管我们花了很长时间来防止级联故障发生,定期日志记录,以及实现程序和服务的可重启性。

当你如此假设时,便会以不同的方式来构建整个系统。

特别地,隔离至关重要,Midori的进程模型有助于轻量级细粒度的隔离,因此,程序和现代操作系统中通常称为“线程”的构造是独立的孤立实体。对一个这样的系统中的网络连接失败进行保护比在地址空间中的共享可变状态下要容易得多。

隔离同样也有助于简单性,Butler Lampson的经典文章“Hints on Computer System Design”探索了这个主题。我一直很喜欢Hoare的这句话:

可靠性的无法避免的代价是简单性(C. Hoare)。

通过将程序分解成更小的部分,每个部分都可以自行失败或成功,使得其中的每个状态机都能保持为更简单的形式,因此使得从故障中恢复变得更加容易。在我们的语言中,可能的失败点是明确的,从而有利于进一步保持这些内部状态机的正确性,并指明了与混乱的外部世界的接口。在这个世界上,单个个体失败的代价并不是那么可怕,所以我不能过分强调这一点。如果没有廉价和永远在线的隔离的架构基础,我后面描述的语言功能都不会很好的工作。

Erlang非常成功地以一种基础的方式将这样的属性构建到语言中。它与Midori一样,利用通过消息传递连接的轻量级进程并鼓励容错架构等措施来实现。它采取的一种常见的模式是“监视器”模式:其中一些进程负责监测环境,并在其他进程发生故障时重启这些进程。这篇文章在明确“让它崩溃(let it crash)”的哲学理念,和关于在实践中构建可靠的Erlang程序的推荐技术上,做了很不错的工作。

因此,关键不在于要防止失败本身,而是要知道如何以及何时处理它们。

一旦你构建了这种架构,你就会战胜它们并确保系统的运行。对我们来说,这意味着为期一周的压力运行:进程不断启动和中止,有些是由故障造成的,并同时确保整个系统良好地前进。这让我想起了像Netflix的Chaos Monkey这样的系统,它会随机终止集群中的某些机器的运行,以确保整个服务保持健康状态。

随着更多的向分布式计算的转变不断发生,我期望更多的系统采用这种理念。例如,在微服务集群中,单个容器的故障通常由封闭的集群管理软件(如Kubernetes,Amazon EC2 Container Service和Docker Swarm等)无缝地处理。因此,我在本文中描述的内容可能有助于编写更可靠的Java,Node.js/JavaScript,Python甚至是Ruby服务。不幸的是,你很可能会与所使用的语言不断抗争来实现这样的目标,因为很多进程的代码在出现问题时也会万分努力地艰难执行。

Abandonment

即使进程是轻量级、隔离且易于重新创建的,你仍然有理由认为在面对错误时放弃整个进程是一种过度的反应。那么下面让我试着来说服你。

当你尝试构建一个健壮的系统时,在运行过程中遇到bug是危险的。如果程序员没有预料到会出现特定的情况,没人知道代码下次是否还会再做正确的事情。关键的数据结构可能在不正确的状态下被丢弃,一个极端(也可能是稍微有点愚蠢)的例子是,一个用于银行业务的例程,其本来目的是将你的存款数字向舍入现在则可能变成了向舍入。

你可能会试图将Abandonment的粒度减少到比进程更小的范围里,但这种方式是很棘手的。举一个例子来说,假设你的进程中的一个线程遇到了一个bug,并且执行失败,而且这个bug可能是由存储在静态变量中的某些状态所触发的。即使其他线程似乎看起来不受导致故障的条件的影响,但你也无法对此确定。除非你的系统有一些属性,被你的语言隔离,隔离暴露给独立线程或其他地方的对象根集合,那么最安全的做法是,假设除了把整个地址空间全部销毁之外的任何操作都是有风险和不可靠的。

由于Midori进程的轻量级特性,放弃进程更像是在经典操作系统中放弃单个线程而不是整个进程。但我们的隔离模型让我们可靠地做到了这一点。

我承认作用域主题是个滑坡谬误:也许环境中所有的数据都已经被破坏了,那么你怎么知道中止掉这个进程就足够了?!这里有一个重要的区别,也就是进程的状态在设计上是瞬态的。在一个设计良好的系统中,它可以被丢弃并随意地被重建。没错,一个bug会对持久性状态造成破坏,但是你手头上有一个更大的麻烦,这个麻烦必须以不同的方式进行处理。

对于某些背景,我们可以考虑容错的系统设计。Abandonment(快速失败)已经是该领域的常用技术,我们可以将我们对这些系统的大部分知识应用于普通程序和进程,也许最重要的技术是定期记录和对宝贵的持久性状态做快照。Jim Gray在1985年的论文,“Why Do Computers Stop and What Can Be Done About It?”,很好地描述了这个概念。随着程序持续地向云平台迁移,并且激进地分解为更小的独立服务的趋势,瞬态和持久状态的这种明确区分甚至变得更为重要。由于这种软件编写方式的转变,在现代架构中Abandonment比以前更容易实现。实际上,放弃可以帮助你避免数据被损坏,因为在下一个快照之前检测到的bug可以防止错误状态的逸出。

Midori内核中的bug处理方式也有所不同,因为微内核中的bug与用户进程中的bug就完全不同。它可能造成的损害范围更大,因此最安全的反应是放弃整个“域”(地址空间)。值得庆幸的是,大多数你认为经典的“内核”功能——调度器、内存管理器、文件系统、网络堆栈甚至是设备驱动程序,都是在用户模式以隔离进程的方式运行,所以故障以如上所述的通常方式被限定在隔离的进程中。

Bug:Abandonment,断言和合约

Midori中如下的一些bug可能会导致Abandonment:

  • 不正确的强制类型转换;
  • 试图解引用null指针;
  • 试图越界访问数组;
  • 除零;
  • 意外的数学上/下溢;
  • 内存不足;
  • 栈溢出;
  • 显式的放弃;
  • 合约失败;
  • 断言失败。

我们的基本理念是:以上每种都是程序无法自动恢复的条件,让我们来依次讨论其中每一个。

普通的旧Bug类型

一些情形毫无疑问表明程序存在bug。

不正确的强制转换,试图对null指针解引用,数组越界访问或除零显然是程序逻辑的问题,因为它试图进行无法否认的非法操作。正如我们稍后将看到的,有一些是可以解决的(例如对于除零操作而言,你可能想使用NaN风格的传播),但默认情况下我们认为这是一个bug。

大多数的程序员都毫无疑问地愿意接受这一点,并且将它们以这种方式作为bug处理可将Abandonment带到内部开发循环中,从而有助于快速找到并修复开发过程中的bug。Abandonment确实有助于提高人们编写代码的效率,起初这对我来说是一个意外惊喜,但它确实是有道理的。

另一方面,其他的一些情况则是比较主观的。我们必须对这些情况的默认行为做出决定,这通常会引起争议,所以有时还需提供对程序的控制。

算术上/下溢出

如果说意外的算术上/下溢出是一种bug,这肯定是有争议的说法。然而,在不安全的系统中,这种情况经常导致安全漏洞,对此,我建议你打开国家漏洞数据库来看看这种类型漏洞的绝对数量

事实上,我们移植到Midori(并获得性能提升)的Windows TrueType字体解析器,仅在过去几年就遭受了十几次的上/下溢出(解析器往往是像这样的安全漏洞的发生地)。

这就产生了像SafeInt这样的软件包,它基本上使你远离了原生的算术运算,转而使用检查性的库来实现。

这些漏洞中的大多数当然还伴随着对不安全内存的访问,因此,你可以有理由的认为,溢出在安全语言中是无害的,所以应该允许出现。然而,基于安全上的经验,很明显的是,程序在面临意外的上/下溢时经常会做错事。简单地说,开发者经常忽略溢出的可能性,使得程序继续执行计划之外的事。而这恰好是Abandonment试图捕捉的bug所定义的内容。关于这一点的最致命一击是哲学上的,当有任何关于正确性上的问题时,我们倾向于在明确的意图方面犯错误。

因此,所有未注解的上/下溢出都被视为bug并会导致Abandonment。除了我们的编译器会激进地优化掉冗余检查之外,这基本上与使用/checked选项来编译C#程序相类似(因为很少有人考虑在C#中使用这个选项,所欲代码生成器在删除冗余的插入检查时几乎没有那么积极地处理)。多亏了这种语言和编译器共同开发的方式,其结果远远好于大多数C++编译器面对SafeInt算法时所生成的代码。与C#一样,unchecked修饰的范围构造 也可用于故意设定的上/下溢出的情况。

虽然大多数C#和C++开发者对我所说的这个想法最初反应都是负面的,但我们的经验是:10次中有9次,这种方法都有助于避免程序中的bug,而剩下的那一次通常是在我们72小时的压力运行(我们用浏览器和多媒体播放器,以及我们认为可以给系统加压的任何应用来测试系统) 的后来某个时间点上,因为一些无害的计数器溢出时发生的Abandonment。我们花时间修复这些而不是采用压力程序提升产品成熟度的经典方式——也就是所谓的死锁和竞争条件,这一点上我总是觉得是非常有趣的。在你我之间,我会将溢出采用Abandonment!

内存不足和栈溢出

内存不足(OOM)的情况总是很复杂,所以我们在这里的立场当然也是有争议的。

在手动管理内存的环境中,错误码风格的检查方式是最常用的方法:

X* x = (X*)malloc(...);
if (!x) {
    // 处理内存分配失败 
} 

这里的一个微妙的好处是:分配是痛苦的,并且需要思考,因此使用这种技术的程序在使用内存的方式上通常更加节俭和慎重。但它也有一个巨大的缺点:容易出错,并导致大量通常是未经测试的代码路径。当代码路径未经测试时,它们通常都会出错。

一般而言,开发者在系统处于资源枯竭的边缘时,非常努力地试图使他们的软件正常工作,但根据我使用Windows和.NET Framework的经验来看,这是一个令人震惊的错误。它会导致非常复杂的编程模型,比如.NET的所谓的约束执行区域(Constrained Execution Region)。如果一个程序艰难地运行着,即使是少量的内存也无法分配,那么这种情况很快就会成为可靠性的天敌,Chris Brumme奇妙的关于可靠性文章中描述了这种情形以及相关的挑战。

在某种意义上,我们系统的某些部分当然是“硬化的”,就像内核的最低层次一样,对这部分采用Abandonment其影响范围必然比单个进程要更宽,但我们也尽量保持这部分代码尽量的小。

对于系统其余部分呢?是的你猜对了——Abandonment,这很不错也很简单。

令人惊讶的是我们侥幸逃脱了多少这种方式,对此我将大部分原因都归结于隔离模型。实际上,由于资源管理的策略,我们可以故意让一个进程遭受OOM,然后对其中止的策略,并且仍然对建立在整体架构上稳定性和恢复保持信心。

如果你真的需要的话,可以选择对单个分配采取可恢复故障的方式,虽然这并不常见,但支持的机制是存在的。也许最激发积极性的例子是:假设你的程序想要分配1MB大小的缓冲区,这种情况与普通的1KB对象的分配是有所不同的。开发者可能已经思考并准备好应对,那些可能无法获得1MB大小的连续块内存的情况,并相应地对其进行处理。例如:

var bb = try new byte[1024*1024] else catch;
if (bb.Failed) {
    // 处理分配失败 
} 

栈溢出是这一理念的简单扩展,因为栈只是内存所支持下的一种资源。实际上,由于我们的异步链接栈模型,栈内存的溢出与堆内存的溢出在物理上是完全相同的,因此开发者对其处理方式的一致性并不令人惊讶。如今,许多系统也都以这种方式来处理栈溢出。

断言

断言是代码中的手动检查某些条件是否成立,如果不成立则触发Abandonment的机制。与大多数系统一样,我们同时具有调试(debug-only)版本和发布(release)版本的代码断言,但与大多数其他系统不同的是,我们在发布版本中的断言数量多于调试版本。事实上,我们的代码充满了断言,而大多数方法中存在多个断言。

这样做的理念是,在运行时找到bug比在遇到错误时继续运行更好,当然,我们的后端编译器也被实现为像其他方面一样激进地对断言进行优化。这种断言的密度水平类似于高可靠性系统的指导原则所建议的一样,例如,来自美国宇航局的论文,“The Power of Ten -Rules for Developing Safety Critical Code”是这样描述的:

规则:代码的断言密度应为平均每个函数最少两个断言。断言用于检查在现实生活中不应发生的异常情况。断言必须始终是无副作用的,并且应该定义为布尔测试。

理由:工业编码工作的统计数据表明,通常每写入10到100行代码,单元测试就会发现至少一个缺陷。而拦截缺陷的几率随着断言密度的增加而增加,断言的使用通常也被推荐为强防御性编码策略的一部分。

要表示断言,只需调用Debug.AssertRelease.Assert即可:

void Foo() {
    Debug.Assert(something); // 仅对调试版本的断言 
    Release.Assert(something); // 始终检查性的断言 
} 

我们还实现了类似于C++中的__FILE____LINE__宏的功能,以及谓词表达式文本的__EXPR__,因此由于断言失败而导致的Abandonment会包含有用的调试信息。

在早期,我们使用不同“级别”断言的方式。断言有三个级别,分别是Contract.Strong.AssertContract.AssertContract.Weak.Assert。最强的Contract.Strong.Assert级别意味着“始终检查”,中间的Contract.Assert级别意味这“是否检查取决于编译器”,最弱的Contract.Weak.Assert级别意味着“只在调试模式下检查”。我做出了有争议的决定,以放弃这种这种分类方式。事实上,我非常确定团队49.99%的成员绝对讨厌我所选择的术语(Debug.AssertRelease.Assert),但我总是喜欢这种方式,因为它们表示的意义非常明确。旧的分类方法的问题在于,没有人确切知道何时会检查断言,而在我看来,这个领域内的混乱根本是不可接受的,因为好的断言规则对程序的可靠性非常重要。

当我们将合约添加到语言中(很快会有更多的合约)时,我们也尝试将assert变成关键字。但是,我们最终转而使用API的方式,其主要原因是断言不像合约那样,它不是API签名的一部分;并鉴于断言可以很容易地作为一个库来实现,我们也不清楚加入到语言中能获得什么。此外,像“checked in debug”和“checked in release”之类的策略根本不像是编程语言特性,我承认,多年以后,我仍然对此持怀疑态度。

合约

在Midori中,合约是捕获bug的核心机制。尽管我们以使用了Spec# 变体Sing#的Singularity作为开始,但我们很快就转移到了普通C#并且不得不重新发现我们想要的东西。在与此模型打交道多年以后,我们最终以和开始时非常不同的模样作为结束。

由于我们的语言对不变性和副作用的理解方式,所有的合约和断言都被证明是无副作用的,这可能是语言创新的最大领域,所以我一定会尽快写一篇关于此的文章。

与其他地方一样,关于合约,我们也受到许多其他系统的启发和影响。 Spec#显然是其中之一,Effiel对我们也有很大的影响力,特别是因为他有许多已发表的案例研究可以学习,另外基于Ada的SPARK的相关研究工作以及实时和嵌入式系统的建议也是如此。像Hoare的公理语义这样的编程逻辑,深入研究理论上的未知领域,为所有这些打下了基础。然而,对我来说,最有哲学意义上的灵感来自CLU以及后来的Argus的整体错误处理方法。

前置条件和后置条件

最基本的合约形式是方法的前置条件,其申明了要指派的方法必须具备的条件,并通常用于验证参数。它有时也用于验证目标对象的状态,但这通常是不受欢迎的,因为对于程序员来说,形态是很难推算的。前置条件基本上是调用者向被调用者提供的一种保证。

在我们的最终模型中,使用requires关键字声明前置条件:

void Register(string name)
    requires !string.IsEmpty(name) {
    // 字符串不为空,并继续处理 
} 

一种稍微不太常见的合约形式是方法的后置条件,它表明在指派完方法之后保持何种状态,这是被调用者向调用者提供的一种保证。

在我们的最终模型中,使用ensure关键字声明后置条件:

void Clear()
    ensures Count == 0 {
    // 继续处理,并当函数返回时,调用者可以保证Count值是0 
} 

也可以通过特殊名称return来声明后置条件中的返回值,而旧的参数值,例如在后置条件中引用输入所必需的值,可以通过old(..)来捕获。比如说:

int AddOne(int value)
    ensures return == old(value)+1 {
    ...
} 

当然,前置和后置条件也可能是混合出现的,比如说下面是来自Midori内核中环形缓冲区的代码:

public bool PublishPosition()
    requires RemainingSize == 0
    ensures UnpublishedSize == 0 {
    ...
} 

此方法在知道RemainingSize的值为0时,可以安全地执行其函数体;而调用者知道UnpublishedSize也为0后,可以在被调用函数返回后安全地执行。

如果在运行时发现这些合约中任何一个是错误的,则会执行Abandonment操作。

该领域是我们与其他工作所不同之处。合约作为高级证明技术中使用的程序逻辑表达最近变得流行起来,这些工具通常使用全局分析来证明所述合约的真实性或虚假性。我们采取了一种更简单的方法:在默认情况下,合约在运行被时检查,但如果编译器可以在编译时证明其真或假,则可以自由地分别进行运行时检查或发出编译时错误。

现代编译器具有基于约束的分析,并在这方面已经做得很好,就像我在上一篇文章中提到的范围分析一样。分析器传播事实并使用它们来优化代码,优化包括消除冗余检查,在合约或正常程序逻辑中显式地编码。并且它们被训练地可以在合理的时间内执行这些分析,避免程序员因等待时间过长而切换到其他更快的编译器上。而定理证明技术根本无法满足在我们的规模上的需求,我们的核心系统模块使用最优秀定理证明分析框架,也花了一天的时间来对其进行分析!

此外,方法声明的合约也是其签名的一部分,这意味着它们会自动显示在文档和IDE的工具提示中,所以合约与方法的返回值和参数类型一样重要。合约实际上只是类型系统的扩展,使用语言中的任意逻辑来控制交换类型的形状,因此,所有通常的子类型要求对于它们都是适用的。当然,这有助于使用标准优化编译器技术在几秒钟内完成对局部分析的模块化。

.NET和Java中90%的典型异常用法都变成了前置条件。所有的ArgumentNullExceptionArgumentOutOfRangeException和相关类型,以及更重要的的人工手动检查和throw都消失了。如今,C#中的方法经常被这些检查所覆盖,仅在.NET的CoreFX代码仓库中就有数千个这样的检查。例如,下面是System.IO.TextReader中的Read方法:

/// <summary>
/// ...
/// </summary>
/// <exception cref="ArgumentNullException">如果buffer为null,则抛出该异常</exception> 
/// <exception cref="ArgumentOutOfRangeException">如果index小于零,则抛出该异常</exception>
/// <exception cref="ArgumentOutOfRangeException">如果count小于零,则抛出该异常</exception>
/// <exception cref="ArgumentException">如果index和cout超出缓冲区的范围,则抛出该异常</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的合约。其实际表达式也可供调用者来阅读,以及供工具来理解和利用,而不要求程序员用通俗语言来表达错误条件。另外,它也使用Abandonment方式来传递失败。

我还提到我们有很多合约辅助工具来帮助开发者编写常见的前置条件。上面的显式范围检查非常混乱,且容易出错,相反地,我们可以这样编写:

public virtual int Read(char[] buffer, int index, int count)
    requires buffer != null
    requires Range.IsValid(index, count, buffer.Length) {
    ...
} 

另外除了交互之外,还有两个高级的功能:数组作为切片(slice)和非零类型。我们可以将上面的代码简化到如下的形式,并同时保留相同的保证:

public virtual int Read(char[] buffer) {
    ...
} 

我们再向前迈了一步……

谦虚的开始

虽然我们提到了语法与Eiffel和Spec#的一样清晰明了,但正如我之前所提到的那样,我们真的不想在一开始就改变语言,所以实际上我们从一个简单的API方法作为开始:

public bool PublishPosition() {
    Contract.Requires(RemainingSize == 0);
    Contract.Ensures(UnpublishedSize == 0);
    ...
} 

这种方法存在许多问题,正如在.NET Code Contracts的努力已经汲取了这方面的教训。

首先,以这种方式编写的合约是API实现的一部分,但我们却希望它们成为签名的一部分,这似乎是一个理论上的问题,但它实际上远非理论上的。我们希望生成的程序包含内置的元数据,使得IDE和调试器等工具可以在调用点上显示合约,同时我们还希望工具能够从合约中自动生成文档。除非你之后以某种反汇编手段从方法中提取它们(这是一种黑客的行为),否则将它们隐藏在实现中是行不通的。

另外,这种方式也使得很难与后端编译器集成,而我们发现与后端的集成对于良好的性能是必要的。

其次,你可能已经注意到对Contract.Ensures的调用存在问题。由于Ensures意味着会保留函数的所有返回路径,那么我们如何将其仅仅实现为API的形式?答案是:这是做不到的。一种方法是在语言编译器生成代码之后重写生成的MSIL,但这非常麻烦。此时,你不禁开始怀疑,为什么不简单地承认这是语言表达性和语义问题,并添加相应的语法呢?

对我们来说,长期挣扎的另一个领域是合约是否是有条件的。在许多经典系统中,只需要检查调试版本中的合约,而对完全优化版本的合约则不检查。正如对前面的断言一样,在很长一段时间里,我们对合约也有三个相应的级别:

  • 最弱,由Contract.Weak.*表示,表示仅调试的合约
  • 正常,简单地用Contract.*表示,留给实现决定何时检查它们
  • 最强,由Contract.Strong.*表示,表示总是对其检查的合约

我承认,我最初认为这是一个优雅的解决方案。但不幸的是,随着时间的推移,我们发现开发者在调试,发布或上述所有内容中是否存在“正常”级别的合约时常常存在着混淆(因此人们经常会误用最弱级别和最强级别)。无论如何,当我们开始将这个方案集成到语言和后端编译器工具链中时,我们遇到了很多问题,所以不得不稍微把目标退后一点。

首先,如果你简单地将Contract.Weak.Requires翻译成weak requires,以及将Contract.Strong.Requires翻译成strong requires,那么在我看来,你最终得到一个相当笨重和专门化的语法,以及更多让我感到不舒服的策略。所以这种方式会立即让人呼吁参数化和weak/strong策略的可替代性。

接下来,这种方法引入了一种对我来说感觉很尴尬的新条件编译模式,换句话说,如果你想对仅调试版本进行检查,可以这样写:

#if DEBUG
    requires X
#endif 

最后,对我来说最后的一击是,合约应该被当作API签名的一部分。那么有条件的合约意味着什么?工具应该如何推理呢?为发布版本生成和调试版本不同的文档?而且只要存在这样的合约,就会失去如果不满足其前置条件则代码将无法运行的关键性保证。

最终,我们完成了整个条件编译的方案。

我们最终得到了单一类型的合约,它是API签名的一部分,并且在所有时刻都会被检查。如果编译器在编译时可以证明合约永远会得到满足(我们花了相当大的精力在这上面),那么完全可以免除检查,但是如果不满足其先决条件,则将保证代码永远不会执行。对于需要进行条件检查的情况,则始终有断言系统可以利用(如上所述)。

当我们部署这样的新模型时,我感到这样的方式更好。并且发现许多人因混淆而滥用上面的“weak”和“strong”概念,因此,迫使开发者做出决定给它们带来更好的代码。

未来的方向

当我们的项目结束时,许多领域的发展处于不同的成熟阶段。

不变量

我们在不变量上进行了很多的实验。每当我们与熟悉合约设计的人交谈时,他们都会对我们从第一天起就没有使用不变量而感到宽慰。说实话,我们的设计从一开始就包含了它们,但是却从来没有完成它的实现和部署,这仅仅是由于工程的产出量不足和一些困难的问题仍然存在所导致的。老实说,团队基本上满意于前置/后置条件和断言的结合,因此我怀疑在充足的时间里我们是否应完成不变量的实现。但到目前为止,我仍然对此有一些问题,所以我需要在行动中再看一段时间。

我们设计的方法是,invariant成为其封闭类型的成员。例如:

public class List<T> {
    private T[] array;
    private int count;
    private invariant index >= 0 && index < array.Length;
...
} 

请注意,invariant标记为private,不变量的访问性修饰符控制了需要保持不变性的成员。例如,public invariant变量只需要在具有public访问性的函数的进入和退出时保持不变,它允许private函数的常见模式暂时违反不变性,只要在public的入口点保持它们即可。当然,如上例所示,类也可以自由声明为private invariant,这需要在所有函数入口和出口保持不变性。

我其实很喜欢这样的设计,并且觉得它会有用。我们所关心的主要问题是在所有地方静默地引入检查。直到今天为止,这一点仍让我感到紧张,例如,在List<T>的示例中,你将在类型的每个函数的开头和结尾进行index > = 0 && index <array.Length的检查。现在,我们的编译器最终非常善于识别和合并冗余合约检查,并且在很多情况下,合约的存在实际上使代码质量更好。但是,在上面给出的极端例子中,我确信它会造成性能损失,因此会对我们改变检查不变量的策略施加压力,从而可能会使整体合约模型变得复杂。

我真的希望我们有更多的时间来更深入地探索不变量,我不认为团队严重错过它们,当然我没有听到太多抱怨缺失他们的声音(可能是因为团队非常注重性能),但我确实认为不变量会是合约系统上的闪光之处。

高级类型系统

我喜欢说合约始于类型系统触及不到之处。类型系统允许你使用类型对变量的属性进行编码,对变量可能包含的预期范围值进行限制。类似地,合约也对变量所持有值的范围进行检查。那么它们的区别在什么地方?类型在编译时通过严格且可组合的归纳规则得以验证,这些规则对于函数局部检查而言开销更小,但通常并不总是由开发者编写的注解所辅助。合约在可能的情况下在编译时进行证明,否则在运行时被证明,因此,合约被允许使用语言本身编码的任意逻辑进行远非严格的规范。

类型是一种可取的方式,因为它们保证在编译时进行检查,同时保证检查的快速。它给开发者的保证很强,使得使用它们的整体效率更高。

然而,类型系统的局限也是不可避免的。类型系统需要留下一些协调空间,否则它会迅速膨胀并且可用性很差,并且在极端情况下会转换为双值位和字节。另一方面,我总是对需要使用合约的两个特定的协调空间区域感到失望:

  1. 空值性;
  2. 数值范围。

我们大约90%的合约都属于这两者类型。因此,我们认真研究了更复杂的类型系统,以使用类型系统而不是合约的方式对变量的空值性和范围进行分类。

具体来说,这是与使用合约的代码之间的区别:

public virtual int Read(char[] buffer, int index, int count)
    requires buffer != null
    requires index >= 0
    requires count >= 0
    requires buffer.Length - index < count {
    ...
} 

下面这段代码在编译时静态检查,虽然不需要但仍然保留了所有相同的保证:

public virtual int Read(char[] buffer) {
    ...
} 

将这些属性放置在类型系统中可以显著地减轻错误条件检查带来的负担。我们说,对于任何给定的一个状态的生产者,都有10个对应的消费者,那么可以将责任推回到生产者身上,而不是让每个消费者自身来抵御错误条件。这么做只需要一个强制类型的断言,或者首先将值存储到正确的类型这种更好的方式。

非空类型

其中之一的非空性真的很难做到:保证静态变量不会使用null值,而这就是Tony Hoare的所谓的“十亿美元的错误”。解决这个问题对于任何语言来说都是一个正确的目标,我很高兴看到新的编程语言的设计师正在正面解决这个问题。

语言的许多方面都会在这一步中与你发生冲突,比如说泛型、零初始化和构造函数等,在现有语言中实现非空性真的很难!

类型系统

简而言之,非空值性可以归结为一些简单的类型系统的规则:

  1. 默认情况下,所有未加修饰的类型T都是非空的;
  2. 任何类型都可以使用?进行修饰,如T?,以将其标记为可为空;
  3. null对于任何非空类型变量来说都是非法值;
  4. T可以隐式转换为T?,从某种意义上说,TT?的子类型(尽管不完全如此);
  5. 存在运算符将T?转换为T,并进行运行时检查,如果值为null则触发放弃机制。

大多数情况可能是“显而易见的”,因为可以选择的方法并不多。主要的原则是系统地确保所有null值对于类型系统都是可知的,特别是,没有null可以“偷偷摸摸”成为非空的T类型值,这意味着其解决了零初始化问题——可能是所有问题当中最困难的一个。

语法

从语法上来讲,我们提供了几种方法来完成任务第五项,也就是将T?转换为T的方法。当然,我们不鼓励这样做,并且希望你尽可能长时间留在“非空”的环境中。但有时它根本不可能做得到,多步骤初始化不时发生,特别是对于集合数据结构来说更是如此,因此这必须得到支持。

设想一下,我们有如下的map数据结构:

Map<int, Customer> customers = ...; 

通过构造,我们知晓了三件事:

1.Map本身不为null;2.其中的int类型的键不为null;3.其中的Customer类型的值也不为null

我们可以说,索引器实际上返回了null以表示key键不存在。

public TValue? this[TKey key] {
    get { ... }
} 

现在我们需要一些方法来检查调用是否成功,对此我们讨论了多种语法。

最容易想到的是“guarded check”:

Customer? customer = customers[id];
if (customer != null) {
    // 这里的customer变量的类型是非null类型Customer 
} 

我承认,我总是对“魔法”类型的强制转换持观望态度。但让我感到恼火的是,当它出现失败时很难弄清楚出了什么问题。例如,如果将c与只持有字面null值的变量进行比较,那么它就不起作用了,但它的语法很容易记住,通常能够发挥作用。

如果值确实为null,则这些检查动态会分派到不同的逻辑上,通常,你只想断言该值为非null并在断言失败则触发放弃。有显式的类型断言运算符可以做到这一点:

Customer? maybeCustomer = customers[id];
Customer customer = notnull(maybeCustomer); 

除此之外,notnull操作符将类型为T?的任何表达式转换为T类型的表达式。

泛型

泛型很难,因为要考虑多个级别的空值性。考虑如下的代码:

class C {
    public T M<T>();
    public T? N<T>();
}

var a = C.M<object>();
var b = C.M<object?>();
var c = C.N<object>();
var d = C.N<object?>(); 

最基本问题是,abcd的类型是什么?

我认为我们最初将其变得比我们需要的更困难,因为C#现有的可空系统非常的古怪,而我们在试图模仿它上面分心太多。不过,好消息是我们终于找到了方向,虽然这还需要一段时间。

为了说明我的所表达的意思,让我们回到这个例子。有如下的两个不同阵营:

  • .NET阵营:aobjectbcdobject?
  • 函数式语言阵营:aobjectbcobject?,而dobject??

换句话说,.NET阵营认为你应该将一个或更多?的序列折叠成一个单独的?;而函数式语言阵营,由于了解组合数学的优雅,从而避免了魔法操作,并让整个代码方式也变得如此。 我们最终意识到.NET的路线方式非常复杂,且需要运行时的支持。

函数语言的做法最初会稍微改变一下你的想法。例如,对于前面的map示例:

Map<int, Customer?> customers = ...;
Customer?? customer = customers[id];
if (customer != null) {
    // 请注意,这里的customer仍然是“Customer?”类型,并且值依然可以是`null` 
} 

在这个模型中,你需要一次剥掉一层?,但老实说,当你停下来思考为什么需要这么做时,它是有道理的。它更透明,并准确反映了正在发生的事情,因此最好不要排斥它。

在实现上也有问题的。最简单的实现是将T?扩展为一些“包装(wrapper)类型”,如Maybe<T>,然后注入适当的包装和解包操作。实际上,这是实现工作方式的符合心理模型,但存在两个原因,使得这个简单的模型不起作用。

首先,对于引用类型T来说,T?一定不能占用额外的存储空间,指针的运行时表示已经可以以null作为值。并且对于系统语言而言,我们想利用这个事实来与T一样高效地存储T?,这一点通过专门化泛型实例可以相当容易地完成。但也确实意味着非空不再仅仅是一个前端技巧,它现在也需要后端编译器的支持。

(注意,这个技巧不是那么容易能够扩展到T??!)

其次,多亏了我们的可变性注解,Midori得以支持安全的协变数组。但是如果TT?具有不同的物理表示,那么将T[]转换为T?[]将是非变换操作。但这仅算得上是微小的瑕疵,特别是因为协变数组在插入已有的安全孔后变得不那么有用了。

无论如何,最终我们彻底放弃了.NET的Nullable<T>方式,转向了更多可组合的多?操作符设计。

零初始化

零初始化是真正的痛苦之处,征服它意味着要做到:

  • 必须在构造时初始化类的所有非空字段;
  • 所有非空元素数组必须在构造时完全被初始化。

但它变得更加糟糕。在.NET中,值的类型隐式地被零初始化。因此,最初的规则变成了:

  • 结构的所有字段都必须是可空的。

但是这是臭名昭著的,它会立刻以可空类型污染整个系统。我的假设是,只有可空是不常见的情况(例如20%)中,可空性才真正起作用。而上述的规则会在瞬间毁掉这样的条件。

因此,我们沿着消除自动零初始化语义的道路走了下去,而这却是一个很大的变化。(C# 6选择了允许结构提供零参数构造函数的路径走了下去,并最终因为它对生态系统产生了巨大的影响而不得不支持它) 它本可以很好的工作但是严重地偏离了路线,并引发了一些可能让我们分散注意力的问题。如果我可以再来一次,我会在C#中完全消除了值与引用类型的区别。在即将发布的关于与垃圾回收器做斗争的文章中,这个理由将被阐述的更加清晰。

非空类型的命运

对于非空类型而言,我们有坚实的设计和多个原型,但却从未在整个操作系统中部署非空类型。之所以这样是因为被捆绑在我们期望的C#兼容性水平上,公平地说,我认为这一点最终是我的决定。在Midori的早期,我们所需要的是“认知熟悉度”,而在项目的后期,我们实际上考虑了是否所有功能都可以作为C#的“附加”扩展来完成。正是后来的思维模式阻止了我们认真地完成非空类型。现在,我对此的信念是,可加注解起不了作用,Spec#尝试利用!达到这个目的而极性总是被翻转。非空必须成为默认值才能实现我们想要的影响力。

我最大的遗憾之一就是我们在非空类型上等待了很久,在合约达到相当数量时,我们才对此进行过认真地探索,并且我们注意到了在项目中有数千个requires x != null。它本来是复杂而且高代价的,但如果我们同时确定值类型的区别,这将是相当杀手锏的组合。活到老,学到老!

如果我们把我们的语言作为一个独立的项目来交付,而不同于C#本身,我相信这会有所成就的。

范围类型

我们有用于向C#添加范围类型的设计,但它总是更进一步超过了我们的复杂性限制。

其基本思想是,任何数字类型都可以给出一个下限和上限的类型参数。例如,假设有一个整型,只能持有数字0到1,000,000之间,那么它可以表示为int<0..1000000>。当然,应该指出的是你可能应该使用uint,所以编译器会对你发出警告。实际上,完整的数字集合可以通过这种方式在概念上表示为范围:

typedef byte number<0..256>;
typedef sbyte number<-128..128>;
typedef short number<-32768..32768>;
typedef ushort number<0..65536>;
typedef int number<-2147483648..2147483648>;
typedef uint number<0..4294967295>;
// 等等... 

真正出彩但又是可怕的复杂的部分是,使用依赖类型来允许符号范围参数。例如,假设我有一个数组,并希望传入一个索引,其范围保证是在范围之内的。那么通常我会写成:

T Get(T[] array, int index)
        requires index >= 0 && index < array.Length {
    return array[index];
} 

或者也许我会用uint来消除检查的前半部分:

T Get(T[] array, uint index)
        index < array.Length {
    return array[index];
} 

在范围类型的情形下,我可以直接将数字范围的上限与数组长度相关联:

T Get(T[] array, number<0, array.Length> index) {
    return array[index];
} 

当然,如果以某种方式欺骗了编译器的别名分析,则无法保证编译器会消除边界检查,但我们希望这类型的工作不会比正常的合约检查更糟糕,并且老实地说,这种方法是对类型系统中信息的更直接编码。

无论如何,我仍然把这归结为很酷的想法,但是仍然处于“很不错但不是关键性”的范畴内。

由于切片(slice)在类型系统中是一等公民,因此其“非关键”方面是尤其突出的,我可以说66%或更多使用范围检查的情况可以更好地使用切片来编写。我认为主要是人们仍然习惯于拥有它们,因此他们会编写标准的C#而不仅仅是使用切片类型。我将在即将发布的帖子中介绍切片,这样他们在大多数代码中都不再需要编写范围检查。

可恢复错误:类型导向的异常

当然,Abandonment不是唯一的主题,程序中仍然存在程序员可以合理地从中恢复错误的大量合法情况。这样的例子包括:

  • 文件I/O
  • 网络I/O
  • 解析数据(例如,编译器解析器)
  • 验证用户数据(例如,Web提交的表单)

在所有的情况下,你通常不希望在一遇到问题时就触发Abandonment机制,而相反地,该程序在预期上就可能会不时地发生错误,并需要通过一些合理的操作来对其进行处理。这通常通过将错误交给其他对象处理:向网页中输入的用户、系统管理员和使用工具的开发者等等。当然,如果Abandonment是最合适的行为,那么它不失为一种做法,但它通常也被认为是这些情况下最为极为激烈的反应。而且,特别是对于IO,它的存在使系统承担非常脆弱的风险。想象一下,如果你使用的程序在每次网络连接丢包时都因Abandonment而终止,那这简直是不可想象的!

进入异常

对于可恢复错误,我们使用异常,这里的异常不是那种非检查性的类型,也不是Java那种检查性类型。

首要的原则是:虽然Midori有异常机制,但是一个没有注解为throws的方法永远不会抛出异常,永远永远不会。例如,Midori没有在Java中那种悄悄抛出的RuntimeException,我们无论如何都不需要这样的方式。因为在Java中使用运行时异常的相同情况下,Midori中采用的是Abandonment机制。

这导致产生的系统具有神奇特性:我们系统中90%的函数都不会抛出异常!事实上,默认情况下他们不能,这就与像C++这样的系统形成了鲜明对比。在这些系统中,你必须不遗余力地避免异常并使用noexcept来申明这一事实。当然,API仍可能因Abandonment机制而调用失败,但只有当调用者未能满足所述合约时才会发生,而这一点上类似于向函数传递了错误类型的参数。

我们选择的异常在一开始时就存在争议,我们在团队中融合了命令式、过程式、面向对象和函数式语言的视角。C程序员想使用错误码的方式,并担心我们所做的却是重新创造了Java,或者更糟糕的C#设计。函数式的视角是对所有错误使用数据流,但异常却是十分以控制流为导向的方式。最后,我认为我们选择的是我们所有可用的可恢复错误模型之间的一种妥协。正如我们稍后将看到的,我们确实提供了一种将错误视为一等类型的机制,在这种情况下,开发者想要的是更多的数据流风格的编程。

然而最重要的是,我们在这个模型中编写了很多代码,它对我们来说非常有用。即便是函数式语言的开发者也最终加入进来。多亏了我们从返回码中获得的一些线索,C程序员也加入了。

语言和类型系统

在某个时间点上,我做了一个有争议的观察和决定:正如你不会期望在零兼容性影响条件下更改函数的返回类型一样,你也不应该以这样的期望方式更改函数的异常类型。换句话说,与错误代码一样,异常只是另一种不同的返回值!

这是针对检查性异常的一个有争议的论点。对此,我的回答可能听起来有点老生常谈,但其实很简单:这太糟糕了。在静态类型的编程语言中,异常的动态性正是它们出问题的地方。我们试图解决这些问题,因此我们拥抱了该决定,用于为强类型提供辅助,并再也没有回头过。仅此一点就有助于弥合错误码和异常之间的鸿沟。

函数抛出的异常成为其签名的一部分,就像参数和返回值一样。请记住,由于异常与Abandonment相比的不常见性,这一点也并不像你想象的那么痛苦,并且很多直观的属性也自然而然地也从这个决定中衍生出来。

因此,首要的原则就是Liskov替代原则:为了避免在C++中所发现的混乱,所有的“检查”必须在编译时静态发生。因此,WG21的文章 中提到的所有这些性能问题对我们来说都不再是问题。这种类型的系统必须是可以抵御攻击的,没有后门可以攻陷它,因为我们需要依赖于优化编译器中的throws来解决这些性能挑战,所以类型安全取决于该属性。

我们尝试了许多不同的语法。在我们致力于改变语言之前,我们使用C#属性和静态分析完成了所有工作,但是这种方式的用户体验不是很好,并且很难用这种方式实现一个真正的类型系统。此外,感觉它太简单了。我们尝试了Redhawk项目中,也就是最终成为.NET Native和CoreRT的方法。然而,尽管这种方法与我们的最终解决方案有许多类似的原则,但它也没有利用语言而仅仅是依赖于静态分析。

最终语法的基本要点是,简单地通过单个bit来声明throws方法:

void Foo() throws {
    ...
} 

(多年来,我们实际上把throws放在了方法的头部位置,但那没有区别)

在这一点上,可替代性问题非常简单,有throws的函数不能代替无throws的函数(这叫做非法加强);而另一方面,无throws的函数可以代替有throws的函数(合法弱化)。而这显然会对虚拟覆盖、接口实现和lambda带来影响。

当然,我们做了所期望的协同和逆变替代等华而不实的功能。例如,如果Foo是虚拟函数并且它被没有抛出异常的函数覆盖,则不需要再声明throws。当然,虚拟地调用这样一个函数的任何调用者都无法利用这个功能,但直接调用却是可以的。

例如,下面的代码是合法的:

class Base {
    public virtual void Foo() throws {...}
}

class Derived : Base {
    // 特定的实现不需要throws关键字: 
    public override void Foo() {...}
} 

Derived的调用者可以利用无throws的条件,而反之则是完全非法的:

class Base {
    public virtual void Foo () {...}
}

class Derived : Base {
    public override void Foo() throws {...}
} 

对单一故障模式的鼓励是相当自由的。Java的检查性异常带来的大量复杂性将立即消失。如果你观察大多数调用失败的API,它们无一例外都有单一的故障模式(一旦Abandonment处理完成所有bug故障模式),这包括IO故障,解析失败等等。开发者倾向于编写的许多恢复操作,实际上并不依赖于在做IO时究竟哪些地方出现故障的具体细节。(有些操作会这样做,对于它们来说,守护者模式通常是更好的方案,等一下就会有关于这个守护者模式的更多内容)。现代异常中的大多数信息实际上并适合程序使用,而相反它们是用于诊断的。

我们坚持这种“单一故障模式”有2到3年,最终我做出了支持多种故障模式的有分歧的决定。这种情况虽然并不常见,但是这样的请求经常会从团队成员中涌现出,而这些使用情景似乎是合法且有用的。它确实是以类型系统的复杂性为代价的,但仅限于所有常见的子类型方式。在更复杂的,例如中止场景中(包括后面的更多场景),则要求我们这样做。

语法如下所示:

int Foo() throws FooException, BarException {
    ...
} 

从某种意义上说,单个throwsthrows Exception的快捷方式。

如果你不在乎的话,很容易“忘记”额外的细节。例如,你可能希望将lambda绑定到上面的FooAPI中,但不希望调用者关心FooExceptionBarException。当然,lambda必须标记为throws,但不需要更多细节。这被证明是一种非常常见的模式:内部系统会使用类似的类型异常来进行内部控制流和错误处理,但是将它们全部转换为API的公共边界上的普通throws时,其中额外的细节是非必需的。

所有这些额外的类型为可恢复的错误增加了强大的功能。但是如果合约的数量超出了异常数量的10倍,那么简单的throws异常方法的数量也将超过了多故障模式方法数量的10倍。

在这一点上,你可能会疑惑的是,这与Java的检查性异常有何区别?

  1. 大部分错误使用Abandonment表达方式的事实意味着大多数API都没有抛出;
  2. 我们鼓励单一故障模式的事实大大简化了整个系统;此外,还可以轻松地从多种模式过渡到单一故障模式,并再次回到多种模式。

利用丰富的类型系统支持弱化和强化也有帮助的,正如我们所做的其他事情一样,也有助于缩小返回码和异常之间的差距,提高代码可维护性等等……

易于审核的调用点(Callsite)

在整个故事叙述到这个时候,我们仍然没有实现错误码的完整显式语法。函数的声明说明它们是否可能出现故障(好的方面),但这些函数的调用者仍然继承静默的控制流(坏的方面)。

这给我带来了异常模型中最喜欢的一些东西。调用点需要声明try

int value = try Foo(); 

这将调用函数Foo,如果发生错误则传播其错误,否则将返回值赋值给value

它有一个很不错的属性:所有控制流在程序中都是显式的。你可以将try视为条件return(如果你愿意,也可以认为是条件throw)。我非常喜欢这种让代码审查错误逻辑变得更容易的方式!例如,设想有一个长函数,里面有数个try语句,具有显式注释使得失败点成为控制流,因此易于选择return语句:

void doSomething() throws {
    blah();
    var x = blah_blah(blah());
    var y = try blah(); // <-- 啊哈!可能产生故障的调用! 
    blahdiblahdiblahdiblahdi(); 
    blahblahblahblah(try blahblah()); // <-- 另一个可能产生故障的调用! 
    and_so_on(...);
} 

如果你的编辑器中有语法高亮,那么try将是蓝色粗体的,这样的话就更好了。

这提供了许多返回码的强大好处,却没有它的所有包袱。

(Rust和Swift现在都支持类似的语法。我不得不承认,我对于我们没有在几年前将这种语法交付给公众而感到难过。它们的实现方式非常不同,但对此的考虑给它们的语法带来了很大的信心。)

当然,如果你正在尝试这样对如此抛出的函数采取try方法,就有一下两种可能:

  • 异常从被调用函数中逃逸出来;
  • 周围有一个try/catch块来处理错误。

在第一种情况下,你也需要对你的函数声明throws。当然,是传播由被调用函数声明的强类型信息,还是利用单个throws位,这是由你来决定的。

在第二种情况下,我们当然理解所有的输入类型信息,因此,如果你尝试捕捉未声明被抛出的内容,我们可能会给你一个dead code的错误,这是与传统异常系统的另一个有争议的背离。它总是提示我catch(FooException)本质上隐藏了动态类型测试。 你是否会默默地允许调用仅返回object的API,并自动将这个返回值分配给具有类型的变量?那一定是不行的! 因此,我们也不会让你在异常上这样做。

CLU也对我们产生了影响,Liskov在一篇关于CLU历史的文章中谈到:

CLU在对待未处理的异常方面的机制是不寻常的。大多数机制都通过这样的方式传递:如果调用者没有处理被调用过程引发的异常,则异常将传播给它的调用者,依此类推下去。而我们拒绝使用这种方法,因为它不符合我们关于模块化程序构建的思想。我们希望能够通过只需知道其规范而不是其实现的方式来调用过程,但如果异常自动地传播下去,则过程可能会触发其规范中未描述的异常。

虽然我们不鼓励大范围的try块,但这是在概念上传播错误码的快捷方式。如果要了解我的意思,请考虑在具有错误码的系统中你是怎么做的,比如在Go中,你可能会写出如下的代码:

if err := doSomething(); err != nil {
    return err
} 

而在我们的系统中,你需要这样编码:

try doSomething(); 

但你可能会说,我们使用了异常,这是完全不同的!当然,运行时系统会不一样,但从语言“语义学”的角度来看,它们其实是同构的。我们鼓励人们根据错误码来思考,而不是他们所熟悉和喜爱的异常。这可能会有点意思:你可能想知道,为什么不使用返回码?在接下来的部分中,我将描述真正的场景的同构,以试图说服你我们的选择。

语法糖

我们还提供了一些处理错误的语法糖。try/catch块作用域结构有点冗长,特别是如果你尽量局部地遵循我们处理错误的预期最佳实践。对于某些地方来说,它仍然不幸地保留了一些goto的感觉,特别是如果就返回码考虑而言。这种方式让位于我们称为Result<T>的类型,它只是一个简单的TException

对于数据流来说更自然的场景中,这基本上是从控制流世界到数据流世界之间的桥梁,虽然大多数开发者更喜欢熟悉的控制流语法,但两者肯定都有自己的一席之地。

为了说明常见的用法,假设你希望在重新传播异常之前记录发生的所有错误,虽然这是一种常见的模式,但使用try/catch块会让我觉得有点过于控制流式:

int v;
try {
    v = try Foo();
    // 或许有更多的语句...
}
catch (Exception e) {
    Log(e);
    rethrow;
}
// 使用v值... 

“或许有更多的语句”位置处会吸引你在try块处挤进应该比更多的东西,将此与使用Result<T>进行比较,从而产生更多的返回码感觉和更方便的本地处理:

Result<int> value = try Foo() else catch;
if (value.IsFailure) {
    Log(value.Exception);
    throw value.Exception;
}
// 使用值`value.Value` ...

作为对故障的响应,try/else构造还允许你替换成自己的值,甚至触发Abandonment机制:

int value1 = try Foo() else 42;
int value2 = try Foo() else Release.Fail(); 

我们还通过从Result<T>中抽取成员T的访问来支持NaN样式的数据流错误的传播。例如,假设有两个Result<int>,并希望对它们求和,那么我可以这样做:

Result<int> x = ...;
Result<int> y = ...;
Result<int> z = x + y; 

注意第三行,我们将两个Result<int>加到一起,没错是的,产生了第三个Result<T>。这是NaN风格的数据流传播,类似于C#的.?新功能。

我发现这种方法是异常、返回码和数据流错误传播的优雅混合。

实现

我刚才所描述的模型不必用异常来实现,因为它已经足够抽象,可以使用返回码来合理地实现。这不仅仅是理论上可行性,我们实际上就这么尝试过,这也是导致我们出于性能原因选择异常而不是返回码方式的原因。

为了说明返回码实现是如何工作的,设想如下的一些简单的转换:

int foo() throws {
    if (...p...) {
        throw new Exception();
    }
    return 42;
} 

变成:

Result<int> foo() {
    if (...p...) {
        return new Result<int>(new Exception());
    }
    return new Result<int>(42);
} 

以及,该代码:

int x = try foo(); 

变成如下的代码:

int x;
Result<int> tmp = foo();
if (tmp.Failed) {
    throw tmp.Exception;
}
x = tmp.Value; 

优化编译器可以对其进行更有效地表示,特别是通过内联,消除了过多的复制操作。

如果你通过goto的方式尝试以同样的方式来对try/catch/finally进行建模,你会很快看到为什么编译器在存在非检查性异常时很难进行优化,所有都隐藏在控制流的边缘上!

无论以哪种方式,这个例子都非常生动地展示了返回码的缺点。所有的跳转语句都处在热路径上,但它们其实是很少需要的(当然,假设故障是罕见的),并混淆你程序的黄金路径上的性能,因此违反了我们最重要的原则之一。

我在上一篇文章中描述了我们双模式实验的结果。总之,由于以下几点,异常方法在我们的关键基准测试中,以几何平均的方式,代码缩小了7%,速度提高了4%:

  • 没有给调用约定带来影响;
  • 没有与包装返回值和调用者分支相关联的“花生酱”开销;
  • 所有抛出函数在类型系统中都是已知的,从而实现更灵活的代码移动;
  • 所有抛出函数在类型系统中都是已知的,为我们提供了新颖的异常处理优化;例如在try无法抛出时将try/finally块转换为直接代码。

还有其他方面的异常也有助于提高性能。我已经提到过,我们并没有像大多数异常系统那样使用调用点来收集元数据,并将诊断留给了诊断子系统。然而,另一个常见的模式是将异常缓存为冻结对象,因此每次throw都不需要再次分配:

const Exception retryLayout = new Exception();
...
throw retryLayout; 

对于具有高概率抛出和捕获的系统,例如我们的解析器,FRP UI框架和其他领域来说,这对于良好的性能至关重要,这也说明了为什么我们不能简单地将“异常很慢”视为理所应当的。

模式

许多有用的模式被用于修饰我们的语言和库。

并发

早在2007年,我就写了关于并发和异常的注解的文章,虽然当时我主要是从并行共享内存计算的角度,但是所有并发编排模式都存在类似的挑战。基本的问题是单一顺序栈和单一故障模式的假设下来实现异常的方式,而在并发系统中,存在许多类型的栈和多种故障模式,可能会有0个,1个或多个模式“同时”发生。

Midori所做的一个简单改进就是,确保所有与Exception相关的基础架构处理具有多个内部错误的情况,至少那时程序员没有被迫决定,像今天大多数异常系统所鼓励的那样,抛弃故障信息。然而,更重要的是,由于我们的异步模型以及其交互方式,调度和堆栈基础架构从根本了解cactus stack(仙人掌堆栈)。

起初,我们并不支持跨异步边界的异常,但最终我们还是在跨异步进程边界上,扩展了声明throws以及可选类型化异常子句的能力。这为异步actor编程模型带来了丰富的类型化编程模型并感觉这就像是一种自然的扩展,而这一点从CLU的继任者Argus那里获得的灵感。

我们的代码诊断基础设施对其进行了修饰,以便为开发者在栈视图中提供全面的跨进程因果关系调试体验。cactus stack不仅在高度并发的系统中存在,而且它们经常在进程消息传递边界上出现,以这种方式调试系统可以节省大量时间。

中止

有时一个子系统需要彻底从故障的环境中摆脱出来。因此Abandonment也是一种选择,但它也只应为应对bug而存在,并且在进程中没有什么可以阻止它。但如果我们想要退回到调用栈的某个点上,并已知栈中没有其他会阻止我们,然后恢复并继续在同一个进程中运行的话,那又会怎么样呢?

对于这种情况,异常更接近于我们所想要的。但不幸的是,栈上的代码可以捕获正被抛出中的异常,从而有效地抑制中止的发生。因此,我们想要的是一些不可被抑制的构造。

接着来聊聊中止(Abort)。我们发明中止主要是为了支持我们使用函数反应式编程(FRP)的UI框架,而FRP模式本身将会在未来的几篇文章中出现。当FRP重计算(recalculation)发生时,事件可能会进入系统,或者新的发现出现,从而使当前的重计算失效。通常这种情况都深埋在用户和系统代码交织的计算中,如果这种情况发生,FRP引擎需要快速返回其堆栈顶部,以便可以安全地开始重计算。由于堆栈上的所有用户代码都是纯函数式的,因此在流的中途进行中止很容易做到,并且不会留下任何导致错误的副作用。并且多亏了类型化异常,所有遍历的引擎代码都经过审核和彻底修改,以确保和维护不变性。

中止的设计借用了权能的设计。首先,我们引入了一种名为AbortException的基础类型,它可以直接或以子类继承的方式使用。但它有一点却是特殊的:在他被捕获后就无法再被忽略掉,在尝试捕获它的任何catch块的末尾都会自动重新抛出该异常。因此,我们可以说这种异常是不可否认的。

但总得有代码去捕捉abort。为此,整体的想法就是离开所处的上下文,而像Abandonment一样销毁整个进程,所以这就是权能发挥作用的地方。下面是AbortException的基本样子:

public immutable class AbortException : Exception {
    public AbortException(immutable object token);
    public void Reset(immutable object token);
    // 省略掉其他不关心的成员...
} 

请注意,在调用构造函数时,需提供不可变的token,而为了抑制抛出的异常,需调用Reset,并且必须提供相匹配的token,一旦token不匹配,则会触发Abandonment。这里的想法是,abort的抛出和预期的捕获方通常是相同的,或者至少是彼此相关联,所以这样可以容易地安全地彼此共享token。所以这是对象作为不可伪造的权能,在实践中一个很好的例子。

是的,栈上的任意一段代码都可以触发Abandonment,但是这样的代码已经可以简单地通过解引用null来实现。该技术禁止在尚未准备好的情况下中止上下文中的代码执行。

其他框架具有类似的模式,.NET Framework中有ThreadAbortException类型的异常,除非你调用Thread.ResetAbort,否则它也是不可否认的。但遗憾的是,由于它不基于权能,因此需要使用安全注释和托管API的笨拙组合来阻止Abort被透明地意外处理。并且更常见的情况是,这是未经检查的异常。

由于异常是不可变的,并且上面的token也是不可变的,因此常见的模式是将这些它们缓存在静态变量中并以单例的方式使用。例如:

class MyComponent {
    const object abortToken = new object();
    const AbortException abortException = new AbortException(abortToken);

    void Abort() throws AbortException {
        throw abortException;
    }

    void TopOfTheStack() {
        while (true) {
            // 调用函数,使得调用栈变得很深, 
            // 在调用栈深处位置可能会发生Abort,这里进行捕获并将其重置: 
            let result = try ... else catch<AbortException>;
            if (result.IsFailed) {
                result.Exception.Reset(abortToken);
            }
        }
    }
} 

这种模式使Abort非常高效,平均下来FRP的重计算会发生多次Abort。请记住,FRP是系统中所有UI的主干部分,因此出现通常归因于异常的缓慢显然是不可接受的。由于随之而来的GC压力,即使异常对象的分配也是一件不幸的事。

可选的“Try”API

我提到过因故障而导致Abandonment的操作,这包括分配内存,算术溢出或除零等。在其中一些实例中,一部分适用于动态错误传播和恢复,而不是Abandonment,即使在通常情况下Abandonment是更好的选择。

结果证明这是一种模式,虽然不是非常普遍,但它确实出现了。因此,我们有使用数据流方式传播溢出、NaN或任何数量的可能发生的错误的一整套算术API。

我之前已经提到了一个具体的实例,即当OOM产生可恢复的错误而不是Abandonment时,能够通过try new尝试进行新的分配。这种情况非常罕见,但如果你想为某些多媒体操作分配一个大缓冲区时,它可能会发挥作用。

守护者

我将介绍的最后一个模式,叫做守护者(keeper)模式

在很多方面,处理可恢复异常的方式是“由内而外”的:调用一堆代码,在调用栈中传递参数,直到最后达到一些被认为是不可接受的状态。在异常模型中,控制流而后在调用栈中向上传播并展开,直到找到处理对应错误的代码。这个时候如果需要重试,则必须进行重新调用。

另一种供选择的模式是使用keeper。keeper是一个知道如何在“原地”从错误中恢复的对象,因此就不再需要展开调用栈,而只需要抛出异常的代码询问keeper如何处理异常,keeper则告知代码如何继续执行。Keeper的很好的优势是,当作为配置的功能时,代码甚至不需要知道它们存在,在这一点上,不像我们的系统中必须被声明为类型系统的一部分的异常。除此之外,Keeper的另一个优势是它们简单而开销较低。

Midori中的Keeper可以被用作快速操作,但更常见的用法是作为跨越异步边界的异常。

Keeper的规范化示例是保护文件系统的操作,访问文件系统上的文件和目录通常具有以下的一些故障模式:

  • 无效的路径规范
  • 文件未找到
  • 目录未找到
  • 文件正在使用
  • 权限不足
  • 媒体已满
  • 媒体写保护

一种选择是使用throws子句为每个文件系统API进行注解,或者,像Java一样,创建一个IOException类型的层次结构,每种故障都作为其子类而存在。另一种方法是使用Keeper模式,它可以确保整个应用程序无需知道或关心IO错误,也允许集中式地恢复逻辑。这样的keeper接口可能像如下的形式:

async interface IFileSystemKeeper {
    async string InvalidPathSpecification(string path) throws;
    async string FileNotFound(string path) throws;
    async string DirectoryNotFound(string path) throws;
    async string FileInUse(string path) throws;
    async Credentials InsufficientPrivileges(Credentials creds, string path) throws;
    async string MediaFull(string path) throws;
    async string MediaWriteProtected(string path) throws;
} 

当发生故障时,在每种情况下相关输入被提供给keeper,然后keeper执行可能异步的操作以进行恢复, 在许多情况下,keeper可以选择返回操作更新后的参数。例如,InsufficientPrivileges可以返回将会用到的Credentials (也许这时程序会提示用户,然后用户切换到具有写访问权限的帐户上)。在以上显式的每种故障情况中,一种可选的操作是,如果keeper不想处理该错误,则可以继续采取抛出异常的方式。

最后,注意到的是Windows的结构化异常处理(SEH)系统支持“可持续(continuable)”的异常,这种异常在概念上试图实现同样的目的,它们让一些代码决定如何重新启动错误的计算。不幸的是,它们是在调用栈上使用环境处理程序上完成的,而不是作为语言中的一等对象,因此它远不如keeper模式那么优雅,而且更容易出错。

未来方向:效果类型化

大多数人问我们是否将asyncthrows作为类型系统的属性分叉到整个库环境中,我对此的答案是,“不,这不是真的”。但在高度多态的库代码中这样肯定是痛苦的。

最令人震惊的例子是组合类型,如map、filter和sort等。在这些情况下,你经常有任意函数,并希望这些函数的asyncthrows属性透明地“传播”。

我们必须解决的设计是让你对效果进行参数化。例如,现在有一个通用的映射函数Map,它传播其func参数的asyncthrows效果:

U[] Map<T, U, effect E>(T[] ts, Func<T, U, E> func) E {
    U[] us = new U[ts.Length];
    for (int i = 0; i < ts.Length; i++) {
        us[i] = effect(E) func(ts[i]);
    }
    return us;
} 

请注意,我们有一个普通的泛型类型E,除了它的声明以关键字effect为前缀之外。然后除了在调用func时通过 effect(E)在“传播”位置使用它之外,我们象征性地使用E来代替Map签名的效果列表。这是一个非常简单的替代操作,用effect(E)替换try,以及用E替换throws,来看看逻辑的转换。

一种合法的调用如下:

int[] xs = ...;
string[] ys = try Map<int, string, throws>(xs, x => ...); 

请注意,这里的throws已经透明地传播下去,因此我们可以传递一个抛出异常的回调。

总而言之,我们将上述的讨论更进了一步,并允许程序员声明任意的效果。我以前曾经对这种类型的系统做过假设,然而我所担心的是,无论它多么强大,这种高阶编程都可能是过度的精巧且难以理解。但上述的简单模型应该会是有意义的,我想如果多几个月时间,我们会将其加以实现。

回顾与总结

我们已经来到了这段特殊旅程的终点,正如我在一开始所说的,最终是一个相对可预测和温和的结果。通过我们对错误的情况进行分类的基本原则,希望所有这些背景都能帮助你完成项目中错误处理的进化。

总之,最终的模型具有以下的特点:

  • 一种假设细粒度隔离和从故障中的可恢复性的体系结构;
  • 区分bug和可恢复的错误;
  • 使用合约、断言、以及面向所有bug的更通用的Abandonment;
  • 对于可恢复的错误,使用向下精简的,具有丰富的类型系统和语言语法的检查性异常模型;
  • 采用返回码的有限的某些特性,比如局部检查,以提高可靠性。

虽然这是一个多年的旅程,但我们一直致力于多个领域中积极努力的改进,直到我们的项目过早地夭折。因为我们没有使用它们的足够经验以宣称获得了成功,所以我对它们进行了不同的分类。如果能走得更远,我希望我们能将大部分内容整理出来并将它们交付出去。特别地,我想把下列原则放到最终模型的类别中:

  • 默认情况下,利用非空类型可以消除大量的可空性注解。

Abandonment以及我们对它的使用程度,在我看来是在错误模型上最大和最成功的赌注。我们进程很早地发现bug,它们是最容易诊断和修复的,基于Abandonment的错误数量超过可恢复错误的比例接近10:1,因此,这样使得检查性异常很少出现并且可以被开发者所容忍。

虽然从未有机会将这些项目发布出来,但我们已经将其中的一些经验教训带到了其他的项目中去。

例如,在从Internet Explorer重写Microsoft Edge浏览器期间,我们在一些区域采用了Abandonment机制。由Midori的工程师处理的一个关键领域是OOM。如前所述,旧代码会艰难地执行,并且几乎总是会出现错误,而Abandonment发现了许多潜伏的错误,就像我们常常在Midori中移植现有代码库时所经历的那样。更重要的是,Abandonment更多的是一种架构上的原则,可以在编程语言的现有代码库中所采用。

细粒度隔离的架构基础至关重要,但许多系统都有这种架构的非正式概念。面向OOM的Abandonment机制在浏览器中运行良好的原因是,大多数浏览器已经将单独进程专门用于各个选项卡,浏览器正以多种方式模仿操作系统的行为,并且在这里我们也看到了同样的情况。

最近,我们一直在探索将这些,包括合约在内的原则带到C++的提议,另外还有一些将这些功能带到C#中的具体提议,我们还正在积极地迭代出给C#带来一些非空检查的提议。我不得不承认,我希望所有这些提案都是最好的,但是没有一个能像在同一个错误规则中写入整个栈那样具有防弹性。请记住,整个隔离和并发模型对于大规模Abandonment至关重要。

我希望继续分享这些知识,使得能够更广泛地采用其中的一些想法。

当然,我已经提到,Go、Rust和Swift在此期间为业界提供了一些非常好的有关系统的错误模型。我们的设计可能会在各个地方有一些微小的瑕疵,但现实情况是,这些语言(Go、Rust和Swift)所诞生的环境,已经超出了在我们开始Midori之旅时在行业中所具有的环境。所以现在正是成为一名系统程序员的好时机!

下一次我会更多地谈谈这门语言,具体来说将会看到Midori是如何利用架构、语言支持和库的万能钥匙来驯服垃圾收集器的(译者注:实际上作者后文中并没有写到GC)。我希望很快能和你们再次见面!