函数契约、循环不变式与断言
上一节介绍了如何在 C* 中书写空间断言,以及如何用定义机制为链表和树定义表示谓词。本节介绍如何在程序中使用这些断言:函数契约(Function Contracts)([[cst::require]][[cst::require]]、[[cst::ensure]][[cst::ensure]])、循环不变式(Loop Invariants)([[cst::invariant]][[cst::invariant]])与内联断言(Inline Assertions)([[cst::assert]][[cst::assert]])。断言可以用在这三类位置;写好之后,C* 就能沿程序逐句检查。
回顾第一个 C* 程序中的求绝对值函数:
int myabs(int x) [[cst::require(`fact(x > --2147483648i)`)]] [[cst::ensure(` fact( (x >= 0i && __return == x) || (x < 0i && __return == --x) ) `)]]{ ... }
int myabs(int x) [[cst::require(`fact(x > --2147483648i)`)]] [[cst::ensure(` fact( (x >= 0i && __return == x) || (x < 0i && __return == --x) ) `)]]{ ... }
前面已经见过契约的语法要素,这里正式说明各部分的含义:
- 契约写在参数表之后、函数体之前(对只有原型的声明,写在分号之前);
[[cst::require(...)]][[cst::require(...)]]是前置条件:函数开始执行时成立的断言,它刻画函数要操作的内存资源,并约束输入;[[cst::ensure(...)]][[cst::ensure(...)]]是后置条件:函数返回时成立的断言,它刻画返回时的内存资源,并用__return__return描述返回值;- 契约中直接写参数名(如
xx),指的是参数在函数入口处的值。
契约约定了实现者的义务与调用者可以依赖的假设:实现者必须保证函数的每条返回路径都满足契约;调用者则无需阅读函数体,就能依赖契约推理。因此,检查调用点时只需查看契约,无需查看实现。
再看一个也只涉及栈上标量局部变量的例子,McCarthy 91 函数:
int mc91(int n) [[cst::require(`fact(0i <= n)`)]] [[cst::ensure(`fact(n > 100i && __return == n - 10i) || fact(n <= 100i && __return == 91i)`)]]{ if (n > 100) { return n - 10; }; int r1 = mc91(n + 11); int r2 = mc91(r1); return r2;}
int mc91(int n) [[cst::require(`fact(0i <= n)`)]] [[cst::ensure(`fact(n > 100i && __return == n - 10i) || fact(n <= 100i && __return == 91i)`)]]{ if (n > 100) { return n - 10; }; int r1 = mc91(n + 11); int r2 = mc91(r1); return r2;}
这个例子有两点值得注意。
- 其一,条件式后置条件:结论随输入而异时,用
||||分情况讨论,每个分支用&&&&将输入条件与空间断言合取(本例中都是空堆factfact)。 - 其二,递归调用按同一契约检查:验证第 9 和 10 行的递归调用时,都只根据上面的契约推进;在 VSCode 中查看符号状态,就能观察
r1r1和r2r2的取值情况。
后面讨论证明时,我们还会看到:契约必须足够强,否则递归调用处的证明可能无法推进。这与数学中的归纳证明类似:证明过程中常会发现,需要回头加强归纳假设。
接下来用空间断言描述指针参数所指向的内存。一个经典例子是交换两个整数:
void swap(int *a, int *b) [[cst::param(`a_v:int`, `b_v:int`)]] [[cst::require(`data_at a Tint a_v ** data_at b Tint b_v`)]] [[cst::ensure(`data_at a Tint b_v ** data_at b Tint a_v`)]]{ int t = *a; *a = *b; *b = t;}
void swap(int *a, int *b) [[cst::param(`a_v:int`, `b_v:int`)]] [[cst::require(`data_at a Tint a_v ** data_at b Tint b_v`)]] [[cst::ensure(`data_at a Tint b_v ** data_at b Tint a_v`)]]{ int t = *a; *a = *b; *b = t;}
这里出现了第三种契约标注:[[cst::param(...)]][[cst::param(...)]] 声明逻辑参数(Logical Parameters),即只在逻辑层存在的额外参数。在本例中,a_va_v、b_vb_v 是两个内存位置的初始内容:程序无需关心它们的具体取值,但后置条件需要引用这些初值,才能表达“交换”的含义。逻辑参数通常无需在调用点传递:符号执行引擎(下一节中介绍)会把被调用函数的 requirerequire 与调用点的符号状态(即分离逻辑的空间断言)匹配,自动推断它们的取值。当匹配有歧义时,放在调用语句之前的标注 [[cst::where(`v`, `e`)]]` 可以显式给出一个逻辑实参,`[[cst::where_type(`:T`, `:ty`)]][[cst::where(`v`, `e`)]]` 可以显式给出一个逻辑实参,`[[cst::where_type(`:T`, `:ty`)]] 则用于类型参数。
从资源的角度看,调用者把 aa、bb 两个内存单元借给 swapswap,返回时收回这两个单元,其中的内容已经交换。注意指针参数 aa 在断言中直接用作地址,因为参数的值本身就是地址;后文循环不变式中的 __addr__addr 则表示局部变量自身所占内存单元的地址,两者需要区分。
指针的另一种常见角色是出参。比如,扩展欧几里得算法通过两个指针输出 Bézout 系数:
int ext_gcd(int a, int b, int *x, int *y) [[cst::require(`fact(0i <= a) ** fact(0i <= b) ** undef_data_at x Tint ** undef_data_at y Tint`)]] [[cst::ensure(`exists xv yv. data_at x Tint xv ** data_at y Tint yv ** fact(a * xv + b * yv == __return) ** fact(b == 0i ==> xv == 1i && yv == 0i) ** fact(1i <= b ==> --b <= xv && xv <= b) ** fact(1i <= b ==> (--a <= yv && yv <= a) || yv == 1i)`)]]{ ... }
int ext_gcd(int a, int b, int *x, int *y) [[cst::require(`fact(0i <= a) ** fact(0i <= b) ** undef_data_at x Tint ** undef_data_at y Tint`)]] [[cst::ensure(`exists xv yv. data_at x Tint xv ** data_at y Tint yv ** fact(a * xv + b * yv == __return) ** fact(b == 0i ==> xv == 1i && yv == 0i) ** fact(1i <= b ==> --b <= xv && xv <= b) ** fact(1i <= b ==> (--a <= yv && yv <= a) || yv == 1i)`)]]{ ... }
与 swapswap 对比,可以总结指针契约的两种模式:
- 入参(in-parameter)(调用前就有意义的值):用逻辑参数命名初值,比如
data_at p Tint a_vdata_at p Tint a_v; - 出参(out-parameter)(返回时才产生的值):前置条件只要求一个可写内存单元
undef_data_at x Tintundef_data_at x Tint(内容可以是未初始化的),后置条件用存在量词(比如exists xv yv.exists xv yv.)引入返回时的内容,再用factfact陈述其性质,比如这里的a * xv + b * yv == __returna * xv + b * yv == __return。
后置条件的最后三条 factfact 用于为递归调用加强规约,并非调用者关心的结论。ext_gcdext_gcd 递归调用自身时,证明只能依赖这份契约,因此需要这些额外约束来完成函数体中的算术推理。递归函数的规约常常需要这样补充性质。
数组的契约需要描述一段内存。上一节提到的 array_atarray_at 等谓词就用于此,但直接写入契约会受到符号执行引擎的一个限制(下一节详述):契约与符号状态中,除 data_atdata_at/undef_data_atundef_data_at 之外的谓词不能直接携带 ctypectype 参数。目前建议用上一节介绍的定义机制,定义一个单态包装谓词,将 ctypectype 封装在定义体内。以“清零 nn 个字节”为例:
[[cst::proof]] thm undef_bytes_def = new_fun_definition( `undef_bytes (x:addr) (n:int) : hprop = undef_array_at x Tchar n`);[[cst::proof]] int _e1 = cst_add_const_to_header(`undef_bytes`);[[cst::proof]] thm zero_bytes_def = new_fun_definition( `zero_bytes (x:addr) (n:int) : hprop = array_at x Tchar n (ireplicate n 0i)`);[[cst::proof]] int _e2 = cst_add_const_to_header(`zero_bytes`);void clear(char *arr, int n) [[cst::require(`fact(0i <= n) ** undef_bytes arr n`)]] [[cst::ensure(`zero_bytes arr n`)]]{ ... }
[[cst::proof]] thm undef_bytes_def = new_fun_definition( `undef_bytes (x:addr) (n:int) : hprop = undef_array_at x Tchar n`);[[cst::proof]] int _e1 = cst_add_const_to_header(`undef_bytes`);[[cst::proof]] thm zero_bytes_def = new_fun_definition( `zero_bytes (x:addr) (n:int) : hprop = array_at x Tchar n (ireplicate n 0i)`);[[cst::proof]] int _e2 = cst_add_const_to_header(`zero_bytes`);void clear(char *arr, int n) [[cst::require(`fact(0i <= n) ** undef_bytes arr n`)]] [[cst::ensure(`zero_bytes arr n`)]]{ ... }
契约的含义是:给定从 arrarr 开始的 nn 个可写字节,函数结束时,这 nn 个字节的内容均为 00。其中 ireplicate n 0iireplicate n 0i(nn 个 0i0i 组成的列表)是逻辑层的纯函数。这是书写规约时的常用方法:用模型函数(model function)在纯数学值上描述结果,无需逐个枚举内存单元。后面链表与树的契约会进一步展示这种方法。
数据结构的契约建立在上一节定义的表示谓词之上。原地反转单链表的契约只有三行:
[[cst::proof]] int _sll = cst_add_const_to_header(`sll`);[[cst::proof]] int _rev = cst_add_const_to_header(`REVERSE:(A)list->(A)list`);struct list *reverse(struct list *p) [[cst::param(`l:(int)list`)]] [[cst::require(`sll p l`)]] [[cst::ensure(`sll __return (REVERSE l)`)]]{ ... }
[[cst::proof]] int _sll = cst_add_const_to_header(`sll`);[[cst::proof]] int _rev = cst_add_const_to_header(`REVERSE:(A)list->(A)list`);struct list *reverse(struct list *p) [[cst::param(`l:(int)list`)]] [[cst::require(`sll p l`)]] [[cst::ensure(`sll __return (REVERSE l)`)]]{ ... }
逻辑参数 ll 是链表的逻辑内容(一个纯粹的 HOL 列表);REVERSEREVERSE 是 HOL 列表库的现成函数。整份契约的含义是:输入一个内容为 ll 的链表,返回一个内容为 REVERSE lREVERSE l 的链表。实现可以采用任意方式反转链表,只要行为符合契约即可。多个逻辑参数之间用逗号分隔:拼接两个链表的函数可以声明 [[cst::param(`l1:(int)list`, `l2:(int)list`)]][[cst::param(`l1:(int)list`, `l2:(int)list`)]],其契约是 sll p l1 ** sll q l2sll p l1 ** sll q l2 蕴含 sll __return (l1 ++ l2)sll __return (l1 ++ l2)。
树的契约同样如此。下面两个例子取自一个 AVL 树实现(store_treestore_tree、theighttheight、avl_insertavl_insert 分别是树的表示谓词与模型函数):
// 把需要的类型和函数都通过 cst_add_const_to_header / cst_add_type_to_header 导出// *out = nd 所指(可能为空的)子树的高度。void node_height_p(struct tree *nd, int *out) [[cst::param(`t0:tree`)]] [[cst::require(`store_tree nd t0 ** undef_data_at out Tint`)]] [[cst::ensure(`store_tree nd t0 ** data_at out Tint (theight t0)`)]];// AVL 插入:*root 所指的树由 t 变为 avl_insert k t。void insert(struct tree **root, int k) [[cst::param(`t:tree`)]] [[cst::require(`exists pv. data_at root Tptr pv ** store_tree pv t ** fact(theight t <= 2147483646i)`)]] [[cst::ensure(`exists qv. data_at root Tptr qv ** store_tree qv (avl_insert k t) ** fact(theight (avl_insert k t) <= theight t + 1i)`)]];
// 把需要的类型和函数都通过 cst_add_const_to_header / cst_add_type_to_header 导出// *out = nd 所指(可能为空的)子树的高度。void node_height_p(struct tree *nd, int *out) [[cst::param(`t0:tree`)]] [[cst::require(`store_tree nd t0 ** undef_data_at out Tint`)]] [[cst::ensure(`store_tree nd t0 ** data_at out Tint (theight t0)`)]];// AVL 插入:*root 所指的树由 t 变为 avl_insert k t。void insert(struct tree **root, int k) [[cst::param(`t:tree`)]] [[cst::require(`exists pv. data_at root Tptr pv ** store_tree pv t ** fact(theight t <= 2147483646i)`)]] [[cst::ensure(`exists qv. data_at root Tptr qv ** store_tree qv (avl_insert k t) ** fact(theight (avl_insert k t) <= theight t + 1i)`)]];
这两个例子有以下几点值得注意。
- 这两份契约都标注在函数原型上(注意结尾的分号),因此规约可以写在头文件中。目前函数定义处仍需重复同一契约,可以用宏减少重复。将接口与规约放在一起符合 C 的惯例;C* 也建议用头文件组织多模块项目。
node_height_pnode_height_p是只读函数:store_tree nd t0store_tree nd t0在前后置条件中原样出现,表示函数借用这棵树,并在返回时原样归还。insertinsert的参数是二级指针:exists pv. data_at root Tptr pv ** store_tree pv texists pv. data_at root Tptr pv ** store_tree pv t刻画rootroot所指的指针单元,以及该单元中的指针所指向的整棵树。- 功能正确性由一次模型函数应用来描述:
store_tree qv (avl_insert k t)store_tree qv (avl_insert k t),即堆上实现的行为与模型在纯值上的行为一致。这就是“规约即模型”:用纯函数定义数据结构的行为(avl_insertavl_insert由上一节的定义机制定义),契约只负责将堆与模型对应起来。
C* 的符号执行引擎沿程序逐句推进符号状态,但验证时并不知道循环体会执行多少次。按照霍尔逻辑的常规做法,需要我们提供一个循环不变式:一条在每次回到循环头时都成立的断言。它写在 while (...)while (...) 条件与循环体之间:
while (条件) [[cst::invariant(`...`)]]{ 循环体 }
while (条件) [[cst::invariant(`...`)]]{ 循环体 }
引擎按以下步骤使用不变式:进入循环前,检查它是否成立(建立);假设它与循环条件同时成立,对循环体进行一次符号执行,检查它在循环体结束时重新成立(保持);循环之后,以“不变式 + 条件为假”作为新的符号状态继续推进。循环头处,引擎只保留不变式。不变式未包含的资源和事实不会保留在后续的符号状态中,因此不变式必须完整描述循环头的符号状态。
先看一个纯标量的教学示例——Gauss 求和:
int sum(int n) [[cst::require(`fact(0i <= n) ** fact(n <= 1000i)`)]] [[cst::ensure(`fact(2i * __return == n * (n + 1i))`)]]{ int s = 0; int i = 0; while (i < n) [[cst::invariant(`exists sv iv. data_at n__addr Tint n__pre ** data_at s__addr Tint sv ** data_at i__addr Tint iv ** fact(0i <= iv) ** fact(iv <= n__pre) ** fact(2i * sv == iv * (iv + 1i)) ** fact(n__pre <= 1000i)`)]] { i = i + 1; s = s + i; } return s;}
int sum(int n) [[cst::require(`fact(0i <= n) ** fact(n <= 1000i)`)]] [[cst::ensure(`fact(2i * __return == n * (n + 1i))`)]]{ int s = 0; int i = 0; while (i < n) [[cst::invariant(`exists sv iv. data_at n__addr Tint n__pre ** data_at s__addr Tint sv ** data_at i__addr Tint iv ** fact(0i <= iv) ** fact(iv <= n__pre) ** fact(2i * sv == iv * (iv + 1i)) ** fact(n__pre <= 1000i)`)]] { i = i + 1; s = s + i; } return s;}
循环不变式通常包含三类成分,这个例子中都能看到:
- 资源:用
data_atdata_at描述每个活跃变量的存储单元。对于会变化的变量(ss、ii),用existsexists绑定其当前值svsv、iviv;对于不变的变量(nn),直接写入口值n__pren__pre。注意全部用的是显式地址形__addr__addr/__pre__pre,不是程序变量名;这与 VSCode 展示的符号状态形式一致。 - 进度事实:
2i * sv == iv * (iv + 1i)2i * sv == iv * (iv + 1i)——部分和公式,它在循环结束(iv == n__preiv == n__pre)时退化为后置条件。 - 边界事实:
0i <= iv0i <= iv、iv <= n__preiv <= n__pre、n__pre <= 1000in__pre <= 1000i。前两条使我们能在循环退出时断定iv == n__preiv == n__pre;最后一条来自前置条件。但前置条件不会在循环头自动保留,验证s + is + i不溢出时需要这条事实,因此必须将其一并纳入不变式。
回到前面的 clearclear 例子。如果用循环实现,就需要在不变式中刻画清零过程中的状态。为此,先定义一个包装谓词,描述尚未清零的后缀:
[[cst::proof]] thm undef_suffix_def = new_fun_definition( `undef_suffix (x:addr) (i:int) (n:int) : hprop = undef_array_at (x + i * sizeof Tchar) Tchar (n - i)`);[[cst::proof]] int _e3 = cst_add_const_to_header(`undef_suffix`);void clear(char *arr, int n) [[cst::require(`fact(0i <= n) ** undef_bytes arr n`)]] [[cst::ensure(`zero_bytes arr n`)]]{ int i = 0; while (i < n) [[cst::invariant(`exists iv. data_at i__addr Tint iv ** data_at n__addr Tint n__pre ** data_at arr__addr Tptr arr__pre ** zero_bytes arr__pre iv ** undef_suffix arr__pre iv n__pre ** fact(0i <= iv) ** fact(iv <= n__pre) ** fact(n__pre <= 2147483647i)`)]] { char *p = arr + i; *p = 0; i = i + 1; }}
[[cst::proof]] thm undef_suffix_def = new_fun_definition( `undef_suffix (x:addr) (i:int) (n:int) : hprop = undef_array_at (x + i * sizeof Tchar) Tchar (n - i)`);[[cst::proof]] int _e3 = cst_add_const_to_header(`undef_suffix`);void clear(char *arr, int n) [[cst::require(`fact(0i <= n) ** undef_bytes arr n`)]] [[cst::ensure(`zero_bytes arr n`)]]{ int i = 0; while (i < n) [[cst::invariant(`exists iv. data_at i__addr Tint iv ** data_at n__addr Tint n__pre ** data_at arr__addr Tptr arr__pre ** zero_bytes arr__pre iv ** undef_suffix arr__pre iv n__pre ** fact(0i <= iv) ** fact(iv <= n__pre) ** fact(n__pre <= 2147483647i)`)]] { char *p = arr + i; *p = 0; i = i + 1; }}
空间部分是遍历类不变式的标准形态:已处理前缀 **** 未处理后缀——zero_bytes arr__pre ivzero_bytes arr__pre iv 加 undef_suffix arr__pre iv n__preundef_suffix arr__pre iv n__pre,两段拼接起来恰好是前置条件刻画的整段内存。每次执行循环体,都从后缀取出一个字节,将其清零后并入前缀,两段的分界线 iviv 因而向右移动一个字节。
undef_suffixundef_suffix 的定义还用到了一个实用技巧:后缀的基址在定义体里写成 x + i * sizeof Tcharx + i * sizeof Tchar。上一节说过,arr + iarr + i 这样的指针运算在逻辑中展开为 sizeofsizeof 形式,而断言与符号状态的匹配是语法性的。将这种形式封装在定义体中,既能保持不变式简洁,也便于符号执行引擎匹配执行中产生的地址。
这里还有一个问题:*p = 0*p = 0 要写的那一个内存单元,符号状态里并没有直接给出对应的 undef_data_atundef_data_at——它折叠在 undef_suffixundef_suffix 里,符号执行引擎执行到这一步时会报错。这正是本节末内联断言(或后续章节的证明代码)要解决的问题。
最后来看原地反转链表的完整程序。前面已经介绍过契约,现在关注其中的循环:
[[cst::proof]] int _app = cst_add_const_to_header(`APPEND:(A)list->(A)list->(A)list`);struct list *reverse(struct list *p) [[cst::param(`l:(int)list`)]] [[cst::require(`sll p l`)]] [[cst::ensure(`sll __return (REVERSE l)`)]]{ struct list *w = 0; struct list *v = p; while (v != 0) [[cst::invariant(`exists wv vv l1 l2. data_at p__addr Tptr p__pre ** data_at w__addr Tptr wv ** data_at v__addr Tptr vv ** sll wv (REVERSE l1) ** sll vv l2 ** fact(l == l1 ++ l2)`)]] { struct list *t = v->tail; v->tail = w; w = v; v = t; } return w;}
[[cst::proof]] int _app = cst_add_const_to_header(`APPEND:(A)list->(A)list->(A)list`);struct list *reverse(struct list *p) [[cst::param(`l:(int)list`)]] [[cst::require(`sll p l`)]] [[cst::ensure(`sll __return (REVERSE l)`)]]{ struct list *w = 0; struct list *v = p; while (v != 0) [[cst::invariant(`exists wv vv l1 l2. data_at p__addr Tptr p__pre ** data_at w__addr Tptr wv ** data_at v__addr Tptr vv ** sll wv (REVERSE l1) ** sll vv l2 ** fact(l == l1 ++ l2)`)]] { struct list *t = v->tail; v->tail = w; w = v; v = t; } return w;}
这个不变式表示:任一时刻,原链表分为两段——ww 指向已反转的前缀(内容 REVERSE l1REVERSE l1),vv 指向尚未处理的后缀(内容 l2l2);纯事实 l == l1 ++ l2l == l1 ++ l2 把两段与原链表联系在一起。
reversereverse 不变式的示意图:已反转前缀(内容 REVERSE l1REVERSE l1)与未处理后缀(内容 l2l2),fact(l == l1 ++ l2)fact(l == l1 ++ l2) 将两段与原链表联系起来。下面按引擎的三个步骤检查这个不变式:
- 建立:进入循环前
w == 0w == 0、v == pv == p,取l1 = []l1 = []、l2 = ll2 = l——空前缀(空列表)反转后仍为空列表(sll 0i (REVERSE [])sll 0i (REVERSE [])即空堆),后缀是整条链表,l == [] ++ ll == [] ++ l显然成立。 - 保持:
v != 0v != 0时后缀非空。循环体先在证明块中按sllsll的定义展开头节点,得到headhead、tailtail两个单元的data_atdata_at;接着v->tail = wv->tail = w将头节点连接到已反转的前缀,ww、vv均向前移动;最后以l1' = l1 ++ [头元素]l1' = l1 ++ [头元素]、l2' = 尾部l2' = 尾部重新折叠为两段sllsll。v->tailv->tail能执行,正是因为展开后,不变式中包含了该单元的data_atdata_at。 - 出口:
v == 0v == 0时后缀是空列表,l2 == []l2 == [],于是l1 == ll1 == l,ww一侧的sll wv (REVERSE l)sll wv (REVERSE l)正是后置条件。
与数组一例相同,v->tailv->tail 所需的字段资源折叠在 sll vv l2sll vv l2 里,若不展开便执行则会报错。展开可以用证明代码完成,也可以用下面的内联断言先行推进。
最后一个标注是 [[cst::assert(`...`)]][[cst::assert(`...`)]]:作为一条语句写在函数体内,断言此处的符号状态。引擎处理它分两步:先检查当前符号状态蕴含所断言的状态,这个蕴含成为一个验证条件;然后以所断言的状态为新的符号状态继续推进。通过改写符号状态并继续执行,assertassert 有以下两种用途。
前面 clearclear 与 reversereverse 的循环体都还无法直接完成符号执行:不变式里的资源是折叠形态(undef_suffixundef_suffix、sll vv l2sll vv l2),而 *p = 0*p = 0 要写的是 pp 所指的那个单元、v->tailv->tail 要读的是节点的 tailtail 字段——符号状态里并未直接给出这些 data_atdata_at/undef_data_atundef_data_at 资源,符号执行引擎在这两处会报错。按定义展开谓词需要证明代码(后续章节介绍)。在尚未编写证明时,可以先插入一条 assertassert,将符号状态改写为展开一步后的形态,让符号执行继续。clearclear 的循环体可以写为:
{ char *p = arr + i; [[cst::assert(`exists iv. fact(0i <= iv && iv < n__pre && n__pre <= 2147483647i) ** data_at p__addr Tptr (arr__pre + iv * sizeof Tchar) ** data_at i__addr Tint iv ** data_at n__addr Tint n__pre ** data_at arr__addr Tptr arr__pre ** zero_bytes arr__pre iv ** undef_data_at (arr__pre + iv * sizeof Tchar) Tchar ** undef_suffix arr__pre (iv + 1i) n__pre`)]]; *p = 0; i = i + 1; }
{ char *p = arr + i; [[cst::assert(`exists iv. fact(0i <= iv && iv < n__pre && n__pre <= 2147483647i) ** data_at p__addr Tptr (arr__pre + iv * sizeof Tchar) ** data_at i__addr Tint iv ** data_at n__addr Tint n__pre ** data_at arr__addr Tptr arr__pre ** zero_bytes arr__pre iv ** undef_data_at (arr__pre + iv * sizeof Tchar) Tchar ** undef_suffix arr__pre (iv + 1i) n__pre`)]]; *p = 0; i = i + 1; }
与不变式对照,这条 assertassert 做了三件事:把循环条件带来的 iv < n__preiv < n__pre 写进 factfact(进入循环体后它已成立);补上新声明的 pp 的资源(其值正是 sizeofsizeof 形的地址);其中最关键的是,将 undef_suffix arr__pre iv n__preundef_suffix arr__pre iv n__pre 分解为头字节 undef_data_at (arr__pre + iv * sizeof Tchar) Tcharundef_data_at (arr__pre + iv * sizeof Tchar) Tchar 加余下后缀 undef_suffix arr__pre (iv + 1i) n__preundef_suffix arr__pre (iv + 1i) n__pre。 assertassert 之后,符号状态中已经明确包含了 *p*p 所需的单元资源,符号执行就能通过这一步。类似地,reversereverse 的循环体在读 v->tailv->tail 之前,用 assertassert 把 sll vv l2sll vv l2 展开成头节点的两个单元加余下链表(vvvv 非空,故 l2l2 必为某个 x :: xsx :: xs):
{ [[cst::assert(`exists q x xs l1 vv wv. fact(~(vv == 0i) && l == l1 ++ x :: xs) ** data_at p__addr Tptr p__pre ** data_at w__addr Tptr wv ** data_at v__addr Tptr vv ** sll wv (REVERSE l1) ** data_at (field_addr vv Tlist Fhead) Tint x ** data_at (field_addr vv Tlist Ftail) Tptr q ** sll q xs`)]]; struct list *t = v->tail; v->tail = w; w = v; v = t; }
{ [[cst::assert(`exists q x xs l1 vv wv. fact(~(vv == 0i) && l == l1 ++ x :: xs) ** data_at p__addr Tptr p__pre ** data_at w__addr Tptr wv ** data_at v__addr Tptr vv ** sll wv (REVERSE l1) ** data_at (field_addr vv Tlist Fhead) Tint x ** data_at (field_addr vv Tlist Ftail) Tptr q ** sll q xs`)]]; struct list *t = v->tail; v->tail = w; w = v; v = t; }
不过,“旧状态蕴含所断言的状态”仍是一个待证的验证条件(此处正是按定义展开 undef_suffixundef_suffix/sllsll 的那一步)。assertassert 并未消除证明义务,而是让符号执行先继续,将这一义务留待后续证明。这是 C* 中一种实用的工作流:先用 assertassert 完成整个函数的符号执行,查看全部验证条件,再逐个补充证明。
[[cst::invariant]][[cst::invariant]] 同样可以附加在 forfor 与 do-whiledo-while 循环上(只有一条限制:forfor 的头部不能声明变量,应写成 int i; for (i = 0; …)int i; for (i = 0; …));对 do-whiledo-while,不变式在判断处、也就是循环体之后检查。也可以在同一位置手动插入一条 assertassert,充当“手动不变式”,如下例所示:
int mul(int x, int y) [[cst::require(`fact(1i <= x && x <= 100i) ** fact(1i <= y && y <= 100i)`)]] [[cst::ensure(`fact(__return == x * y)`)]]{ int ans = 0; do { ans += y; x--; [[cst::assert(`exists xv av. data_at x__addr Tint xv ** data_at y__addr Tint y__pre ** data_at ans__addr Tint av ** fact(1i <= x__pre && x__pre <= 100i && 1i <= y__pre && y__pre <= 100i) ** fact(0i <= xv && xv <= 99i) ** fact(av + xv * y__pre == x__pre * y__pre)`)]]; } while (x > 0); return ans;}
int mul(int x, int y) [[cst::require(`fact(1i <= x && x <= 100i) ** fact(1i <= y && y <= 100i)`)]] [[cst::ensure(`fact(__return == x * y)`)]]{ int ans = 0; do { ans += y; x--; [[cst::assert(`exists xv av. data_at x__addr Tint xv ** data_at y__addr Tint y__pre ** data_at ans__addr Tint av ** fact(1i <= x__pre && x__pre <= 100i && 1i <= y__pre && y__pre <= 100i) ** fact(0i <= xv && xv <= 99i) ** fact(av + xv * y__pre == x__pre * y__pre)`)]]; } while (x > 0); return ans;}
与 invariantinvariant 一样,assertassert 断言的是该程序点的完整符号状态,前一节的书写准则同样适用。
- 函数契约 =
[[cst::require]][[cst::require]]+[[cst::ensure]][[cst::ensure]](辅以[[cst::param]][[cst::param]]逻辑参数),写在参数表之后;契约中参数名指入口值,__return__return指返回值;契约可标注于原型上;带契约而无函数体的原型是假定规约,属于信任基。 - 指针入参用逻辑参数命名初值(
data_at p Tint a_vdata_at p Tint a_v);出参在前置条件中用undef_data_atundef_data_at描述可写单元,在后置条件中用existsexists引入新值。 - 数组契约用单态包装谓词隐藏
ctypectype(引擎限制);契约中出现的新常量、新类型须导出(cst_add_const_to_headercst_add_const_to_header/cst_add_type_to_headercst_add_type_to_header)。 - 数据结构契约 = 表示谓词 + 模型函数:堆上实现的行为与模型在纯值上的行为一致。
- 循环不变式描述循环头的完整符号状态;遍历类的标准形态是“已处理前缀
****未处理后缀”,并包含进度与边界factfact;书写时应遵循上述五条准则。 [[cst::assert]][[cst::assert]]断言并替换一处的完整符号状态:在未写证明时可把资源改写成引擎需要的形态、先让符号执行完整通过(蕴含成为待证的验证条件),也可以在循环体内充当手动不变式。- 匹配有歧义时,
[[cst::where]][[cst::where]]在调用处显式传入一个逻辑实参;[[cst::given]][[cst::given]]为当前状态的存在例证命名,使assertassert、wherewhere或内层循环的不变式能够提到它们。