近些年来,开发者可以通过代码契约(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 则希望能够得到两种选择: