Lab01,三大位置规则,datatype,match,requires/ensures

11 minute read Published: 2026-08-31

hello.dfy:总览

读这个文件之前需要知道的全部背景:

Dafny 是一门"带验证器的编程语言"。写代码,同时写下对代码的断言(它应该满足什么性质),然后运行 dafny verify 文件名.dfy——验证器会用数学方法检查:断言是否对所有可能的输入都成立。不是跑几个测试用例——是全部输入,一个不漏。这就是它和普通语言的根本区别。

注释语法:// 到行尾;/* ... */ 块注释。注释不参与验证。

本文件相对原件改了两处,否则无法通过解析:原文件第 175 行末尾有一个孤立的 "|" 字符(粘贴事故),已删除;isLeapYear 里 "0then" 缺空格,已补。其余代码与原件逐字相同。全文在 Dafny 4.11.0 下验证通过:18 verified, 0 errors

method:语句的世界

method 是"过程式"的代码块:一串按顺序执行的语句,可以打印、可以修改变量。Main 是特殊名字:程序的入口,dafny run 时从这里开始执行。本课程里 method 只是配角(用来跑 print 看结果);主角是 function 和 lemma。三样基础语法:var d := 表达式; 声明并赋值,:= 是赋值符号,每条语句以分号结尾;print d, "\n"; 打印;变量不写类型时 Dafny 自己推断。

method Main(){
    var d := distSquared((3, 2));           // (3, 2) 是一个二元组
    print d , "\n";
    var dotp := dotProduct((2,3), (4, 5));
    print dotp , "\n";
    var perpend := perpendicular((1,0), (0,5));
    print perpend, "\n";
    var div := divides(6, 2);
    print div, "\n";
    var inrange := inRange(2, 3, 4);
    print inrange, "\n";
    var cen := withTax(100);
    print cen, "\n";
    var s := area(shape.Rectangle(2, 3));   // 构造一个"形状"值
    print s, "\n";
    var m := monthLen(month.Jun, true);
    print m, "\n";
    var expx := exp(2, 3);
    print expx, "\n";
}

function:表达式的世界

function 是数学意义上的函数:给输入,算输出,别的什么都不做。剖析定义的每一块:v : (int,int) 参数 v,类型是一个由两个整数组成的元组 tuple,类型标注永远是 名字 : 类型;最后的 : int 是返回类型;花括号里只放一个表达式,它就是返回值——没有 return,没有分号,不能写语句。这是 Dafny 三大位置规则的第一条:

◆ function 体 = 表达式的位置。

取元组的分量用 .0 和 .1(从 0 数起)。

function distSquared(v : (int,int)) : int
{
    v.0 * v.0 + v.1 * v.1   // 向量 v 的长度平方 = x² + y²
}

function dotProduct(u : (int,int), v : (int,int)) : int
{
    u.0 * v.0 + u.1 * v.1   // 点积:对应分量相乘再相加
}

predicate:返回 bool 的缩写

predicate(谓词)就是返回类型为 bool 的 function,只是省去 : bool 的一种缩写。数学上,谓词 = 一个关于输入的判断

表达式版的 if:if 条件 then A else B 整体是一个表达式,值为 A 或 B。else 不能省——表达式必须有值。(对比语句版的 if:出现在 method / lemma 体内,带花括号,可以没有 else。同一个词,两种身份,位置决定身份。)

◆ 风格备注:if c then true else false 与直接写 c 完全等价。官方这里写了展开版;考试里两种都对,但直接写 c 更体面。两向量垂直 ⟺ 点积为零,本可一行:dotProduct(u, v) == 0

predicate perpendicular(u : (int,int), v : (int,int))
{
    if dotProduct(u, v) == 0 then true else false
}

nat 与 % 的定义域

nat 是"自然数"类型:≥ 0 的整数,是 int 的子集类型;用 nat 做参数,等于免费获得一条前置条件"参数 ≥ 0"

divides(m, n) 判断 n 能否整除 m(注意参数顺序:m 是被除数)。关键点:

m % n 只有在 n != 0 时才有定义。Dafny 会拒绝任何可能"除以零"的表达式——不是运行时报错,是验证阶段直接不通过

◆ 那第二个分支为什么合法?因为 && 是短路的:在 n != 0 && m % n == 0 里,只有左边为真时才需要看右边,所以 Dafny 只要求 m % n 在"n != 0 成立"的前提下有定义。用条件的顺序保护危险表达式——这个手法贯穿全课程,lab04 的 div12 会再见到它(换成 requires 的顺序)。

链式比较:lo <= x <= hi 是合法 Dafny,等价于 lo <= x && x <= hi,同方向的比较可以连着写。

function divides(m : nat, n : nat) : bool
{
    if (m == 0 && n == 0) then true
    else if (n != 0 && m % n == 0) then true
    else false
}

predicate inRange(lo : int, x : int, hi : int)
{
    if lo <= x <= hi then true else false
}

requires:前置条件

requires 写在签名和函数体之间,是对调用者的要求:"想用我,先保证这个条件"。验证器会在每个调用点检查它。

三大位置规则第二条:requires / ensures 里放的是命题(可判真假的断言),不是语句。

requires 有两种动机:(a) 保证函数体里的表达式有定义(除零、取空列表的头……);(b) 排除"数学上能算、语义上荒谬"的输入——本例属于这种:负的金额算税没有意义,尽管 cents / 10 对负数也能算。

◆ 函数体内的 var:var tax := ...; 表达式表达式的一部分(数学里的 let),给中间值起名字,分号后面跟着最终表达式。别和 method 里的赋值语句混淆——形似,位置和本质不同。

整数除法向下取整:105 / 10 == 10,不是 10.5。

function withTax(cents : int) : int
  requires cents >= 0
{
    var tax := cents / 10;
    cents + tax
}

datatype:代数数据类型

datatype 定义一种新类型,穷举它的所有构造方式。最简单的形态是枚举;构造子也可以带字段——竖线 | 读作"或者",构造值时写 shape.Rectangle(2, 3),类型名前缀在无歧义时可省略。

datatype light = Red | Orange | Green

datatype shape =
    Rectangle(height : nat, width : nat)
  | Square(side : nat)
  | Triangle(base : nat, height : nat)

match:模式匹配

match 按构造子拆开一个 datatype 值。读作:如果 s 是用 Rectangle 造出来的,就把它的两个字段取名为 h、w,然后算右边的表达式。case 必须穷尽所有构造子——漏一个,验证器报错。这保证你永远不会忘记处理某种情况。

◆ match 出现在 function 体里时,整体是一个表达式(每个 case 的右边也是表达式)。它还有语句形态,lemma 里见。

多模式合并:case Apr | Jun | Sep | Nov => 30,几个构造子共享同一个结果,写一次即可。注意这里"月份"被建模成 datatype——lab04 会给出另一种建模(nat 0..11 加 requires),两种方案的代价对比到时讲。

function area(s : shape) : nat
{
    match s
    case Rectangle(h, w) => h * w
    case Square(s) => s * s        // 这个 s 是新名字,遮住了参数 s
    case Triangle(b, h) => (b * h)/2
}

datatype month =
    Jan | Feb | Mar | Apr | May | Jun |
    Jul | Aug | Sep | Oct | Nov | Dec

function monthLen(m : month, isLeap : bool) : nat
{
    match m
    case Feb => if isLeap then 29 else 28
    case Apr | Jun | Sep | Nov => 30
    case Jan | Mar | May | Jul | Aug | Oct | Dec => 31
}

闰年规则的 if 链版本:400 整除 → 闰;否则 100 整除 → 平;否则 4 整除 → 闰;否则平。判断顺序承载了规则的优先级。 lab04 有一个一行的"纯逻辑"版本,两者等价,届时对照。

predicate isLeapYear(year : nat)
{
    if year % 400 == 0 then true
    else if year % 100 == 0 then false
    else if year % 4 == 0 then true
    else false
}

lemma 与 assert

lemma(引理)是一段"证明":不产生值,只让验证器确认一个事实

三大位置规则第三条:lemma 体 = 语句的位置。 assert 命题; 就是一条语句,意思是"验证器,请在此处证明这个命题"。证不出来 → 报错。

下面三条是测试引理:拿具体数字喂给函数,让验证器算一遍确认结果。注意,验证器是真的把函数按定义展开算出来的,不是跑程序

一个值得较真的细节:这里的写法是 assert 放体内、ensures 空缺。这样验证时确实检查了事实,但引理对外什么都没承诺(ensures 为空 = 调用它得不到任何信息)。惯用的测试引理写法是反过来:lemma isLY2024() ensures isLeapYear(2024) {}——把命题放进 ensures,体留空。两种都能过验证;后者才是可被别的证明引用的形态。体会一下:同一个命题,放 assert(体内语句)和放 ensures(对外契约),角色完全不同。

lemma isLY2024()
{
    assert isLeapYear(2024) == true;    // == true 可省:assert P; 即可
}
lemma isLY1900()
{
    assert isLeapYear(1900) == false;   // 可写 assert !isLeapYear(1900);
}
lemma isLY2000()
{
    assert isLeapYear(2000) == true;
}

递归与停机

函数调用自己。递归必须停机,Dafny 要检查这一点。规则:每次递归调用,某个度量必须严格变小、且有下界。没写 decreases 时,Dafny 默认拿参数本身当度量:这里 p : nat 每次减一、又不会低于 0,自动通过。往后遇到 Dafny 看不出来的递归,才需要手写 decreases(lab03 的 Part C)。

function exp(m : nat, p : nat) : nat
{
    if p == 0 then 1 else
    m * exp(m, p-1)
}

斜率 = Δy / Δx,requires 保证分母不为零(这里更强:> 0)。◆ 但类型是 int:整数除法截断。gradient((0,0),(2,1)) == 0,真实斜率 0.5 被抹掉了。requires 能保证"有定义",救不了"类型选得不合适"。看见 int 除法就要想一下截断。

function gradient(p1 : (int,int), p2 : (int,int)) : int
  requires (p2.0 - p1.0) > 0
{
    (p2.1 - p1.1) / (p2.0 - p1.0)
}

ensures:后置条件

ensures 是函数对调用者的承诺:"我的返回值满足这个命题"。验证器负责检查函数体确实兑现承诺;此后所有调用点免费获得这条知识。在 ensures 里提到返回值的方式:直接写函数调用自身 makeItBigger(i)。(另一种写法是给返回值命名,课程后面见。)

function makeItBigger(i : int) : int
  ensures makeItBigger(i) > i
{
    i + 1
}

阶乘:if n < 2 合并了 0 和 1 两个基础情况(0! = 1! = 1)。原件把题目要求的 ensures 写成了体内 assert(TODO 注释还留着)。两种形态的差别上面 isLY2024 处已讲——验证都能过,但题目点名要 ensures,考试按题目来:lemma factTest1() ensures fact(4) == 24 {}

function fact(n : nat) : nat
{
    if n < 2 then 1 else n * fact(n - 1)
}

// Claim: fact(4) equals 24.
lemma factTest1()
// TODO: write the ensures for "fact(4)"
{ assert fact(4) == 24; }

// Claim: fact(6) is greater than 100.
lemma factTest2()
// TODO: write the ensures that claims that 6! is strictly larger than 100
{ assert fact(6) > 100; }

预告:递归数据类型

datatype 的构造子字段可以是这个类型自己——于是有了链表:一个 list<T> 要么是空表 Nil,要么是 Cons(头元素, 剩余的表)。<T> 是类型参数(泛型):list<int>、list<bool> 共用一份定义。[1,2] 就是 Cons(1, Cons(2, Nil))。

这三个函数是整门课的主旋律,此处只需先看出它们的共同骨架:match 拆两种情况;Nil 直接给答案;Cons 处理头、递归尾

datatype list<T> = Nil | Cons(hd : T, tl : list<T>)

function length<T>(l : list<T>) : nat
{
    match l
    case Nil => 0
    case Cons(_, xs) => 1 + length(xs)   // _ :占位符,"我不关心这个字段"
}

function append<T>(l1 : list<T>, l2 : list<T>) : list<T>
{
    match l1
    case Nil => l2
    case Cons(x, xs) => Cons (x, append(xs, l2))
}

function member(x : int, A : list<int>) : bool
{
    match A
    case Nil => false
    case Cons(y, ys) => if y == x then true else member(x, ys)
    // 等价一行:y == x || member(x, ys) —— lab03 的官方版就这么写
}