C#的未来:方法契约

  近些年来,开发者可以通过代码契约(Code Contracts)这个研究性项目获得添加方法级别契约的能力,但这种方式存在许多问题,它所使用的命令式语法相当冗长,并且通过工具提供的语法支持也很差。无论是开发类库或是应用程序,要完整的利用这一契约特性,必须要运行某种编译后指令。总的来说,这是一个有趣的项目,但要真正变得实用,还需要第一等的编译器与语法的支持。

  第 119 号提议——方法契约旨在提供这种支持。这一语法要求在方法签名与方法体之间定义前置与后置条件,与泛型的约束写法类似。下面这个示例展示了该语法的表现形式:

public int Insert (T item, int index) 
    requires index >= 0 && index <= Count 
ensures return >= 0 && return < Count 
{ … }

  这条提议中共包含三个新的关键字。“requires”开头的语句负责处理前置条件,多数情况下将用于检查参数,但理论上也可以用于检查对象本身的状态。“ensures”开头的语句用于设定后置条件,它重用了“return”关键字,以指代该方法调用的返回结果。

  快速失败还是抛出异常

  类似于代码契约,这条提议最初的目的也是产生快速失败。这是一种相当激进的强制契约形式,任何对契约的违反都会立即导致应用的崩溃。在这种模型下,倾向于抛出异常的开发者不得不手动地进行标注:

public int Insert (T item, int index) 
    requires index >= 0 && index <= Count 
        else throw new ArgumentOutOfRangeException (nameof (index)) 
    ensures return >= 0 && return < Count 
{ … }

  对于该提议的这一部分,人们的反对相当激烈。

  Nathan Jervis 写道:

你了解程序中哪些部分不会受到影响,并且能够安全地继续执行下去。可能在某些情况下你会编写某些极其关键的代码,或许你会希望实现快速失败,但我不认为了解你的程序中哪一部分产生错误是一件不可能的事。

以立即干掉整个进程的方式作为正确的处理行为,这种假设让人觉得十分可笑。如果微软的 Word 有某个代码错误,在将文件保存到某个网络路径时产生了一个 bug,你会希望它立即干掉整个应用吗?不,你会希望它能够将文件暂存在某个临时路径下,然后为用户显示一条错误信息,记录这个问题,最后在下次加载文件时让用户选择尝试恢复它。

  HaloFour 也表示附和:

我认为由于参数校验违反契约而导致整个进程崩溃绝对是一种愚蠢的做法。这种实现方式肯定会导致这一特性将无人问津,除非某人有意要写一些难懂的代码。这一特性中的这方面目的在于参数校验,而程序完全能够以某种形式从参数校验错误的情况下恢复正常运行。而且坦白地说,即使程序无法恢复正常,调用者也可以决定不要捕获这个异常。这也是我所看到在 .NET 或其它任何语言中实现的代码契约的工作方式。

  David Nelson 则提到了代码契约的经历:

如果你之前曾经使用过代码契约进行开发,那么你一定注意到它在是否应当采取快速失败这一方式上曾经导致大量的争论。代码契约团队进行了几个月(甚至几年?)的努力,试图说服整个社区:快速失败才是正确的做法,可最终他们还是失败了。我深切感受到了那个极具误解性的决定所带来的后果,这让我无法拥抱这一提议。我曾经是快速失败这一荒谬的方式最坚定的反对者,而且我还会继续反对下去。

  稍后,他明确地列举了快速失败方式所导致的问题:

1) 你怎样记录错误日志?使用 Watson 显然不能满足需求,绝大多数的 .NET 应用程序都不会使用它,因为它提供的信息非常有限并且难以理解,访问这些信息也很困难。我所看到过的每个 .NET 应用程序都会生成独有的错误日志。

2) 只因为某个用户在某种极端情况下遇到了一个无害的逻辑 bug,就要让为全球几百万用户提供服务的某个生产环境中的 web 服务器挂掉,这种做法真的合适吗?

3) 如果某个单元测试违反了契约的话该怎么办?让单元测试执行器崩溃吗?

4) 如果一个程序的错误会导致进程的崩溃,为什么在 .NET 中其它的错误情况下会抛出异常?NullReferenceException 又为什么还会存在,难道不应当直接干掉进程吗?为什么在 JIT 过程中编译某个方法失败时(这很明显意味着存在某个比违反契约严重得多的问题)会抛出异常,而不是直接干掉进程?

  Aaron Dandy 则希望能够得到两种选择:

我当然会使用快速失败,但我只想在我私人的工作中使用它,在公共项目中我还是希望使用异常。如果调用我代码的用户决定用大量的异常去喂饱异常这个怪兽(即选择使用异常),那也是他们自己的选择,他们(同时也隐含了他们代码的用户)也需要为这一决定买单。

  HaloFour 对以下观点表示同意:

我更希望方法契约能够抛出异常(至少是对于 requires 语句来说),然后添加一个语言关键字断言,在某种条件未满足的情况下会快速失败。

  异常类型

  这条提议中比较容易接受的部分是 Argument 异常,编译器会将某个简单的 requires 语句转化为某个 ArgumentNullException 或 ArgumentOutOfRange 异常。如果 requires 语句检查的内容是对象的状态,那么它可以抛出一个 InvalidOperationException 异常。但如果该语句同时检查参数与对象状态呢?这种情况下要决定抛出何种异常会成为一个相当复杂的问题。

  另外一个问题在于 ObjectDisposedException,因为没有什么标准方式能够表现一个被回收的对象。因此只能采取一些宽松的约定,检查是否存在某个叫做_disposed 或m_IsDisposed 之类名称的布尔型字段。这一点的重要性在于,InvalidOperationException 异常一般来说能够通过改变对象状态的方式进行恢复,而 ObjectDisposedException 永远做不到这一点。

  另一方面,需要通过某种异常表示 ensures 语句出错。与 requires 语句不同,在 ensures 契约中的错误总是意味着在方法内部存在 bug。

  本地化

  假设我们采取了某种基于异常的方式,那么接下来的问题就是本地化了。对于基本的参数检查来说,编译器可以简单地生成包含英文文本的参数异常。但如果这些异常信息需要本地化为其它语言呢?如果选择使用简化的语法,就不会明确地抛出某个参数异常。这种情况下,或者需要通过某种渠道添加本地化的信息,或者不得不使用冗长的语法以显示本地化异常信息。

  枚举与契约

  目前为止所讨论的契约都是一种附加条件,而在 Fabian Schmied 所提出的提议中,编译器将允许省略那些绝对不会命中的 return 语句。

public string GetText (MyEnum myEnum) 
requires defined (myEnum) 
{ 
    switch (myEnum) 
    { 
        case One: return "Single"; 
        case Two: return "Pair"; 
        case Three: return "Triple"; 
    }    
    // 所有分支情况都已涵盖,因此即使省略了 return 语句也不会产生错误。 
}

  英文原文:C# Futures: Method Contracts