lotuc
首发于lotuc
Programming Languages: Application and Interpretation【译16】

Programming Languages: Application and Interpretation【译16】

初翻: @MrMathematica

原文:PLAI 第二版

GitHub:PLAI-cn

GitBook:PLAI-cn

翻译声明见 Github 仓库


16 动态地检查程序中的不变量:契约

类型系统提供了丰富且有价值的表示程序不变量的方式。然而,它们也代表了一种重要的权衡,因为并非所有程序的非平凡属性都可以被静态验证。【注释】此外,即使某个属性可以设计静态方法解决,注解和计算复杂度的负担也可能过大。因此,我们所关心的一些属性不可避免地只能被忽略或在运行时解决。本章我们来讨论运行时检查不变量。

这是一个正式的定理,被称为赖斯定理

实际上,每种编程语言都包含某种形式的断言机制,使程序员能够编写比语言的静态类型系统允许的更丰富的属性。在没有静态类型的语言中,这些属性可能以简单的类型断言开始:例如,某个参数是否为数。然而,断言语言通常是整个编程语言,因此任何谓词都可以用作断言:例如,某个加密包的实现可能希望确保某些参数通过素性测试,或者某个平衡二叉搜索树可能想要确保其子树确实是平衡且有序的。

16.1 以契约实现谓词

因此很容易想到如何实现简单的契约(contract)。【注释】契约包含一个谓词。它读入一个值并将谓词应用于该值。如果值能通过谓词判断,则契约原样返回该值;否则,该契约会报告错误。其行为只能是返回原值或报错:它不应以任何方式更改值。简而言之,对于能通过谓词的值,契约本身就是恒等函数。

下面我们将使用#lang plai语言,原因有两个。首先,这更好地模拟了动态类型语言编程。其次,为了简单起见,我们将契约写成类型化的断言,但是在静态类型语言中,它们将由类型检查器处理,使得我们无法看到运行时行为。从效果来看,“关闭”类型检查器会更容易。然而,即使在静态类型的世界里,契约也是非常有意义的,因为它们增强了程序员可以表达的不变量。

我们使用如下函数编码该本质:

(define (make-contract pred?)
  (lambda (val)
    (if (pred? val) val (blame "violation"))))

(define (blame s) (error 'contract "~a" s))

契约的例子:

(define non-neg?-contract
  (make-contract
   (lambda (n) (and (number? n)
                    (>= n 0)))))

(在静态类型语言中,检查number?当然是不必要的,因为它可以在类型系统中使用契约函数的方式静态检查!)假设我们要确保计算平方根时不会得到虚数;可以这么写

(define (real-sqrt-1 x)
  (sqrt (non-neg?-contract x)))

在很多语言中,断言是写作语句而不是表达式,所以另一种编写方式是:

(define (real-sqrt-2 x)
  (begin
    (non-neg?-contract x)
    (sqrt x)))

(在某些情况下,这种形式更清晰,因为它在函数的开始部分清晰地声明了参数的期望值。它还确保参数只被检查一次。实际上,在某些语言中,契约可以写入函数头部中 ,从而改善接口给出的信息。)现在,如果将real-sqrt-1real-sqrt-2应用于4,则它们产生2,但如果应用于-1,则会引发违反契约的错误。

16.2 标签、类型和对值的观测

到这里我们已经重现了大多数语言中断言系统的本质。还有什么要讨论的?我们先假设手上的语言不是静态类型的。我们希望编写的断言至少要能重现传统的类型不变量,甚至更多。前述的make-contract可以覆盖所有标准类型的属性,比如检查数、字符串等等,假设语言提供了合适的谓词,或者可以从已有的谓词中构造出来。是这样吗?

回想一下,即使我们最简单的类型语言也不仅仅只包含数等基本类型,还包含构造类型。尽管其中的一些,如链表和向量,似乎并不是很难,但一旦涉及赋值、性能和问责,挑战就来了,后面将讨论它们。然而,函数就很难处理了。

作为示例,我们来看这个函数:

(define d/dx
  (lambda (f)
    (lambda (x)
      (/ (- (f (+ x 0.001))
            (f x))
         0.001))))

其静态类型是:

((number -> number) -> (number -> number))

(它读入一个函数,并生成其导数——也是个函数。)假设我们想用契约来处理这种情况。

根本的问题是,在大多数语言中,我们无法直接将其表示为谓词。大多数语言的运行时系统关于值的类型存储了非常有限的信息——相对于我们目前所看到的类型,这些信息太过有限,我们应该用不同的名称来描述它们;传统上它们被称为标签(tag)。【注释】有些情况下,标签与我们认为是类型的不谋而合:例如,数会带上标识其为数的标签(甚至可能是某种特定类型的数)、字符串带有标识其为字符串的标签,等等。因此,我们可以基于这些标签的值来编写谓词。

已经有一些工作试图保存丰富的类型信息,从源程序到较低的抽象层次、一直到汇编语言,但这些都是研究工作。

当我们处理结构化值时,情况就复杂了。向量将会带有标签声明它是向量,但不会指明它的元素是什么类型的值(而且它们甚至可能都不是同一类型);不过,程序通常也可以获得向量的大小,从而遍历向量来收集此信息。(然而,关于结构化值后面还更多有待讨论的。)

思考题

编写契约,检查只包含偶数的链表。

这就是:

(define list-of-even?-contract
  (make-contract
   (lambda (l)
     (and (list? l) (andmap number? l) (andmap even? l)))))

(同样,请注意,如果我们静态地知道这是数的链表,则无需问前两个问题。)类似地,对象可能只将自己标识为对象,而不提供其他信息。但是,在允许对对象结构进行反射(reflection)的语言中,契约仍可以收集它所需的信息。

然而,在任何语言中,当遇到函数时就出问题了。我们一般将函数的类型理解为包含其输入和输出的类型,但是对运行时系统,函数只是带有函数标签的不透明对象,可能还有一些非常有限的元数据(如函数的参数数量)。运行时系统甚至难以分辨函数是否读入和生成函数——而非其他类型的值——更不用说判断它是否读入并生成(number -> number)类型的函数了。

这个问题很好地体现在JavaScript的(错误命名的)typeof运算符中。传给其数或字符串等基本类型的值,typeof会返回对应类型名字的字符串(例如"number")。对于对象,它返回"object"。最要命的是,对于函数它返回"function",没有额外的信息。

出于这个原因,typeof对这个操作符来说可能是个糟糕的名字。它应该被称为tagof,为未来的可能出现的JavaScript静态类型系统留下的typeof这个名字。

总而言之,这意味着当遇到函数时,函数契约只能检查它是否的确是函数(如果不是,那显然是错误的)。它无法检查有关该函数的定义域和值域的任何信息。我们要放弃吗?

16.3 高阶契约

为了确定要做什么,我们先回忆一下契约最初提供了什么保证。在前述的real-sqrt-1中,我们要求参数是非负的。然而,只有在real-sqrt-1被实际使用时才会进行检查,并且仅检查实际传入的值。例如,如果程序包含片段

(lambda () (real-sqrt-1 -1))

但该thunk一直没被调用,那么程序员将永远不会看到这里的契约被违反。事实上,可能在程序的这次运行中没有调用此thunk,但在后一次运行中调用到了;因此,该程序包含一个潜在的契约错误。出于此原因,通常最好用静态类型来表示不变量;但在使用契约时,我们明白,仅当程序执行到相关位置时,我们才会收到错误通知。

这是有用的见解,因为它为我们的函数问题提供了解决方案。对于指明的函数值,我们立即检查它真的是函数。但是,我们不会忽略定义域和值域的契约,而是延迟处理。我们在函数(每次)实际作用于某个值时检查定义域契约,并在函数实际返回值时检查值域契约。

这显然和make-contract不是一种模式。因此,我们给make-contract起个更具描述性的名称:它检查即时的(immediate)契约(即当前可以完整检查的契约)。

在Racket契约系统中,即时契约被称为扁平的(flat)。这个术语有点误导,因为它们也可以保护数据结构。
(define (immediate pred?)
  (lambda (val)
    (if (pred? val) val (blame val))))

相比之下,函数契约读入两个契约作为参数——分别表示对定义域和值域的检查——并返回谓词。这个谓词作用于需要满足契约的值。首先,它会检查给定的值实际上是函数:这部分仍然是即时的。然后,我们创建一个代理(surrogate)函数,由它来应用“剩余的”契约——检查定义域和值域——但其他方面与原函数行为相同。

创建代理这一行为背离了传统的断言机制,也就是只是简单地检查而不改变值。相反,对于函数,如果想要检查契约,我们必须使用新创建的代理。因此,一般来说我们需要创建封装函数,它会读入契约和值,并创建该值的保护版本:

(define (guard ctc val) (ctc val))

一个非常简单的例子,假设我们要用数契约包装add1函数(使用稍后定义的函数契约的构造函数function):

(define a1 (guard (function (immediate number?)
                            (immediate number?))
                  add1))

我们希望a1本质上绑定到以下代码:

(define a1
  (lambda (x)
    (num?-con (add1 (num?-con x)))))

其中(lambda (x) ...)是代理;它会add1的调用之处前后调用数值契约。回忆一下,在没有违规的情况下,契约的行为就是恒等函数,所以这个程序在不违规的情况下行为于add1完全相同。

为了达到此目的,我们使用下面的function定义。【注释】请记住,我们还必须确保给定的值真的是函数(这里的add1的确是,这一点可以立即检查,这也是为什么在我们将代理绑定到a1时此项检查已经消失的原因):

(define (function dom rng)
  (lambda (val)
    (if (procedure? val)
        (lambda (x) (rng (val (dom x))))
        (blame val))))
简单起见,我们这里假设单参数函数,不过扩展到多参数的情况很简单。事实上,更复杂的契约甚至可以检查参数之间的关系。

要理解这是如何工作的,我们来替换参数。为了保持代码可读性,我们先构造number?契约检查器,并将其命名:

(define num?-con (immediate number?))
= (define num?-con
    (lambda (val)
      (if (number? val) val (blame val))))

回到a1的定义。我们先调用guard

(define a1
  ((function num?-con num?-con)
   add1))

接下来调用函数契约的构造函数:

(define a1
  ((lambda (val)
     (if (procedure? val)
         (lambda (x) (num?-con (val (num?-con x))))
         (blame val)))
   add1))

调用左括号-左括号-lambda得:

(define a1
  (if (procedure? add1)
      (lambda (x) (num?-con (add1 (num?-con x))))
      (blame add1)))

请注意,这一步会检查被保护的值的确是函数。因此我们得到

(define a1
  (lambda (x)
    (num?-con (add1 (num?-con x)))))

这正是我们想要获得的代理,对于不违规的调用,其行为就是add1

思考题

有多少种方式可以违背上述的add1契约?

三种方式,分别对应于三个契约构造函数:


  1. 被封装的值可能不是函数;
  2. 被封装的是函数,它可能被作用于不为数的值;或者
  3. 被封装的是函数,输入也是数,但其返回值不是数类型。

练习

编写示例实现这三种违规行为,并观察契约系统的行为。你能改进错误信息以更好地区分这些情况吗?

同样的封装技术也适用于d/dx

(define d/dx
  (guard (function (function (immediate number?) (immediate number?))
                   (function (immediate number?) (immediate number?)))
         (lambda (f)
           (lambda (x)
             (/ (- (f (+ x 0.001))
                   (f x))
                0.001)))))

练习

违反此契约的方式有七种,分别对应于七个契约构造函数。根据需要,传入(错误的)参数或修改代码,以违反它们中的每一个。是否可以改进错误报告,以正确识别每种违规行为?

请注意,嵌套函数的契约推迟了两处即时契约的检查,而不是一处。这符合我们的期望,因为即时契约只能报告实际值的问题,所以直到应用于实际值之前,它们无法报告任何错误。但是,这确实意味着“违规”这个概念很微妙:传递给d/dx的函数值可能的确违反了契约,但这类违规只有在传递或返回数值之后才会被观测到。

16.4 便捷语法

之前我们看到了两种扁平契约的使用风格,分别由real-sqrt-1real-sqrt-2体现。这两种风格各有缺点。后者让人联想到传统的断言系统,它不能用于高阶值(函数),因为被封装的值才需要检查。(当然,传统的断言系统只处理扁平契约,所以它们忽略了这个细微的差别。)前者将值的使用放与契约之中,理论上这可行,但有三个缺点:


  1. 开发人员可能会忘记封装某些使用。
  2. 契约在每次使用中都会被检查一次,在多次使用时这是浪费。
  3. 程序混合了契约检查和其功能行为,降低了可读性。

幸运的是,一般情况下,明智地使用语法糖就可以解决此问题。例如,假设我们要将契约附加到函数的参数上,那么开发人员可以这么编写:

(define/contract (real-sqrt (x :: (immediate positive?)))
  (sqrt x))

意图是用positive?来保护x,但只在函数调用时只执行一次检查。这应该转化为:

(define (real-sqrt new-x)
  (let ([x (guard (immediate positive?) new-x)])
    (sqrt x)))

也就是说,宏为每个标识符生成新名称,然后将用户给出的名称关联到新名称的封装版本。这个宏的实现如下:

(define-syntax (define/contract stx)
  (syntax-case stx (::)
    [(_ (f (id :: c) ...) b)
     (with-syntax ([(new-id ...) (generate-temporaries #'(id ...))])
       #'(define f
           (lambda (new-id ...)
             (let ([id (guard c new-id)]
                   ...)
               b))))]))

有了这些(语法上的)便利,契约语言的设计师可以提高契约使用的可读性、效率和健壮性。

16.5 扩展到复合数据结构

正如我们已经讨论过的,将契约扩展到结构化数据类型(如链表、向量和用户定义的递归数据类型)似乎很容易。只需要提供适当的对运行时观测集。一般来说这取决于语言提供类型的精度。例如,正如我们之前讨论过的,支持数据类型的语言不需要类型谓词,但仍然会提供区分变体的谓词;这种情况下,类型级别的“契约”检查最好(也许必须)留给静态类型系统,而由契约来断言更精确的结构特性。

但是,这种策略可能会遇到严重的性能问题。例如,假设我们编写了平衡二叉搜索树,能以对数渐近时间(相对树的大小)实现插入和查找。接下来我们将树封装在合适的契约中。遗憾的是,仅检查契约就会访问整个树,从而用去线性时间!因此,理想情况下更好的策略是,构建树的时候就(以增量方式)完成契约检查,查找时则不需要再次检查。

更糟的是,平衡和顺序都是搜索树的递归属性。因此原则上,每个子树都应满足,所以每次递归调用都需要检查。在插入过程中,由于插入是递归的,将在每个访问的子树上检查契约。在大小为 t 的树中,契约谓词应用于 \frac{t}{2} 元素的子树,然后应用于 \frac{t}{4} 元素的子子树,依此类推,在最坏情况下,会访问总数为 \frac{t}{2}+\frac{t}{4}+...+\frac{t}{t} 的元素——使我们预期的对数时间插入过程花费线性时间。

对这两个例子,许多情况下都可以采用措施缓解。每个值都需要与它已经通过的一组契约相关联(或内部存储,或存储于散列表中)。然后,当需要调用契约时,首先检查它是否已被检查过,如果有,则不再检查。这实质上是将契约检查记忆化(memoization),从而减少检查的算法复杂性。当然,对记忆化而言,最好值是不可变的。如果这些值可能发生变化,并且契约执行任意计算,那么此优化可能无法做到可靠。

检查数据结构还有一个微妙的问题。作为例子,考虑我们之前编写的检查数链表中所有值均是偶数的契约。假设我们已经用契约封装了链表,但只对链表的第一个元素感兴趣。当然,我们检查了列表中的所有值,这可能需要很长时间。但更重要的是,用户可能会争辩说,报告链表第二个元素违规的行为本身违反了我们对契约检查的期望,因为我们并未实际使用该元素。

这意味着推迟检查某些值,即使它们可以即时被检查。例如,可以将整个链表转换为包含延时检查的封装值,每个值仅在访问时被检查。这种策略可能很有吸引力,但编码该策略并不简单,尤其当存在别名的情况下会遇到问题:如果两个不同的标识符引用同一链表,一个有契约保护而另一个没有,我们必须确保它们都按预期运行(这通常意味着我们不能在链表中存储任何可变状态)。

16.6 再论契约和观测

契约实现还有一个奇怪的普遍问题——遇到复杂数据时更甚。之前,我们抱怨说检查函数的契约很难,因为我们观测能力受限:我们能检查的只有值是否是函数。在真实的语言中,数据结构的问题其实是相反的:我们的观测能力过剩。例如,如果我们实施延迟检查链表的策略,则很可能需要使用某个结构体来保存实际链表,并修改firstrest,以此(检查契约后)获取结构体中的值。但是,像list?这样的函数现在可能返回false而不是true,因为结构体不是链表;因此,list?需要绑定到新函数上,遇到这些特殊的表示链表的延迟契约结构体也返回true。但契约系统作者还需要记得解决cons?pair?,天知道还有多少其他函数都可以执行观测操作。

一般来说,有一个观测基本上不可能“修复”:eq?。通常情况下,每个值eq?它自己,即使函数也是如此。然而,函数封装以后就是新的函数了,不但eq?自己,也不应该,因为其行为真的不同了(尽管只是在违反契约的情况下,并且只在提供了足够多的输入值得以观测到违规行为后)。然而,这意味着程序无法暗中保护自己,因为守护行为可以被观测到。因此,恶意模块有时可以检测它收到的是否是受保护的值,如果是就正常运行,否则就不!

16.7 契约和赋值

我们无疑应该关注契约与赋值之间的相互作用,当契约延迟检查——固有延迟或者以延迟方式实现——时更是如此。有两件事值得关注。一是将契约值存储在可变状态中;二是可变状态编写的契约。

当我们存储契约值时,封装策略确保契约检查正常进行。在每个步骤,契约都会尽可能多地检查现有的值,并创建包含其余检查的封装值。因此,即使这个封装值被存储在可变状态并在稍后检索以供使用,它仍然包含这些检查,并且当值最终被使用时它们将被执行。

另一个问题是编写可变数据的契约,如box和向量。在这种情况下,我们可能必须为包含契约的整个数据类型创建封装。然后,当数据类型中的值被替换为新值时,执行更新的操作(例如set-box!)需要从封装中检索契约,将其应用于新值并存储新封装的值。因此,这需要修改数据结构赋值操作符的行为,使其对契约值敏感。然而,赋值不会改变违规行为的发生点:即时契约即时发生,延时契约遇到(非法)输入值时发生。

16.8 契约的组合

我们已经讨论过所有基本数据类型的组合,本节很自然要契约的组合。正如之前讨论的联合交叉类型一样,我们应该考虑契约的联合和交叉(分别是“或”与“和”);还应当考虑取反。然而,契约只是表面上类似于类型,所以我们必须根据契约来考虑这些问题,而不是试图将我们从类型学到的意义映射到契约领域。

直接的例子总是简单的。联合契约通过析取组合——事实上,因为是谓词,其结果可以字面上用or组合——而交叉契约通过合取组合。我们依次调用谓词,进行短路求值(译注,参见后文),最后产生错误或返回契约的值。交叉契约通过合取(and)组合。而取反契约就是直接调用原始的契约,但对谓词取反(通过not)。

在延迟、高阶的情况下,契约组合要困难得多。例如,考虑对数到数的函数的契约进行取反。这里取反到底是什么意思?是否表示该函数应接受数?或者如果接受了数,它不应该返回数?或两者都要?特别是,我们如何执行这样的契约?例如,如何检查某个函数不接受数——是否期望在给予数时会产生错误?但请考虑用这样的契约封装的恒等函数;因为当给予数(或者其他任何值)时,它显然不会出错,这是否意味着应该等到它产生值,如果它确实产生了数,那么拒绝它?但最糟糕的是,请注意,这意味着我们将在定义的定义域中运行函数:显然这会破坏程序中的不变量、污染堆栈、或使程序崩溃。

交叉契约要求值通过所有子契约。这意味着高阶值需要重新封装,检查所有定义域子契约以及所有值域子契约。只要一个子契约没有满足,整个交叉(契约)都会失败。

联合契约更加微妙,因为任何一个子契约失败都不直接导致值被拒绝。相反,它只是意味着这个子契约不再是所封装值所遵守的契约;其他子契约仍然可能被遵守,只有当没有任何子契约候选时才拒绝值。这意味着联合契约的实现中必须记录哪些子契约通过或失败——这里,记录就意味着赋值。【注释】由于每条子包契约失败时,它将被从候选名单删除,而剩下的会继续执行。当没有候选子契约时,系统必须报告违规行为。错误报告最好要提供导致每个子契约失败的实际值(请记住,这些值可能嵌套在多层函数中)。

在类似Racket的多线程语言中,还需要加锁以避免竞争条件。

Racket所实现的契约构造器和组合器对可接受的子契约形式提出了限制。这使得实现既有效率又能提供有用的错误消息。此外,上面讨论的极端情况很少在实践中出现——当然现在如果需要你知道如何实现它们。

16.9 问责

本节回过头讨论报告契约违反的问题。这指的不是打印什么字符串,而是更重要的问题,报告什么。我们将看到,此问题实际上是语义上的考虑。

为了说明这个问题,回想一下上面d/dx的定义,假设我们在没有任何契约检查的情况下运行。先假设我们将这个函数应用于完全不合适的string-append(它既不读入也不产生数)。这么做只会产生一个值:

> (define d/dx-sa (d/dx string-append))

(请注意,即使有契约检查,这也会通过,因为函数契约的即时部分认可string-append是函数。)接下来假设我们将d/dx-sa应用于一个数,这应是正常行为:

> (d/dx-sa 10)
string-append: contract violation
  expected: string?
  given: 10.001

请注意,错误报告位于d/dx函数体的内部。一方面,这完全是合理的:这是string-append不正确调用发生的地方。另一方面,错误并非来自d/dx,而来自声称string-append是合法的数到数的函数的代码。但问题是,做这件事的代码早已逃之夭夭;它已经不在堆栈中,因此也不在传统错误报告机制的范围内。

这个问题不是d/dx所特有的;事实上,大型系统中它很常见。这是因为系统——尤其是含有图形、网络和其他外部接口的系统——中大量使用回调(callback):对某个实体感兴趣而被注册的函数(或方法),要发某种状态或值的信号时被调用。(在这里,d/dx等价于图形层,而string-append等价于传给它(并由它存储)的回调。)最终,系统层会调用回调。如果这会导致错误,那既不是系统层的错误,它收到的回调符合要求的契约,也不是回调本身的错误,它应该有合理的用途,只是被错误地提供给函数。相反,错误来源于引入这两者的实体。然而,此时调用栈只包含回调(位于栈顶)和系统(位于其下)——唯一有错的一方不在了。这种类型的错误因此非常难调试。

解决办法是扩展契约系统,纳入问责(blame)的概念。想法是,有效地记录将一对组件组合在一起的那个实体,以便如果它们之间发生契约违规,我们可以将失败归因于该实体。请注意,这只在函数的情况下才有实际意义,但为了一致性,我们以自然的方式也将问责扩展到即时契约中。

对于函数,请注意有两种可能的失败点:要么它被给予了是错误的值(先验条件),要么是它生成了错误的值(后验条件)。区分这两种情况很重要,因为在前一种情况下,我们应该将错误归咎于环境——这里,也即实参表达式——而在后一种情况下(假设参数已经通过),则应归咎于函数本身。(对即时值的自然扩展是我们只能对值值本身不满足契约进行问责,也就是“后验条件”)。

对于契约,我们引入术语(positive)和(negative)位置。对于一阶函数,负位置是先验条件,正位置是后验条件。这么看这似乎是不必要的额外术语。但我们很快就会看到,这两个术语具有更一般的含义。

现在将情况推广到契约的参数。之前,即时契约读入一个谓词,而函数契约读入定义域和值域的契约。这点保持不变。不过它们返回的将是函数,此函数有两个参数:正负位置的标签。(这个标签可以是任何合理的数据类型:抽象语法节点、缓冲区偏移量、或其他描述符。简单起见,我们使用字符串。)这样,函数契约将闭包于程序位置标签,以便将来对非法函数的提供方进行问责。

现在由guard函数负责传入契约调用位置的标签:

(define (guard ctc val pos neg) ((ctc pos neg) val))

blame显示合适的标签(由契约实现传给它):

(define (blame s) (error 'contract s))

假设我们像以前一样,保护add1的使用。正负位置用什么名字有意义呢?正位置是后验条件:这里的任何失败都必须归咎于add1的函数体。负位置是先验条件:这里的任何失败都必须归咎于add1的参数。因此:

(define a1 (guard (function (immediate number?)
                            (immediate number?))
                  add1
                  "add1 body" ;add1函数体
                  "add1 input")) ;add1的输入

假设传给guard的不是函数,我们会期望在“后验条件”位置出现错误:这并不是后验条件的失败,而是因为,如果调用的不是函数,不能去指责参数。(当然,这表明我们这里扩展了术语“后验条件”,更合理地应该使用术语“正(位置)”。)因为相信add1的实现只会返回数,所以我们预计它不可能让后置条件失败。当然,我们期望像(a1 "x")这样的表达式触发先验条件错误,可以在"add1 input"位置处发出契约错误。相反,如果我们保护的函数违反了后验条件,比如这样,

(define bad-a1 (guard (function (immediate number?)
                                (immediate number?))
                      number->string
                      "bad-add1 body"
                      "bad-add1 input"))

我们希望将责任被归咎于"bad-add1 body"

接下来讨论如何实现这些契约构造函数。对于即时契约,我们说过应问责正位置:

(define (immediate pred?)
  (lambda (pos neg)
    (lambda (val)
      (if (pred? val) val (blame pos)))))

对于函数,我们可能想这么写

(define (function dom rng)
  (lambda (pos neg)
    (lambda (val)
      (if (procedure? val)
          (lambda (x) (dom (val (rng x))))
          (blame pos)))))

但是这根本不能运作:它违反了契约所预期的签名。这是因为,现在所有契约都期望输入正负位置的标签,也就是domrng不能像上面那样使用。(另一个理由,函数体中用到了pos,但完全不含neg,尽管已经看到过一些例子,我们认为责任必须归咎于neg所绑定的位置。)所以很明显,我们要以某种方式使用posneg实例化的值域和定义域契约,以便它们“知道”和“记住”可能调用非法函数的地方。

最显然的做法是用相同的domrng值实例化这些契约构造函数:

(define (function dom rng)
  (lambda (pos neg)
    (let ([dom-c (dom pos neg)]
          [rng-c (rng pos neg)])
      (lambda (val)
        (if (procedure? val)
            (lambda (x) (rng-c (val (dom-c x))))
            (blame pos))))))

现在所有签名都匹配了,我们可以运行契约了。但这样做时,返回不太对劲。比如,在我们最简单的违反契约的例子中,返回是

> (a1 "x")
contract: add1 body

咦?也许我们应该展开a1的代码,来看看发生了什么。

(a1 "x")
= (guard (function (immediate number?)
                   (immediate number?))
         add1
         "add1 body"
         "add1 input")
= (((function (immediate number?) (immediate number?))
    "add1 body" "add1 input")
   add1)
= (let ([dom-c ((immediate number?) "add1 body" "add1 input")]
        [rng-c ((immediate number?) "add1 body" "add1 input")])
    (lambda (x) (rng-c (add1 (dom-c x)))))
= (let ([dom-c (lambda (val)
                 (if (number? val) val (blame "add1 body")))]
        [rng-c (lambda (val)
                 (if (number? val) val (blame "add1 body")))])
    (lambda (x) (rng-c (add1 (dom-c x)))))

可怜的add1:它都没有获得机会!剩下的唯一问责标签是"add1 body",所以只能归咎于它了。

等下会讨论此问题,先来观察上面的代码,其中没有任何函数契约的踪迹。我们有的只是即时契约,当实际值(如果)发生时进行问责。这与我们之前所说只能观测到即时值完全一致。当然,这只适用于一阶函数;当遇到高阶函数时,这不再成立。

错在哪里?请注意,在add1函数体中只有绑定到rng-c的契约应该被问责。相反,add1的输入中应该被问责的是绑定到dom-c的契约。看起来,在函数契约的定义域位置,正负标签需要……交换。

考虑契约保护的d/dx,我们会发现情况确实如此。关键的见解是,当调用的函数作为参数时,“外部”成为“内部”,反之亦然。也就是说,d/dx的函数体——处于正位置——调用了被求微分的函数,将这个函数的函数体置于正位置,并将调用者——d/dx的函数体——置于负位置。因此,在契约的定义域一侧,每次嵌套函数契约都会导致正负位置交换。

值域一侧无需交换。继续考虑d/dx。它返回的函数代表导数,所以它的输入是数(代表计算导数的点),返回也是数(该点的导数)。这个函数的负位置就是使用微分函数的客户——即先验条件——正位置就是d/dx本身——即后验条件——因为它负责生成导数。

这样,我们就更正的、正确的函数构造函数的定义:

(define (function dom rng)
  (lambda (pos neg)
    (let ([dom-c (dom neg pos)]
          [rng-c (rng pos neg)])
      (lambda (val)
        (if (procedure? val)
            (lambda (x) (rng-c (val (dom-c x))))
            (blame pos))))))

练习

将此应用于之前的例子,确认得到的问责符合预期。此外,手动展开代码以了解为何。

更进一步,假设我们定义d/dx的正位置标签为"d/dx body",负位置标签为"d/dx input"。假设我们传给它函数number->string(此函数明显无法计算导数),然后将结果应用于10

((d/dx (guard (function (immediate number?)
                        (immediate string?))
              number->string
              "n->s body"
              "n->s input"))
 10)

这正确地表明,应该归咎于将number->string假定为数函数提供给d/dx的表达式——而不是d/dx本身。

练习

手工计算d/dx,将其作用于所有相关的违规情况,并确认由此产生的问责是准确的。如果你将string->number传给d/dx,附带函数契约指明它将字符串映射到数,会发生什么?如果你在没有契约的情况下传入相同的函数呢?

文章被以下专栏收录