November 7, 2023 by Soluty

契约式编程

blog-feature-image

在人类的社会活动中,契约一般是用于两方,一方(供应商)为另一方(客户)完成一些任务。每一方都期待从契约中获得利益,同时也要接受一些义务。 通常,一方视为义务的对另一方来说是权利。契约文档要清楚地写明双方的权利与义务。契约合同能保障双方的利益. 对客户来说, 合同规定了供应者提供的功能;对供应商来说,合同说明了如果约定的条件不满足,供应商有权利拒绝要完成规定的任务。

同样的情况应用在软件开发过程中, 函数的实现是供应商, 函数的调用者是客户, 而函数的文档则可以视为双方所订的契约. 文档中会有 参数以及返回值的描述,参数满足文档中描述的内容是函数调用方的义务.同样返回一些什么值以及放回的值满足的条件则是函数的义务. 这个其实就是契约式编程的思想.在有些编程语言比如d语言中,语法级别支持了契约式编程.它对这种常见情况添加了in, out代码块, d语言的函数实现如下:

return_type function_name(function_params)
in
{
   // in block
}
out (result)
{
   // in block
}
body
{
   // actual function block
}

一般的防御式编程中,认为在函数中检查错误状况并且上报,是函数本身的义务。而在契约体制下,对于契约的检查并非义务,实际上是在履行权利。一个义务,一个权利,天地之别。

例如这段的代码, if (param == NULL) { ... } 函数中写这段代码意味着这个是函数的义务, 函数必须对每一个参数进行严格的检查并保证任何情况下都没问题,这个极大的添加了函数编写的复杂度, 而这段代码assert(dest != NULL)是检查契约,履行权利。如果条件不满足,那么错误在对方而不在我,我可以立刻“撕毁合同”, 罢工了事, 并且把锅明确的甩到函数调用方, 这可以极大的降低函数本身的编写复杂度。并且在多人合作的过程中, 出了bug定位和分锅也非常简单明确.

契约式编程的思想对于区分错误和异常非常有用, 不满足契约的情况就是异常情况, 其它的情况是错误, 比如一个整数除法的函数,

func div(x int, y int) int

如果契约中有写y不能等于0,则调用方传了0是调用方的责任, 如果没写, 调用方传0, 那么div函数可以返回一个错误而绝对不允许panic.

理论上,文档是自然语言编写而成,它当然可以表示所有的契约, 但是在实际软件的编写的过程中,函数的文档或者注释并没有强制的效果, 在一方违反契约时,并没有办法简单快速的找出违反了哪些契约.所以在实践的过程中有必要对常见的情况进行抽象.

一般而言,契约式编程包含3种检测, require代表检测参数满足的条件,一般在函数运行之前检测, ensure代表检测返回值满足的条件,一般在函数返回前检测.invariant则是代表不变量, 一般函数运行前和运行后都检测.

有些语言通过库对契约式编程提供了一定的支持,如rust.

golang中如果要实现契约式编程,由于golang的源码发布特性以及defer方法的存在, 我通过经常在单元测试中使用的断言库gomega实现了一个小小的package github.com/soluty/golib/contract来满足golang中引入契约式编程的需求.

使用的情况如下:

func div(x, y int) (r int) {
    if contract.Sign {
        Expect(y).Should(BeNumerically(">",0))
        Expect(x).Should(BeNumerically(">",0))
        defer func() {
           Expect(r).Should(BeNumerically(">=",0))
        }
    }
    return x/y
}

一般而言, contract.Sign 代码块位于函数最前面, 代表使用函数时签订的契约. defer是在函数退出是检测. 并且通过编译tag contract来控制是否开启契约的检测.