6260 notes: 列表构建

12 minute read Published: 2026-08-15

8.15 辛酉

building-intlists.dfy

datatype intlist = Empty | Cons(hd: int, tl: intlist)
function singleton(i: int): intlist
  // what "ensures line" might we write here?
{
  Cons(i, Empty)
}
// what more interesting functions might we write to build lists?
// - of different lengths
// - with successive elements
// - ?
  1. 为什么写成 datatype intlist = Empty | Cons(hd: int, tl: intlist)

  2. singleton 是什么意思?

  3. selector 是什么, singleton(i).hd 吗?

  4. 我笔误写成了:

 datatype intlist = Empty | Cons(hd: list, tl: intlist)
                                              应该是 int          ↑
                                                  ↑         剩下的整条列表
                                                单个整数

一条 intlist, 要么是 Empty(空, 到此为止), 或者是 Cons, 一个整数头, 一条 intlist 尾。

第一个坑装乘客(int), 第二个坑装车厢(intlist 自己), 套娃的自引用只发生在第二个坑。

它和 list<T> 的关系: 这是焊死了元素类型的简装版。Nil 改名为 Empty, 名字自由。

  1. singleton 本义是"单元素的"。这个函数干的事: 收一个整数, 吐一条只装着它的列表。

singleton(7) 得到 [7], 即 Cons(7, Empty)

这是全课程第一个"造列表的函数"。它的价值只在示范函数的最小形态。

  1. selector 选择器是从造好的值里取某个坑的内容: ".坑名", 坑名来自 datatype 声明。

第一行写了 hd 和 tl, 于是任何一个 Cons 造的值都能 .hd 取头, .tl 取尾。

singleton(i) = Cons(i, Empty)

singleton(i).hd = i

singleton(i).tl = Empty

①match: 不知道是哪种情况, 分案讨论

②selector: 明知道是 Cons, 直接伸手拿。伸手的前提是:不是 Empty, Empty 括号里一个坑都没

selector 的真正用武之地不在函数体里, 而在规格语言里描述"结果长什么样"

building-intlists.dfy

datatype intlist = Empty | Cons(hd: int, tl: intlist)
function singleton(i: int): intlist
  // what "ensures line" might we write here?
{
  Cons(i, Empty)
}
// what more interesting functions might we write to build lists?
// - of different lengths
// - with successive elements
// - ?

给它写条什么样的 ensures?

ensures singleton(i) != Empty
ensures singleton(i).hd == i             // selector 派上用场了
ensures singleton(i).tl == Empty
ensures singleton(i) == Cons(i, Empty)       规格 Spec 该写多强?

singleton 造的列表长度焊死是1, 注释问: 怎么造长度可变, 元素连续的列表?

递归转向 —— 递归地消耗一个数字, 产出一条列表。

function countdown(n: nat): intlist
{
  if n == 0 then Empty
  else Cons(n, countdown(n-1))
}

countdown(3) 展开: Cons(3, countdown(2)) → Cons(3, Cons(2, countdown(1))) → 最终 [3,2,1]。递归终点是0(产出 Empty)。n 传多大, 列表就多长, 元素还是连着的

如果参数类型不是 nat 是 int, 那传负数进去, n-1 离0越来越远, 递归不终止

Dafny 对每个递归函数都要证明它会停。

intlist.dfy

datatype intlist = Empty
  | Cons(hd: int, tl: intlist)                          .字段名
// write some variations on this with selectors ".fldname" and
// pattern-matching to get particular output numbers
function dumb(): int
  ensures dumb() == 0
{
  var l := Cons(0, Cons(1, Cons(2, Empty)));      l是列表 [0,1,2]
  l.hd                                            l.hd 就是 0
}                                                 l.tl 是 [1,2]
                                                  l.tl.hd 是 1
                                                  l.tl.tl.hd 是 2

selector 和 match 用哪个?

l.hd 有个前提: l 得真的是 Cons, 如果 l 是 Empty, 验证器会在编译时拦下来。

①match: 不知道手里是哪种情况时用, 要把每种情况都处理掉

②selector: 已经确定(且能让验证器信服)是哪个构造子时用。

l.tl.tl.tl 是 Empty, l.tl.tl.tl.hd 会被验证器拦

list-maximum.dfy

datatype list<T> = Nil | Cons(hd: T, tl: list<T>)
function maxList(l: list<int>): int
// with an accumulator

list: 我们起的类型名。

<T> 是说这个类型带一个空位。T 是个占位符。

list<T> 读作: "list 是一族类型的模版, 装什么由你定"

Nil 是空列表吗?

Nil 是一个不带任何数据的值。代表"什么元素都没有的列表"。

同一个东西有 Empty, Lf。作用是给递归一个终点。

| 是什么?

读作"或者"。整个第一行连起来读: "一个 list 类型的值, 要么是 Nil, 或者是 Cons(某个头, 某条尾巴)"

| 隔开的每一项叫一个构造子。造这个类型的值只有这几种办法, 没有第三种

这也是为什么 match 写两个 case 就齐了。| 隔开几次, match 就要写几个 case

点菜只能从菜单里选。

同一行里所有的 T 只能填一个东西。

function 函数名(参数名: 参数类型): 返回值类型。

"maxList 这个函数, 吃一个整数列表(给它起名 l), 吐一个整数"

  requires length(l) > 0          整条列表的最大值 = 头 和剩余部分的最大值 两者取大
{
  match l
  case Cons(hd, Nil) => hd                           // 恰好一个元素
  case Cons(hd, tl) => max(hd, maxList(tl))
}

              function max(i: int, j: int): int                这里 recursive 了吧?
              { if i < j then j else i }

case Cons(hd, tl) ⇒ max(hd, maxList(tl)) ✗

递归maxList(tl)也带着 requires length > 0 的合同。可 tl 可能是空的。(列表只剩一个元素)

accumulator 版:

function maxListA(l: list<int>, maxSoFar: int): int
{
  match l
  case Nil => maxSoFar
  case Cons(hd, tl) =>
    var newMaxSoFar := max(hd, maxSoFar);
    maxListA(tl, newMaxSoFar)
}

function maxListWithA(l: list<int>): int
  requires length(l) > 0
{
  match l
  case Cons(hd, tl) => maxListA(tl, hd)
}
maxListWithA([2,7,4])
= maxListA([7,4], 2)
= maxListA([4], max(7,2))
= maxListA([4], 7)                       
= maxListA([Nil], max(4,7))       //不能写成[Nil],Nil 就是空列表, 不需要套 []
= maxListA([Nil], 7)              // = maxListA(Nil, 7)
= 7             

写递归函数, 先 match 搭骨架

maxList[2,7,4]
= max(2, maxList[7,4])
= max(2, max(7, maxList[4]))            
= max(2, max(7, max(4)))       //Cons(hd, Nil) ⇒ hd 直接得4
= max(2, max(7,4))
= max(2, 7) = 7                //max 函数也要定义了才能用。
case Cons(hd, tl) ⇒ max(hd, maxList(tl))
                     max(头, 剩余的冠军)

剩余的冠军从哪来 —— 信任契约又登场了。

maxList(tl): 假装 maxList 已是造好的成品, 把 tl 塞给它, 它保证还你 tl 里的最大值, 别问它是怎么做到的。本层任务只剩把这个答案和 hd 送进 max 决赛

假装的原因
  ①tl 严格短一截, 必然触底
  ②底层答案是写死的, 不靠假装。

底层是 case Cons(hd, Nil) ⇒ hd

maxList([5,3,9])                          // 5 vs 剩余冠军
= max(5, maxList([3,9]))
= max(5, max(3, maxList([9])))            // 3 vs 更小范围的冠军
= max(5, max(3, 9))                       // [9] 只剩一人, 不战而王
= max(5, 9)                               // 3 输给 9
= 9                                       // 5 输给 9, 尘埃落定

注意形状: 一层层展开时什么也没算。只是欠下一层层"再回来比"的账。触底后从最里面往外逐层结算。

为什么终点是 Cons(hd, Nil) 而不是 Nil? 因为空列表没有最大值, 函数头上挂了 requires length(l) > 0。把空列表拒之门外。但这份合同每层递归都要守。如果只写 case Cons(hd, tl) ⇒ max(hd, maxList(tl)), 当列表只剩一个元素时, tl 是空的, 递归调用 maxList(Nil) 就违约了。所以必须加"只剩一人, 直接称王"这条, 让递归在触碰空列表之前刹车。合同不只管入口, 还管到每一次自我调用。

function maxListA(l: list<int>, maxSoFar: int): int
                  ↑                ↑
            要检查的剩余列表        目前为止见过的最大值。

同一条 [5,3,9]

入口: maxListWithA([5,3,9]) → 拆出头5当初始擂主 → 转交 maxListA([3,9], 5)

账本版(边走边算):                        朴素版(回程算账):
maxListA([3,9], 5)                       maxList([5,3,9])
= maxListA([9], max(3,5))                = max(5, maxList([3,9]))
= maxListA([9], 5)     ← 3挑战失败       = max(5, max(3, maxList([9])))
= maxListA(Nil, max(9,5))                = max(5, max(3,9))
= maxListA(Nil, 9)     ← 9上位           = max(5, 9)
= 9                    ← 列表空, 交便签  = 9

两边的形状差:

  1. 左边永远只有一层, 没有嵌套。每一行都是干干净净的 maxListA(短了的列表, 一个已经算好的数), 比较当场做完, 结果立刻写进第二个参数, 不欠账。所以走到 Nil 时, 答案已经躺在便签上。case Nil ⇒ maxSoFar 直接交货, 没有回程。

朴素版 maxList 只带一个包袱上路(列表), 账本版带两个, (列表 + 一个数), 这个数不是列表的一部分, 它是随身携带的便签, 上面永远写着"到目前为止的擂主是谁"。函数每走一步干两件事: 让新头挑战擂主, (max(hd, maxSoFar)), 把便签更新成胜者, 然后带着更短的列表和新便签继续走。

  1. 右边括号越叠越深, 每层只留下一句"回来再和5比""回来再和3比"的欠条。什么都不结算。直到触底才从最里往外还账。
①朴素版: 把所有人名单传到底, 再层层上报冠军
②账本版: 擂台赛, 擂主一路守擂, 打完即终。

① 为什么账本版不需要 requires, 不需要单元素特判? 朴素版怕空列表, 是因为 Nil 时它两手空空, 交不出任何数; 账本版走到 Nil 时手里永远有便签, 交便签就行。空列表的难题被那个多出来的参数吸收了。

② 擂主哪来的第一任? 这就是 maxListWithA 那个入口包装的存在意义: 从列表里掏第一个元素当初始擂主。掏这一下要求列表非空, 所以 requires 挂在入口, 不挂在干活的函数上。

两函数分工: 一个体面(对外签名干净但要合同), 一个能干(对内多带个参数但百无禁忌)。

var newMaxSoFar := max(hd, maxSoFar);      比出胜者, 起个名, 传下去
    maxListA(tl, newMaxSoFar)
    和 maxListA(tl, max(hd, maxSoFar)) 等价。var 纯粹为了看着清楚。

我写的:

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

function max(i: int, j: int): int{
  if i < j then j
  else if i > j then i
  else i
}

function maxList(l: list<int>): int{
  match l
  case Nil => Nil //✗         类型纪律: maxList 的签名承诺返回的是 int, 而 Nil 是列表 ⇒ 右边核对返回类型
  case Cons(hd, tl) => max(hd, maxList(tl))
}

空列表没有最大值可交的解法: 用 requires 把空列表拒之门外, 然后 Nil 的 case 整个不写

应该是:

function maxList(l: list<int>): int
  requires l != Nil                                   // 合同: 不收空列表
{
  match l
  case Cons(hd, Nil) => hd                            // 只剩一人, 直接称王(刹车)
  case Cons(hd, tl) => max(hd, maxList(tl))           
}

requires 写在函数体花括号之前, 上面, 不是函数体第一行。

一旦挂上 requires, maxList(tl) 就成了违约现场: 列表剩最后一人时 tl 为空, 递归调用违反合同。所以必须补单人称王的刹车

list-recursion.dfy

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

// write these
function length<T>(l: list<T>): nat
function append<T>(l1: list<T>, l2: list<T>): list<T>

// some lemma might connect these two notions
// lemma ...

长度和拼接之间有条显而易见的性质:

拼起来的长度 = 两段长度之和

lemma LengthAppend<T>(l1: list<T>, l2: list<T>)
  ensures length(append(l1, l2)) == length(l1) + length(l2)
{}

lemma 是拿逻辑锁死"代码没写错"的合同。

写任何递归函数, 问三问:

  1. 类型有几种情况?
  2. 终点那种情况答案是什么?
  3. 递归那种情况, 怎么用"更小的问题的答案"拼出当前答案?

对 length:

  1. 两种情况
  2. Nil 是空列表, 长度是0
  3. Cons(hd, tl) 是"一个头 + 一条尾巴", 长度自然是 1+尾巴的长度。尾巴的长度谁来算? length 自己。
function length<T>(l: list<T>): nat
{
  match l
  case Nil => 0
  case Cons(_, tl) => 1 + length(tl)
}

头是什么值不影响长度, 所以 hd 的位置放 _

length(Cons(1, Cons(2, Nil))): 1 + length(Cons(2, Nil)) →
1 + 1 + length(Nil) = 1 + 1 + 0 = 2

套娃逐层剥开, 结果逐层往回加。

append函数

append: 拼接两条列表

append(l1, l2) 要把 l2 接到 l1 屁股后面。比如 [1,2] + [3] 得 [1,2,3]。

三问前有个先手判断: 对谁做 match? 拆 l1。理由: l2 整条会被原封不动接在后面, 不需要动它, 需要逐个搬运的是 l1 的元素。

  1. l1 分两种情况
  2. l1 是 Nil: 空列表接上 l2, 结果就是 l2 本身
  3. l1 是 Cons(hd, tl): 结果应该是"hd 打头, 后面跟着(把 tl 和 l2 拼好的东西)"

"tl 和 l2 拼好的东西"谁来做? append 自己。

function append<T>(l1: list<T>, l2: list<T>): list<T>
{
  match l1
  case Nil => l2                       tl 是 l1    l1 为 Nil 时 ⇒ append(Nil, l2) 为 l2
  case Cons(hd, tl) => Cons(hd, append(tl, l2))
}                       ↑    ↑          ↑
                     粘合剂  头      剩余的答案

Cons(hd, append(tl, l2)) 在这里不是"拆", 是"装", 把 hd 重新包到递归结果的前面。

同一个构造子, 出现在 case 后面是拆, 出现在 ⇒ 右边是装。把 l1 一层层剥空, 剥到 Nil 时手里正好摊着 l2, 然后回程路上把剥下来的元素按原顺序一个个装回去。

append([1,2], [8,9])
= Cons(1, append([2], [8,9]))                  ← 剥下1, 留欠条: "回来给我套上 Cons(1, _)"
= Cons(1, Cons(2, append(Nil, [8,9])))         ← 剥下2, 又一张欠条
= Cons(1, Cons(2, [8,9]))                      ← 触底。l1 空了, 交出 l2, 这就是手里摊着 l2
= Cons(1, [2,8,9])                             ← 还内层欠条, 2 套上去
= [1,2,8,9]                                    ← 还外层欠条, 1 套上去。"装回去"完毕

append([5,3], [7])
= Cons(5, append([3], [7]))
= Cons(5, Cons(3, append(Nil, [7])))
= Cons(5, Cons(3, [7]))
= Cons(5, [3,7])
= [5,3,7]

自己写 append 又写成

case Cons(hd, tl) ⇒ Cons(Cons(hd, append(tl)), l2)

一. append 少喂一个参数。append 的签名收两条列表, 每次调用都得两条齐上 append(tl, l2)

二. 外层 append(某坨东西, l2) 犯了坑位类型错误。Cons 的两个坑是死规矩, 第一个坑只吃单个元素, 第二个坑才吃列表。

三. 我想在这一行里把整个拼接画出来, 我的代码意图是左边组装好 l1 的前部分, 右边挂上 l2, 一步到位。它违反了递归分工的规律: 本层只干一层的活。只能撬一个头, 套一个头。整个剩下的难题快递给下一层。l2 不在这层里露面, 在 case Nil ⇒ l2 才端出来垫底。

Sum函数

function Sum(l: list<int>): int
{
  match l
  case Nil => 0
  case Cons(hd, tl) => hd + Sum(tl)  
  1. l 分2种情况
  2. Nil 为空, 长度为0
  3. 当前的头, 加剩下的头之和。 加完就是整个列表里的 int 之和。