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

Recoverable Errors: Type-Directed Exceptions

Cover Image

我们当然不是只需要“放弃”就够了。在很多情况i西安,程序员能够把程序从发生的错误中恢复过来。例如:

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

在这些情况下,遇到错误时你通常不会想要触发放弃。相反,你的程序应该做好了随时遇见这些错误的准备,并且能够合理地解决它们。通常来说是向某些人传递一些信息:例如在网页上输入数据的用户、系统的管理员、开发人员等等。当然,如果合适的话,放弃仍然可以作为一种选择,但通常来说这有点太夸张了。尤其是对于 IO 操作来说,这会让整个系统太过脆弱。想象一下:每当你的网络丢了一个包时,你的程序就决定从此消失!

使用异常

我们使用异常来应对可恢复错误。不是不受检查的那种,跟 Java 的受检查异常也不太一样。

必须要说明的是:虽然 Midori 使用异常,但一个没有标记为 throws 的方法永远不允许抛出异常。 Java 中提供的鬼鬼祟祟的 RuntimeException 在这是不存在的。我们其实也不需要它,因为在 Java 中使用 RuntimeException 的那些情况,在 Midori 中会使用放弃来进行处理。

在最终的系统中,我们神奇的发现,大约有 90% 的函数不会抛出异常!或者说,它们根本不允许抛出异常。这与 C++ 之类的系统形成了鲜明对比——在那些系统中,你必须时刻尽力地避免碰上异常,还需要使用 noexcept 来标记。由于存在放弃机制,API 调用仍然可能会失败,但只有调用者没有满足声明的合约时在会发生——类似于传入的参数类型错误。

我们选择使用异常,但这从一开始就有争议。命令式、过程式、面向对象和函数式语言的方法,在我们组里全都有人支持。C 程序员想要用错误码,担心我们会重新发明 Java 的异常设计,甚至更糟糕—— C# 的设计。函数式的观点认为我们应该使用数据流来应对所有错误,异常是一种面向控制流的方法。最后,我认为我们选择的是一种集所有优点于一身的组合模型。一会我们就会看到,我们的确提供了一种机制来将错误看作是一等公民,并且正如开发人员想要的一样,使用数据流风格的编程方法来处理这些偶发情况。

最重要的是,我们使用这种模型写了一堆的代码,对我们来说这个模型工作的很好,甚至得到了经常使用函数式语言的朋友的认可。由于我们也从返回码模型中汲取了一些经验,这让 C 程序员们也很开心。

语言和类型系统

当时,我做了一个有争议的决定。正如当我们改变了函数的返回类型时,一定会产生兼容性的影响,你也不应该认为改变了函数的异常类型时就没什么兼容性影响。换句话说,异常和错误码一样,都是返回值的一种!

这一点在之前介绍受检查的异常时提到过,它是受检查异常的反对者的论据之一。我的回答有点老套,但很简单:反对得不对。你正在使用的是一门静态类型编程语言,而异常的动态特性才是它们难用的原因。我们想要去解决这些问题,因此我们接受了“异常是一种返回值”的观点,并辅之以强类型。我们从没想过要回头。错误码和异常之间就这样建立起了一座桥梁。

一个函数所抛出的异常是它的签名的一部分,就像参数和返回值一样。请记住,由于异常本来就很少见,这种做法没有你想象的那么痛苦。进而,很多直观的想法就自然而然地出现了。

首先要说的是里斯科夫替换原则(Liskov Substitutin Priciple)。为了避免类似于 C++ 的糟糕局面,所有“检查”都在编译期发生。因此,在 WG21 中所提到的所有性能问题,对我们来说就都不是问题了。类型系统必须坚不可摧,禁止存在任何后门。之所以类型安全与这个问题息息相关,是因为我们的优化器需要依赖于 throws 标注才能解决这些性能问题。

我们尝试过使用不同的语法。在我们对语言进行修改之前,我们已经尝试过了各种使用 C# 属性(attribute)和静态分析的方法。用户体验不是非常好,也难以做出一个真正的类型系统。这种方法也存在太多的限制。我们曾经尝试过 Redhawk 项目所采取的方法——Redhawk 最终变为了 .NET Native 和 CoreRT——然而,他们的方法同样没有借助语言本身,而是使用了静态分析。不过他们跟我们的最终方案还是有着很多相似之处的。

最终的语法大概是这样,一个方法后面只跟着一个 throw

void Foo() throws {
    ...
}

(有很长一段时间,我们把 throws 放在了函数的最前面。但那种方式读起来有问题。)

这时,可替代性的问题就非常简单了。一个 throws 函数不能够替换一个非 throws 函数(不合法的加强)。另一方面,非 throws 函数能够替换 throws 函数(合法减弱)。显然,虚重载、接口实现和 Lambda 会受到这些规则的影响。

我们也实现了预想的协变、反协变替换。例如,如果 Foo 是个虚方法,而你的重载实现不会抛出异常,那么你就不需要声明 throws。当然, 只有直接调用它时,非 throw 的性质才会体现,通过虚方法调用它的话则不会。

例如,这是合法的:

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

class Derived : Base {
    // 某个特定的实现可能不会抛出异常
    public override void Foo() {...}
}

在直接调用 Drived 的方法时,可以从非 throws 这一点获益。然而,下面的写法完全不合法:

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

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

鼓励使用“单一失败模式”解放了我们——Java 的受检查异常所带来的大量的复杂性瞬间就消失了。如果你看一下 Midori 中可能会失败的 API,大多数都采取了单一失败模式(曾经所有的失败模式都是利用放弃完成的),不论是 IO 失败还是解析失败,等等。例如在处理 IO 操作时,开发人员所准备的恢复操作大多与 究竟发生了什么错误无关。(有一些是有关的,而对于那些情况,看守人模式(the keeper pattern)则更加适合。下面很快会讨论到这一点。)其实大多数现代的异常中所携带的信息,从编程角度来说,都没什么用;相反,它们是为了更方便的调试和查错。

大约有两到三年,我们都始终坚持这种“单一错误模式”。最终我又做了一个有争议的决定来支持多失败模式。这种情况不算常见,但经常从团队成员那里听到关于这一点的合理需求,使用的场景看起来很确实是合法的、有用的。这的确会增加类型系统的复杂度,但是跟处理其他的子类型差不多。更复杂的场景——例如终止(Abort)(下面会讨论)——需要我们提供这个功能。

语法看起来像这样:

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

或者说,单一的 throwsthrows Exception 的简写。

如果你不关心,把这些显式错误类型“忘掉”也很简单。例如,也许你想把 Foo 包进一个 Lambda,但又不想让用户关心 FooExceptionBarException,那么就可以只写一个 throws。这似乎是一个很常见的模式:在系统内部,对于内部的控制流和错误处理来说,使用这种带类型的异常;而在 API 边界处,如果不需要具体的信息,就可以把这些异常都变为简单的 throws

所有的这些标注做法给可恢复错误带来了强大的能力。不过,如果合约与异常之间的比例是 10:1 的话,抛出单一的 throws 异常的函数与多异常模式的函数之比就又是 10:1。

到这里,你也许会想,这与 Java 的受检查异常究竟有什么不同?事实上,

  1. 大部分的错误都使用了放弃来处理,因此大多数 API 根本不会抛出异常。
  2. 我们更建议使用单一错误模式,它让整个系统变得更加简洁。更进一步,多模式和单一模式之间的转换同样非常简单。

强大的类型系统关于弱化和强化的支持在这里同样有所帮助,我们还做了一些别的事情来填补返回码和异常之间的鸿沟,提升了代码的可维护性,等等…

易于审核的调用现场

目前,我们还没能实现错误码方案中完全显式的语法。函数的声明提到了它们可能会失败( ),但调用这些函数的地方的控制流仍然是隐式的( )。

我们的异常模型中,我始终非常喜欢的就是这一点——调用方需要写明try:

int value = try Foo();

这将会调用函数 Foo,如果发生了错误的话就继续向上传播,否则就把返回值赋给 value

这有一个巨大的好处:程序中所有的控制流都是显式的。你可以把 try 看作是一种条件 return(或者是条件 throw 也行)。在做 Code Review 时,我简直爱死这个语法了!例如,想象一下你有一个很长的函数,里面有一些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。不过,是否要把类型信息也向上传播则取决于你。

如果是第二种情况,因为我们能够了解到所有的类型信息,所以如果你试图 catch 没有声明的异常,编译器就会给出错误来避免不可达的代码。这是另外一个与经典的异常系统所不一样的、有争议的地方。我始终对下面这一点耿耿于怀:catch (FooException) 本质上隐藏了动态类型测试。你会允许一个人调用一个类型为 object 的 API,然后自动地把返回值赋值给一个其他类型的变量吗?当然不能!异常这里也是一样。

在异常处理这个方面,CLU 同样对我们有所影响。里斯科夫在 A History of CLU 中讨论了这个话题:

CLU 对待未处理异常的机制有点不同寻常。大多数机制都把这些异常再次抛出:如果一个调用者没有处理被调用方的异常,那么就把这个异常再抛给上面的调用者。我们不同意使用这种方法,因为它跟我们的模块化程序构建相违背。我们希望知道一个函数的说明就能够去调用它,而不是需要知道它是如何实现的。如果一个异常被自动传播出去,那么一个过程就有可能会抛出它声明中没有指明的异常。

虽然我们不鼓励大范围的 try 块,但在概念上它跟传播错误码是一样的。举个例子,想象一下再使用错误码的系统里,例如 Go,你可能会写:

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

在我们的系统里,你可以这样写:

try doSomething();

但你可能会说,我们用的是异常啊!这完全不一样啊!当然,运行时的系统有所不同。但从语言的“语义”角度来说,它们是一样的。我们鼓励大家从错误码的角度来看待这种方法,而不是它们熟知和喜爱的异常。这看起来有点高效:那我们为什么不直接使用错误码呢?下面我会介绍它们为什么确实是一样的,并会向你解释我们的选择。

语法糖

我们为错误处理提供了一些语法糖。try/catch 块的构建有点啰嗦,同时我们还建议要尽可能细粒度地处理错误,这就让每次都写 try/catch 块显得更加啰嗦。如果你把异常看作是一种错误码的话, 它甚至还有点 goto 的味道。这使得我们提供了一种 Result<T> 的类型,简单说它里面要么存放的是 T,要么是一个 Exception

它在控制流和数据流的世界之间建立起了一座桥梁,对于某些场景来说后者更加自然。两者都有其用武之地,尽管大多数开发人员都对控制流的语法更加习惯。

举个例子,假设你想要在向上抛出异常之前记录下所有的错误。尽管这是一种常见的模式,使用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 结构还允许使用自己定义的值来替代 catch,也可以触发放弃:

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

通过将T的成员映射至 Result<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;

编译器的优化器可以通过消除不必要的拷贝、或是将函数内联,而使得这些逻辑更加高效。

如果你使用这种方式来建模 try/catch/finally 代码块(大概需要使用 goto),你就会知道为什么编译器难以优化那些使用了不受检查的异常的代码。大量的隐式控制流在作祟!

不管怎样,这个例子非常生动地展示了返回错误码方式的缺点。这些错误处理的代码其实很少有机会被执行(当然,我们需要假设故障发生的频率没那么高),但它们仍然在热路径(Hot Path)上,会影响程序在主要场景下的性能。这违背了我们最重要的准则之一。

我在上一篇博客中描述了我们对两种模式的实验。简单来说,使用异常来实现的话,我们做到了平均减小 7% 的程序体积、提高 4% 的执行速度。原因在于:

  • 调用约定没有受到影响。
  • 调用函数时不需要做分支检查。
  • 在类型系统中,所有会抛出异常的函数都是已知的,因此能够实现更灵活的代码移动。
  • 在类型系统中,所有会抛出异常的函数都是已知的,因此能够让我们实现一些更新颖的错误处理优化,例如当 try 块中没有异常会抛出时,可以把 try/finally 块展平。

还有其他原因使得异常更加高效。我已经提到了,我们不会像大多数异常系统一样遍历调用栈、收集元数据。我们把诊断功能留给了我们的诊断子系统。另一个常见的模式在这同样有所帮助:将异常缓存为不可变的对象,使得 throw 时不会发生内存申请:

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

对于会频繁抛出和捕获异常的系统来说——例如我们的语法分析器、FRP UI 框架(译注:Functional Reactive Programming,函数式反应式编程范式)等等——这种方法对于提高性能非常重要。这也很好地说明了,我们最好不要把“异常很慢”当作真理来对待。

模式(Pattern)

在我们的语言和类库中,我们添加了很多有用的模式。

并发

回到 2007 年,我写了一篇有关并发和异常的博客文章。这篇文章主要是从并行、共享内存计算的角度来写的,但那些难点是所有的并发调度模式所共有的。我们遇到的基本问题在于:异常是基于单一连续的栈、以及单一失败模式来实现的。在一个并发系统中,你可能有多个栈、多种失败模式,很多情况都有可能“同时”发生。

Midori 所做的一个简单的改进是,它会保证所有与 Exception 相关的基础设施都能够处理多个内层错误。至少当有多个错误信息同时存在时,程序员不用考虑究竟要留下哪一个。更重要的是,调度和栈爬取设施从根本上掌握着这些“仙人掌风格”的栈的信息,也知道如何作出处理。这多亏了我们的异步模型。

一开始,我们不支持异常跨越出异步执行的边界。然而最后我们还是扩展了异常系统,使得异常能够跨越异步边界。这就为异步角色编程模型(Asynchronous Actors Programming Model)引入了一个强大的、有类型支持的编程模型,就像是一种自然的扩展。我们从 CLU 的继任者 Argus 那里学习了一个。

我们的诊断设施在其“栈视图”里提供了一种全面的、跨进程因果关系的调试体验。在高度并发的系统中,栈不仅仅是“仙人掌”式的,它们还经常出现在进程之间的消息传递边界上。支持这种形式的调试,为开发人员节省了大量的时间。

中止

有时,某个子系统需要“快速脱离苦海”。当遇到 Bug 时,放弃是一种选择。在一个进程中没什么东西能阻止放弃的执行。不过,在确认条件允许的情况下,我们能不能把调用栈恢复到某个时间点、进行恢复、然后在同一个进程中继续执行?

异常比较接近我们的需求。但不幸的是,在调用栈上的代码能够捕获异常,并将其屏蔽掉。我们想要一个不能被屏蔽的东西。

这个“东西”就是中止(Abort)。我们发明中止,主要是因为我们的 UI 框架使用了函数式反应式编程(FRP),这种编程范式在其他一些地方也有应用。当 FRP 重计算发生时,某些事件、或是某些新的发现会令当前的计算失效——例如,当某些计算的调用栈中同时包含了用户代码和系统代码的时候。如果这种情况发生了, FRP 引擎就需要返回栈顶,从而能够安全地开启新一轮计算。由于我们的用户代码是纯函数式的,在其间中止执行非常简单,也不会发生错误的副作用。同时,由于使用了类型化的异常,我们的引擎代码能够被彻底审查并加固,以此来保证满足所有的不变量的条件。

中止的设计借鉴于 capability playbook 。首先,我们引入了一个基类,叫做 AbortException。它可以直接使用,也可以派生出子类。有一点比较特别:没人能够捕获这个异常然后忽略它。如果有任何 catch 试图捕获它,它就会自动地 catch 块的结尾再次抛出。我们把这种异常叫做 不可屏蔽的(undeniable)

但总是要有人能够捕获中止的。整个思路就是退出某个上下文,而不是放弃整个进程。我们将会看到如何获得捕获中止的能力。下面是 AbortException 的样子:

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

需要注意的是,构造函数接收了一个不可变的 token;如果不想再继续向上层抛出,就需要调用Reset 方法,同时必须传入一个相同的 token。如果 token 不匹配的话,就触发放弃。这里的思路是,抛出和想要捕获中止的模块通常是同一个,或者二者之间至少有协同关系,能够彼此安全地共享 token。这是一个典型的对象具有不可伪造特性的例子。

当然,调用栈上的任何一段代码都可以触发放弃,但只要简单地对 null 解引用就可以实现了。这种技术禁止在中止上下文中执行,因为上下文可能还没有作出足够的准备。

一些其他的框架也有类似的模式。.NET Framework 中有 ThreadAbortException,这同样是一种不可屏蔽的异常,只有调用 Thread.ResetAbort 才可以将其捕获。不过,由于这种异常不是基于能力的(译注:参见 Compatibility-based Security),因此它需要安全注释(Security Annotations)和宿主 API 的同时努力,才能够防止中止被意外地屏蔽。老话重提,这种异常也是不受检查的。

由于异常是不可变的,上文提到的 token 也是不可变的,一种常见的做法是把这些东西利用单例缓存到静态变量中。例如:

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

    void Abort() throws AbortException {
        throw abortException;
    }

    void TopOfTheStack() {
        while (true) {
            // 调用一些其他函数
            // 调用栈的某处可能会中止,我们将其捕获并重置:
            let result = try ... else catch<AbortException>;
            if (result.IsFailed) {
                result.Exception.Reset(abortToken);
            }
        }
    }
}

这种模式使得中止非常的高效。平均来说一次 FRP 计算会中止多次。要记得, FRP 是系统中所有 UI 的基石,因此不能因为使用了异常就影响性能——申请一个异常对象可能都会不幸地触发 GC。

可选的“Try” API

上文提到过,很多操作失败时都会触发放弃,例如申请内存、算术计算溢出或是零除,等等。在某些情况下中,有一些失败更适合动态的错误传播和恢复,而不是直接触发放弃。当然,放弃在大多数情况下都是更好的选择。

这也变成了一种模式。不是很常用,但我们也提供。我们提供了一系列的算数 API,它们使用了数据流风格的传播(Propagation)来处理溢出、NaN 或是其他的情况。

我在前面已经提到了这项功能的具体例子——我们使用 try new 来申请内存,这时内存不足将产生一个可恢复错误,而不是触发放弃。这极其少见,但有时还是很有用的,例如当你想申请一大块缓存来做一些多媒体操作的时候。

看守人

这里介绍的最后一种模式,叫做 看守人模式(the keeper pattern)

在很多方面,可恢复异常都是“由内而外”地进行处理。一堆代码执行了,向下传递了一些参数,知道某段代码发现自己的状态有点问题。然后,在异常模型中,控制流重新沿着调用栈向上传播,进行栈展开,直到某段代码能够处理这个错误。如果此时决定需要重试的话,就再次执行一系列的操作。

一种可选的替代模式是使用看守人。看守人是一个对象,它知道如何“就地”从错误当中恢复,因此调用栈就不需要展开了。相反,之前需要抛出异常的代码,现在可以咨询看守人,而它就可以对如何继续执行做出指示。看守人的好处之一是,如果它作为一项配置能力时(configured capability),周围的代码甚至不需要知道它们的存在。这就与异常不同,在我们的系统中,异常是类型系统的一部分。看守人的另一个好处是,它们非常简单,代价也不高。

Midori 中的看守人可以用来做一些提示的操作,不过更常见的是用来跨越异步的边界。

看守人典型的使用场景是对文件系统操作进行保护。访问文件系统中的文件和目录通常会由于这些原因失败:

  • 不合法的路径
  • 文件没找到
  • 目录没找到
  • 文件正在使用
  • 权限不足
  • 设备已满
  • 设备写保护

一种方案是,对每一个文件系统 API 都标注为 throws。或者像 Java 一样,创建一套 IOException 的体系,把它们作为子类。而另一种方案就是使用看守人。这保证了应用程序整体上不需要知道、或是不需要关心 IO 错误,能够让错误恢复逻辑集中在一起。这个看守人的接口可能会是这样:

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;
}

这里的想法是,对于每种情况,当错误发生时,相关的输入都会提供给看守人。看守人随后会执行一些操作——也许是异步的——来进行恢复。很多时候,看守人可以选择返回一个新的参数来执行这项操作。例如,InsufficientPrivileges 可以返回一个替代的 Credentials 来使用。(程序可以向用户弹出一个提示框,让用户切换到有写权限的账户。)对于上面列举的每一种情况,如果看守人不想处理的话,也可以抛出异常,不过这个功能不是看守人模式必须提供的。

最后,我应该说明的是,Windows的结构化异常处理(Structured Exception Handling,SEH)支持“可继续的”异常,它们从概念上讲是一样的。SEH 允许让某段代码来决定如何重新执行失败的计算。不过遗憾的是,SEH 使用了调用栈上的环境处理函数,而不是语言中的一等公民对象,这让它看起来不那么美观,而且更容易出错。

后续的方向:Effect Typing

很多人都问过我们,在类型系统中加上 asyncthrows 会不会让库代码变得更加臃肿。答案是“不会”。不过在高度多态的库代码中,这确实是件令人头痛的事。

最让人不安的是那些组合子,例如 map,filter,sort,等等。在这些情况下,你通常会希望那些带有 asyncthrows 的函数能够透明地“流过”。

我们用来解决这个问题的方案是,允许你使用效果(Effect)来参数化类型。例如,这是一个通用的映射函数 Map,它把它的 func 参数上的 async 或是 throws 效果进行传播:

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。而后我们就可以用 E 来符号化地用在 Map 的效果列表里,同时还在“传播”时使用了它——通过 effect(E) 来传播调用 func 时产生的效果。我们可以简单地把 E 替换为 throwseffect(E) 替换为 try,来看一看背后的逻辑转换。

Map 的合法调用如下:

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

由于 throws 会“流过”内部操作,我们因此可以传入一个会抛出异常的回调函数。

总而言之,我们进一步讨论了这个想法,并允许程序员声明任意多的效果。我之前就设想过这样的类型系统。不过我们还是有些顾虑,无论这种高阶编程多么强大,它们也许都只是小聪明,而且还难以理解。上面这个简单的模型也许看起来不错,如果在多给我几个月的时间,我们也许能够把它实现出来。