CMU-15-150-函数式编程笔记-全-
CMU 15-150 函数式编程笔记(全)
01:序幕
概述
在本节课中,我们将要学习函数式编程的基本概念,包括其哲学、核心思想以及我们将要使用的编程语言 Standard ML 的基础知识。我们将探讨什么是函数式编程,它与传统编程的区别,以及为什么学习它对你的编程生涯至关重要。
课程介绍与后勤
大家好,欢迎来到 15150 课程。我是 Brandon,本学期的讲师。课程将使用 Standard ML 语言,重点教授函数式编程范式。
以下是课程的一些重要后勤信息:
- 作业与提交:每周都会有作业,通过 GradeScope 提交,通过 Canvas 接收,问题请在 Piazza 上提问。
- 课程网站:所有幻灯片、课程政策和其他信息都会发布在课程网站上。
- 日程安排:请务必查看 Piazza 上发布的彩色日程表,了解每周的讲座、作业截止日期和考试时间。
关于评分,有两种方案供你选择:
- 讲座参与方案:作业占 42%,讲座参与占 3%,期中考试一占 10%,期中考试二占 15%,期末考试占 20%。
- 纯作业方案:作业占 45%,讲座参与不计分,期中考试一占 10%,期中考试二占 15%,期末考试占 20%。
本周晚些时候会通过 Piazza 发送表单,让大家选择自己偏好的方案。
为了增加趣味性,大家将被分为不同的“学院”,通过积极参与(如在 Piazza 提问/回答、课堂回答问题)为学院赢得积分。积分领先的学院将获得奖励。
课堂将包含不计分的随堂测验,旨在促进主动学习和团队协作。你的表现将帮助助教了解大家的学习情况,以便在实验课上提供更有针对性的帮助。
最后,请务必照顾好自己。充足的睡眠、健康的饮食和适当的社交同样重要。如果在课程中遇到任何困难,请随时联系我或你信任的助教。
什么是函数式编程?
上一节我们介绍了课程的基本情况,本节中我们来看看函数式编程到底是什么。
首先,让我们思考一个更基本的问题:什么是编程?编程是指令计算机执行某些操作的行为,本质上是一种沟通行为——你与计算机沟通,也与未来阅读代码的人(包括你自己)沟通。因此,好的编程应该是描述性的、模块化的和可维护的。
那么,什么不是函数式编程呢?让我们看一个命令式编程的例子:
count = 0
def increment():
global count
count = count + 1
return count
调用 increment() 函数会返回什么?答案是:它取决于程序的历史状态。count 是一个可变的状态,函数的结果依赖于之前被调用了多少次。这种对状态的依赖使得程序难以推理,因为要理解一个函数调用,你需要知道整个程序到目前为止的所有历史。
相比之下,函数式编程避免修改状态。它更像一个分工明确的厨房,每个厨师有自己的工作台和厨具,只在完成一道工序后将成品传递给下一位厨师,彼此不会互相干扰。在函数式编程中,计算是通过将表达式规约为值来完成的,例如 2 + 2 总是规约为 4,这个结果不会因为程序的其他部分而改变。
函数式编程的核心是使用纯函数。纯函数就像数学函数一样,给定相同的输入,总是返回相同的输出,并且没有副作用(如修改外部状态、读写文件等)。许多计算机科学问题本身是纯粹的(如寻找最短路径、计算第 n 个质数),因此用纯函数来解决它们更为自然和可靠。
本课程将围绕三个核心论点展开:
- 递归问题,递归解决:在函数式编程中,递归是解决问题的基本方式。
- 编程思维即数学思维:我们将像数学家一样,使用数学工具(如证明)来推理程序的正确性和复杂性。
- 类型指导结构:类型系统是沟通的蓝图,它规定了代码能做什么,帮助我们在编写代码前就规划好结构,防止错误,并作为清晰的文档。
Standard ML 基础:类型、表达式与值
上一节我们探讨了函数式编程的理念,本节中我们来看看如何在 Standard ML 中具体实践。
在 Standard ML 中,计算就是求值。我们有以下基本构建块:
- 表达式:可以求值的代码片段,例如
2 + 3、4 * 5、150。 - 值:表达式求值的最终结果,无法再被简化,例如整数
2、字符串"hi"、布尔值true。
求值过程是一步步的简化。我们用 ---> 表示单步规约,用 ===> 表示多步规约(直到得到值或无法进行)。例如:
(2 + 3) * 4 ---> 5 * 4 ---> 20,所以 (2 + 3) * 4 ===> 20。
一个 SML 表达式求值后,只会发生三种情况之一:
- 规约到一个值。
- 引发一个异常(例如
1 div 0)。 - 永远循环下去。

Standard ML 是静态类型语言。每个表达式都有一个类型,类型规定了该表达式可以产生何种值。我们用 e : t 表示表达式 e 具有类型 t。例如:
1 : int"hi" : stringtrue : bool

类型检查器会在程序运行之前,根据一系列类型规则来验证程序是否类型正确。例如,加法规则要求两边都是 int,结果才是 int。对于表达式 "1" ^ 50(字符串连接),类型检查器会发现 50 不是 string 类型,因此判定该表达式类型错误。类型错误的程序根本不会运行,这能在早期阻止许多潜在的错误。



声明:变量与函数

上一节我们学习了表达式和类型,本节中我们来看看如何在 SML 中定义变量和函数,这是构建程序的基础。
在 SML 中,我们使用 val 关键字来声明变量:
val courseNumber : int = 150
这声明了一个名为 courseNumber、类型为 int、值为 150 的变量。
我们使用 fun 关键字来声明函数。一个函数定义包含几个部分:
fun double (n : int) : int = n + n
fun:函数声明关键字。double:函数名。(n : int):参数及参数类型。: int:返回值类型。n + n:函数体,即要计算的表达式。
函数应用(调用)通过并置完成,即函数名后直接跟参数,中间用空格分隔:
double 2
求值时,会将参数 2 代入函数体,得到 2 + 2,最终规约为 4。

类型注解是可选的,但建议加上。SML 会推导出表达式的类型,然后检查是否与你的注解一致。如果不一致,程序将是类型错误的,无法运行。


总结
本节课中我们一起学习了函数式编程的序幕。我们了解了函数式编程的核心理念:通过使用纯函数、避免可变状态来编写更描述性、模块化和可维护的代码。我们探讨了 Standard ML 的基础,包括表达式、值、求值以及强大的静态类型系统,该系统能在运行前捕获错误。最后,我们学习了如何声明变量和函数。请记住本课程的三个核心论点:拥抱递归、像数学家一样思考、让类型系统指导你的程序结构。函数式编程不仅仅是一种编码技术,更是一种思维方式,它将伴随你整个编程生涯。
02:等价性、绑定与作用域 😊

在本节课中,我们将学习函数式编程中的核心概念:等价性、绑定与作用域。我们将探讨如何通过绑定来管理变量,理解作用域如何影响函数行为,并学习如何利用等价性进行数学推理,从而编写更清晰、更可靠的代码。
类型回顾与扩展 📚
上一节我们介绍了表达式、值和类型的基本概念。本节中,我们来看看更多的基础类型以及如何组合它们。
SML 提供了一些基础类型,它们是构建更复杂类型的基石。
以下是 SML 中的一些基础类型:
int:整数类型,例如1、150、-3。real:实数(浮点数)类型,例如0.0、0.12。bool:布尔类型,只有两个值true和false。string:字符串类型,例如""、"Hello"。char:字符类型,使用#"a"、#"B"表示。
除了基础类型,我们还可以通过类型构造器组合它们,创建新的类型。元组(tuple)就是一种组合类型。
元组类型与求值规则 📦
元组允许我们将多个值组合成一个复合值。例如,(1, 2) 是一个包含两个整数的元组。
元组的类型写作其各组成部分类型的“星积”。例如,(1, 2) 的类型是 int * int。元组的求值遵循从左到右的顺序。
考虑表达式 (1+1, 2*3) 的求值过程:
- 首先求值最左边的非值子表达式
1+1,得到2。(1+1, 2*3)→(2, 2*3)(根据加法规则)
- 接着求值
2*3,得到6。(2, 2*3)→(2, 6)(根据乘法规则)
- 最终得到值
(2, 6)。
元组的类型规则是:如果表达式 E1 的类型是 T1,E2 的类型是 T2,那么元组 (E1, E2) 的类型就是 T1 * T2。括号在元组中很重要,(1, (2, 3)) 和 (1, 2, 3) 是不同类型和不同结构的值。
函数类型与 Lambda 表达式 🔧
函数也是值,并且有特定的类型。函数类型使用箭头 -> 表示。例如,一个将整数翻倍的函数 double 的类型是 int -> int。


我们可以通过 fun 声明来定义函数,例如:
fun double (n: int): int = n + n
另一种定义函数的方式是使用 lambda 表达式(匿名函数)。其语法是 fn 关键字后接参数、箭头和函数体。

以下是一个 lambda 表达式示例:
fn (n: int) => n + n
这个表达式本身就是一个值,代表一个“匿名”的加倍函数。我们可以将它绑定到一个变量,或者直接应用它:
(fn (n: int) => n + n) 2
这个表达式会求值得出 4。Lambda 表达式是匿名的,因此无法在其自身内部进行递归调用。
函数求值遵循特定规则。对于函数应用 E1 E2:
- 首先将
E1求值为一个函数值。 - 然后将
E2求值为一个值。 - 最后,将参数值代入函数体进行求值。
绑定、作用域与闭包 🔒
在函数式编程中,变量绑定(binding)与命令式语言中的赋值(assignment)有根本区别。绑定是将一个名字与一个值永久关联起来,而赋值是改变一个已存在名字所指向的值。
考虑以下代码:
val x: int = 2
fun foo (y: int): int = x + y
val x: int = 4
val result: int = foo 1
这里,result 的值是 3,而不是 5。第二个 val x = 4 创建了一个新的绑定,遮蔽了第一个 x,但它没有改变函数 foo 在定义时所记住的 x 的值(即 2)。foo 的行为在其定义时就被固定了。
这是因为函数在创建时,会捕获其定义时的环境(所有活跃的绑定),形成一个闭包(closure)。闭包包含函数代码和其创建时的环境。后续的绑定不会影响闭包内记住的环境。
因此,函数的行为是纯的:给定相同的输入,总是产生相同的输出。这带来了代码的模块性:要理解一个函数的行为,只需查看其定义之前的代码,之后的代码无法影响它。

模式匹配 🧩




模式匹配是一种强大的工具,用于解构值(如元组)并提取其组成部分。

我们可以使用模式来绑定元组中的值:
val (x: int, y: int) = (1, 2)
执行后,x 被绑定为 1,y 被绑定为 2。

模式有多种形式:
- 变量模式:如
x,匹配任何值并将其绑定到该变量。 - 通配符模式:如
_,匹配任何值但不产生绑定。 - 常量模式:如
2,只匹配该特定值。 - 元组模式:如
(x, y),匹配元组并解构其内容。
val 绑定的通用形式是 val <pattern> = <expression>。表达式求值后,其结果会与模式进行匹配。如果模式匹配成功,则根据模式创建绑定;如果失败,则引发异常。模式与表达式的类型必须兼容。

条件表达式与函数子句 🌳
SML 使用 if-then-else 进行条件判断。它是一个表达式,两个分支必须返回相同类型。
例如,判断奇偶的函数:
fun isEven (n: int): bool =
if n mod 2 = 0
then true
else false
注意,if n mod 2 = 0 then true else false 在逻辑上等价于 n mod 2 = 0。应避免编写这种冗余的条件表达式。
对于有多个条件分支的函数,使用函数子句和模式匹配通常更清晰。例如,阶乘函数:
(* 使用 if 表达式 *)
fun fact (n: int): int =
if n = 0
then 1
else n * fact (n-1)
(* 使用函数子句和模式匹配 *)
fun fact 0 = 1
| fact (n: int) = n * fact (n-1)
第二个版本更简洁,更接近数学定义。求值 fact 3 时,SML 会依次尝试匹配子句:3 不匹配 0,但匹配变量模式 n,然后执行 3 * fact(2)。
外延等价性与指称透明性 ⚖️
外延等价性是函数式编程中一个核心的数学概念。如果两个表达式在所有可能的环境下:
- 都求值为相同的值,或
- 都永远循环,或
- 都引发相同的异常
那么它们就是外延等价的,记作E1 ≅ E2。
例如,1+1、2 和 3-1 都是外延等价的,因为它们都求值得出 2。由于函数是纯的,fact 3 在任何地方都等价于 6。
指称透明性是外延等价性带来的一个重要性质。它意味着在一个程序中,任何外延等价的表达式都可以相互替换,而不会改变程序的整体行为。这允许我们像解数学方程一样推理代码,进行安全的代码重构和优化。
例如,我们可以将复杂的条件逻辑 if (flag andalso (if perm then true else false)) then true else false,通过一系列等价替换,简化为 flag andalso perm。每一步替换都基于指称透明性,保证了程序行为的正确性。

本节课中我们一起学习了函数式编程的关键基石:通过绑定和作用域实现的不可变性、利用模式匹配进行清晰的数据解构、以及基于外延等价性和指称透明性进行的数学化程序推理。这些概念共同构成了编写可靠、可维护函数式代码的基础。
03:归纳与递归 🧠


在本节课中,我们将学习归纳与递归这两个核心概念,并探索它们之间深刻的联系。我们将看到,编程中的递归思维与数学中的归纳证明本质上是相通的。通过结合两者,我们可以编写并证明代码的正确性。
等价性与规范 📝


上一节我们介绍了函数类型和变量绑定。本节中,我们来看看如何定义函数的等价性,以及如何规范地描述函数的行为。
函数的等价性
在函数式编程中,我们关心函数的行为。两个函数 f 和 g(类型均为 T1 -> T2)是外延等价的,当且仅当对于所有可能的输入值 v(类型为 T1),f(v) 和 g(v) 是外延等价的。这意味着,即使它们内部实现不同,只要在所有输入上行为一致,它们就是等价的。
例如,以下两个函数是外延等价的:
fn x => x + x
fn x => 2 * x
因为对于任何整数输入 x,x + x 的结果总是等于 2 * x。
全函数与部分函数
一个函数被称为全函数,如果对于其定义域(输入类型 T1)中的每一个值,它都能产生一个定义域(输出类型 T2)中的值。换句话说,它不会在某些输入上永远循环或抛出异常。例如,整数加法 + 是全函数,而整数除法 div 在除数为零时会产生异常,因此不是全函数。
函数规范(五要素法)
为了清晰地描述函数行为,我们使用一种包含五个要素的规范方法:
- 类型:声明函数的输入和输出类型。
- 功能描述:用自然语言简述函数的作用。
- 前置条件:描述调用函数前,输入必须满足的条件。
- 后置条件:描述函数返回后,输出满足的性质。
- 函数实现:实际的 SML 代码。
以下是 divideByTwo 函数的规范示例:
(* 类型: int -> int
* 描述: 计算输入整数除以2的结果(整数除法)。
* 要求: 无(但需注意整数除法的截断特性)。
* 返回: 输入值 `x` 的 `x div 2`。
*)
fun divideByTwo (x: int): int = x div 2
编写测试用例也是良好实践的一部分,可以使用课程提供的测试库。
递归:编程的核心 🔁
现在,让我们转向递归。在缺乏 for 或 while 循环的函数式语言中,递归是我们实现迭代和重复计算的主要工具。
什么是递归?
一个函数是递归的,如果它在定义中调用了自身。递归是解决许多问题的自然方式,尤其是在处理具有自相似结构的数据(如列表、树)时。
编写递归函数的四步法
以下是编写任何递归函数的通用方法:
- 识别并编写基本情况:这是递归停止的条件。例如,计算阶乘时,
factorial(0) = 1是基本情况。 - 识别递归情况:这是函数需要调用自身的条件。在阶乘中,当
n > 0时是递归情况。 - 递归信念飞跃:假设函数已经能正确解决规模更小的子问题。这是递归思维的关键——你不需要在脑海中展开所有递归调用。
- 组合结果:利用对“更小问题”的解决方案,构造出原始问题的解。对于阶乘,即
factorial(n) = n * factorial(n-1)。
让我们用这个方法来编写一个计算 n 的 k 次方的函数 power:
(* 类型: int * int -> int
* 描述: 计算 n 的 k 次方。
* 要求: k >= 0。
* 返回: n ^ k。
*)
fun power (n: int, k: int): int =
case k of
0 => 1
| _ => n * power (n, k-1)
- 基本情况:当指数
k为 0 时,任何数的 0 次方都是 1。 - 递归情况:当
k > 0时,我们利用信念飞跃,假设power(n, k-1)能正确计算n^(k-1),然后将其乘以n得到n^k。
归纳:数学的基石 📐
我们刚刚看到了递归在编程中的力量。有趣的是,在数学中有一个几乎完全对应的概念——数学归纳法。
什么是数学归纳法?
数学归纳法是一种证明关于自然数命题的方法。要证明命题 P(n) 对所有自然数 n 成立,需要两步:
- 归纳基础:证明
P(0)成立。 - 归纳步骤:假设
P(k)对某个自然数k成立(这称为归纳假设),然后证明P(k+1)也成立。
如果这两步都成立,那么根据归纳原理,P(n) 对所有自然数 n 都成立。
归纳证明的结构
- 陈述命题:明确要证明的
P(n)是什么。 - 归纳基础:证明
P(0)(或P(1))为真。 - 归纳假设:假设对于某个
k,P(k)成立。 - 归纳步骤:利用归纳假设,推导并证明
P(k+1)成立。 - 结论:由数学归纳法,命题得证。
示例:前 n 个奇数和
让我们证明一个定理:前 n 个奇数的和等于 n^2。即,令 S(n) = 1 + 3 + 5 + ... + (2n-1),则 S(n) = n^2。
- 归纳基础:当
n=1时,S(1)=1,且1^2=1,成立。 - 归纳假设:假设对于某个
k,有S(k) = k^2。 - 归纳步骤:考虑
S(k+1)。
S(k+1) = S(k) + 第(k+1)个奇数 = S(k) + (2(k+1)-1) = k^2 + (2k+1)(根据归纳假设)
而k^2 + 2k + 1 = (k+1)^2。
因此,S(k+1) = (k+1)^2。 - 结论:由数学归纳法,对于所有自然数
n,S(n) = n^2成立。
递归与归纳的统一 🤝
现在,让我们将编程与数学联系起来。比较编写递归函数的步骤和进行归纳证明的步骤,你会发现惊人的相似性:
| 递归(编程) | 归纳(数学) |
|---|---|
| 1. 识别基本情况 | 1. 证明归纳基础 (P(0)) |
| 2. 识别递归情况 | 2. 设定归纳步骤的目标 (P(k+1)) |
| 3. 递归信念飞跃(假设函数对更小输入有效) | 3. 引入归纳假设 (P(k)) |
| 4. 组合结果 | 4. 利用归纳假设证明目标 |
变量递推(如 power 中的 k)直接对应归纳变量。这种对应关系意味着,我们可以使用归纳法来证明递归函数的正确性。
证明 power 函数的正确性
让我们用归纳法证明之前定义的 power 函数确实计算了 n^k。
定理:对于所有满足 k >= 0 的整数 n 和 k,power(n, k) 求值结果为 n^k。
证明:对 k 进行数学归纳。
- 归纳基础 (
k=0):
power(n, 0)根据函数定义(子句1)直接求值为1,而n^0 = 1。基础成立。 - 归纳假设:假设对于某个
k,power(n, k)求值结果为n^k。 - 归纳步骤:证明
power(n, k+1)求值结果为n^(k+1)。
power(n, k+1)根据函数定义(子句2,因为k+1 > 0)求值为n * power(n, k)。
根据归纳假设,power(n, k)求值结果为n^k。
因此,power(n, k+1)求值结果为n * n^k = n^(k+1)。 - 结论:由数学归纳法,定理得证。
通过这个证明,我们不仅编写了函数,还确凿地知道它是正确的。这正是“程序性思维即数学性思维”的体现。
SML 语言特性补充 🛠️
在深入下一个案例前,我们补充两个重要的 SML 语言特性。
Case 表达式
除了在函数定义中使用模式匹配,SML 还提供了 case 表达式,允许在任何地方进行模式匹配。
case someExpression of
pattern1 => result1
| pattern2 => result2
| ...
所有分支的结果类型必须相同。case 表达式本身也是一个表达式,可以嵌套在其他表达式中。
列表
列表是存储零个或多个相同类型元素的数据结构。类型写作 t list,例如 int list、string list。
- 空列表:写作
[]或nil。 - 构造列表:使用中缀操作符
::(读作“cons”)。x :: xs表示将元素x添加到列表xs的头部。 - 语法糖:
[1, 2, 3]是1 :: 2 :: 3 :: []的简便写法。
列表是一个递归定义的结构:一个列表要么是空的 ([]),要么是一个元素连接在另一个列表之前 (x :: xs)。因此,处理列表的函数通常是递归的,并基于这两种情况进行模式匹配。
例如,判断列表是否为空的函数:
fun is_empty (lst: int list): bool =
case lst of
[] => true
| (x::xs) => false
案例研究:快速幂算法 ⚡
最后,我们来看一个结合了递归、归纳和性能优化的精彩案例:快速幂算法。
问题与朴素解法
计算 n 的 k 次方。我们已有的 power 函数需要进行 k 次乘法,时间复杂度是 O(k)。
更快的数学原理
利用指数运算的性质,我们可以减少乘法次数:
- 如果
k是偶数:n^k = (n^(k/2))^2 - 如果
k是奇数:n^k = n * n^(k-1) = n * (n^((k-1)/2))^2
注意,这里出现了 n^(k/2) 或 n^((k-1)/2) 的平方。这暗示了一个递归结构,但需要计算两次相同的子问题。
初始实现及其问题
直接翻译上述公式:
fun fast_power_v1 (n: int, k: int): int =
case k of
0 => 1
| _ =>
if k mod 2 = 0
then (fast_power_v1 (n, k div 2)) * (fast_power_v1 (n, k div 2)) (* 计算了两次! *)
else n * (fast_power_v1 (n, k-1))
这个实现的问题是,在偶数分支中,相同的递归调用 fast_power_v1 (n, k div 2) 被计算了两次,浪费了时间。
利用 Let 表达式和引用透明性优化
SML 的 let 表达式允许我们在一个局部作用域内命名一个值。结合函数的引用透明性(相同输入总是产生相同输出),我们可以只计算一次子问题并复用结果。
fun fast_power (n: int, k: int): int =
case k of
0 => 1
| _ =>
if k mod 2 = 0
then let
val half_pow = fast_power (n, k div 2)
in
half_pow * half_pow
end
else n * fast_power (n, k-1)
现在,fast_power 在每次递归调用中最多只进行一次递归调用,算法复杂度降低到了 O(log k),速度显著提升。
正确性证明思路

我们可以使用强归纳法来证明 fast_power 与朴素的 power 函数是外延等价的(即计算相同的结果)。证明的关键在于,无论 k 是奇是偶,fast_power 都通过递归调用处理了规模更小的指数(k div 2 或 k-1),并利用数学恒等式组合出正确结果。详细的证明会涉及对 k 的奇偶性进行分类讨论,并应用归纳假设。

总结 🎯

本节课中我们一起学习了:
- 函数的等价性与规范:如何定义函数的行为等价,以及如何使用五要素法清晰规范地描述函数。
- 递归:作为函数式编程的核心,我们学习了编写递归函数的四步法,并通过
power函数进行了实践。 - 归纳法:回顾了数学归纳法的原理和结构,并证明了关于奇数和的定理。
- 递归与归纳的统一:认识到递归编程与归纳证明在结构上的深刻对应,并首次使用归纳法证明了递归函数
power的正确性。 - SML 特性:了解了
case表达式和列表的基本操作。 - 案例研究:将递归、归纳和引用透明性结合,设计并优化了快速幂算法,体验了通过数学原理提升程序效率的过程。


核心在于理解:编写递归函数的过程,本质上就是为计算过程构造一个归纳证明。这种思维模式将贯穿我们整个函数式编程的学习。
04:结构归纳与尾递归 🧮

在本节课中,我们将学习两种重要的编程概念:结构归纳和尾递归。结构归纳是一种证明递归数据结构(如列表)性质的方法,而尾递归则是一种优化递归函数性能的技术。我们将通过具体的例子来理解它们的工作原理和实际应用。

数据解构 🧩
上一节我们介绍了递归和归纳的基本概念。本节中,我们来看看如何更有效地处理数据。
在编程中,我们经常需要“解构”数据,即根据数据的形状来提取信息。在SML中,我们使用 case 表达式进行模式匹配来实现这一点。
例如,解构一个列表就像对它进行模式匹配。我们可以这样写:
case L of
nil => ...
| x::xs => ...
这里,x::xs 是一个模式,它将列表分解为第一个元素 x 和剩余部分 xs。这种解构方式比先检查列表是否为空(得到一个布尔值),再分别调用 head 和 tail 函数来获取元素要强大得多。
以下是使用模式匹配与使用验证函数的对比:
- 模式匹配(解析):直接获取数据的组成部分(如
x和xs),信息更丰富,代码更简洁高效。 - 验证函数:先通过一个函数(如
isEmpty)返回一个布尔值,再根据布尔值调用其他函数获取数据。这种方式冗余且容易出错。
核心思想:尽可能使用模式匹配来“解析”数据,而不是编写函数来“验证”数据属性。这能使代码更安全、更清晰、更高效。
结构归纳 📐
上一节我们了解了如何解构数据。本节中,我们来看看如何证明关于递归数据结构(如列表)的性质。
数学归纳法让我们可以证明关于自然数的定理。类似地,结构归纳法让我们可以证明关于列表(或其他递归定义的数据结构)的定理。
结构归纳原理(针对列表):
要证明对于所有类型为 T list 的值 L,性质 P(L) 成立,只需证明以下两点:
- 基础情况:
P(nil)成立。 - 归纳步骤:对于任意
x: T和xs: T list,假设P(xs)成立(归纳假设),能推导出P(x::xs)也成立。
这类似于自然数归纳法:基础情况对应0,归纳步骤对应从 n 到 n+1。
示例:证明 length 函数是完备的
我们想证明:对于任何整数列表 L,length(L) 都会求值为某个值(即函数是完备的)。
证明(通过结构归纳法):
我们对 L 进行结构归纳。
-
基础情况:
L = nil。
根据定义,length(nil)求值为0。这是一个值,因此基础情况成立。 -
归纳步骤:假设对于某个列表
xs,length(xs)求值为某个值v(归纳假设)。现在考虑列表x::xs。
根据length的定义,length(x::xs)求值为1 + length(xs)。
根据归纳假设,length(xs)求值为v,因此1 + v也是一个值。
所以,length(x::xs)也求值为一个值。
根据结构归纳法原理,对于所有列表 L,length(L) 都会求值为一个值。证明完毕。
正确量化的关键:在归纳假设中,我们假设性质对某个任意但具体的列表 xs 成立,然后证明它对 x::xs 也成立。这确保了性质能通过不断在列表前添加元素,覆盖所有可能的列表。
尾递归与更多列表函数 🔄
上一节我们学习了如何证明列表函数的性质。本节中,我们来看看如何优化递归函数的性能。
尾递归的概念

观察 length 函数的实现:
fun length nil = 0
| length (x::xs) = 1 + (length xs)
在递归情况 (x::xs) 中,函数需要先计算 length xs,得到结果后再进行 +1 操作。这意味着每次递归调用后,系统都需要“记住”还要做一次加法。对于长列表,这会导致很长的调用链,占用大量内存。
尾递归是一种特殊的递归形式:在递归情况下,递归调用是函数体最后执行的操作。这样,系统就不需要保存额外的状态,可以复用当前的调用栈,从而大幅提升空间效率,并避免栈溢出。

使用累加器实现尾递归
为了实现尾递归,我们常引入一个累加器参数,它携带了到目前为止的计算结果。
以下是尾递归的 length 函数实现:
(* 辅助函数:tail-recursive length *)
fun tlength nil acc = acc
| tlength (x::xs) acc = tlength xs (1 + acc)

(* 主函数 *)
fun length L = tlength L 0
在 tlength 中,递归调用 tlength xs (1+acc) 是函数体最后一步。累加器 acc 负责在递归过程中累加长度。初始调用时,累加器从0开始。

列表反转与效率问题
考虑一个直观的列表反转函数:
fun rev nil = nil
| rev (x::xs) = (rev xs) @ [x]
这个函数是正确的,但效率很低。因为 @(append)操作的时间复杂度与左操作数列表的长度呈线性关系。在 rev 的递归中,我们会反复对越来越长的列表调用 @,导致总时间复杂度为 O(n²)。
我们可以利用尾递归和累加器,在线性时间内完成反转:
(* 尾递归反转 *)
fun trev nil acc = acc
| trev (x::xs) acc = trev xs (x::acc)
fun rev L = trev L nil
trev 函数将输入列表的元素依次取出,并放入累加器列表的头部。这就像把元素从一个栈移到另一个栈,自然实现了反转。由于是尾递归,且只涉及常数时间的 :: 操作,其时间复杂度和空间复杂度都是 O(n)。
总结 ✨
本节课我们一起学习了两个核心概念:

- 结构归纳:一种用于证明递归数据结构(如列表)性质的强大证明技术。它通过基础情况和归纳步骤,确保性质对所有可能的数据实例都成立。
- 尾递归:一种优化递归函数的技术。通过确保递归调用是函数的最后一步操作,并配合使用累加器,可以显著减少函数的内存占用,避免栈溢出,并常常能提升时间效率。我们通过将
length和rev函数重写为尾递归形式,具体实践了这一技术。

理解并运用结构归纳和尾递归,对于编写正确、高效且优雅的函数式程序至关重要。
05:树与代数数据类型
在本节课中,我们将要学习树这种数据结构。树是计算机科学中除列表外最基本的数据结构之一。我们会学习如何定义树、在树上进行遍历,并通过结构归纳法证明关于树的性质。最后,我们将探讨如何通过代数数据类型来精确地建模问题,使非法状态无法表示。
05-1:树的定义与基本操作 🌲
上一节我们介绍了列表上的结构归纳法。本节中,我们来看看另一种递归数据结构:树。
SML 没有内置的树类型,但我们可以使用数据类型声明来定义它。一个二叉树的定义如下:
datatype tree = Empty
| Node of tree * int * tree
这个声明创建了一个名为 tree 的新类型。它有两个构造器:
Empty:一个常量构造器,表示空树。Node:一个递归构造器,它接受一个左子树、一个整数值和一个右子树,并组合成一个新的树。
这与列表的定义类似:Empty 对应 nil,Node 对应 cons。我们可以使用 case 表达式对树进行模式匹配。
以下是几个树的例子:
- 空树:
Empty - 只有一个根节点(值为150)的树:
Node(Empty, 150, Empty) - 一个更复杂的树:
Node(Node(Empty, 1, Empty), 5, Node(Empty, 0, Empty))
我们可以为树编写递归函数。例如,计算树中所有节点值的和:
fun treeSum (Empty) = 0
| treeSum (Node(L, x, R)) = treeSum(L) + x + treeSum(R)
在节点情况下,我们需要对左子树和右子树分别进行递归调用,然后将结果与当前节点的值相加。
05-2:树的遍历 🔄
上一节我们介绍了树的基本定义和简单操作。本节中,我们来看看如何系统地访问树中的所有元素,即树的遍历。
直接为树编写各种函数(如求和、计数)可能会产生大量重复代码。一个更好的思路是将树转换为列表,然后复用为列表编写的函数。我们需要一个类型为 tree -> int list 的函数。
有三种主要的树遍历方式,区别在于访问 根节点 (Root)、左子树 (Left) 和 右子树 (Right) 的顺序:
以下是三种遍历方式的定义:
- 前序遍历 (Preorder):顺序为 根 -> 左 -> 右。
- 中序遍历 (Inorder):顺序为 左 -> 根 -> 右。
- 后序遍历 (Postorder):顺序为 左 -> 右 -> 根。
以中序遍历为例,其实现如下:
fun inorder (Empty) = []
| inorder (Node(L, x, R)) = inorder(L) @ [x] @ inorder(R)
对于节点,我们递归地获取左子树的中序遍历列表,将当前节点的值放入列表,再与右子树的中序遍历列表连接起来。
类似地,可以实现前序遍历:
fun preorder (Empty) = []
| preorder (Node(L, x, R)) = x :: (preorder(L) @ preorder(R))
现在,要计算树的节点数,我们可以先中序遍历得到列表,再计算列表长度:length (inorder T)。这避免了为树单独编写计数函数。
05-3:树上的结构归纳法证明 📜
上一节我们学习了树的遍历。本节中,我们将使用结构归纳法来证明关于树的一个性质。
结构归纳法的原理与在列表上类似。要证明对于所有树 T,性质 P(T) 成立,我们需要证明两个步骤:
- 基础步骤:证明
P(Empty)成立。 - 归纳步骤:假设对于任意树
L和R,P(L)和P(R)成立(归纳假设),然后证明对于任何整数x,P(Node(L, x, R))也成立。
我们将证明:对于任何树 T,treeSum(T) 本质上等价于 listSum(inorder(T))。即,直接对树求和与先将树转为列表再对列表求和,结果相同。
证明过程如下:
我们进行关于树 T 的结构归纳。
-
基础步骤:
T = Empty。- 左边:
treeSum(Empty) = 0。(根据treeSum定义) - 右边:
listSum(inorder(Empty)) = listSum([]) = 0。(根据inorder和listSum定义) - 两边都等于0,因此基础步骤成立。
- 左边:
-
归纳步骤:假设对于特定的树
L和R,归纳假设成立:-
IH1:treeSum(L) == listSum(inorder(L)) -
IH2:treeSum(R) == listSum(inorder(R)) -
需要证明:
treeSum(Node(L, x, R)) == listSum(inorder(Node(L, x, R))) -
处理左边:
treeSum(Node(L, x, R)) = treeSum(L) + x + treeSum(R)(根据treeSum定义)
== listSum(inorder(L)) + x + listSum(inorder(R))(应用归纳假设 IH1 和 IH2) -
处理右边:
listSum(inorder(Node(L, x, R))) = listSum(inorder(L) @ [x] @ inorder(R))(根据inorder定义)
这里需要一个引理:listSum(L1 @ L2) == listSum(L1) + listSum(L2)。要应用此引理,我们必须确保inorder(L)和[x] @ inorder(R)是值(即表达式已求值完毕)。这可以通过引用inorder函数的完全性来证明。
应用引理和完全性后,右边简化为:listSum(inorder(L)) + listSum([x]) + listSum(inorder(R))。
而listSum([x]) = x。
因此,右边也等于listSum(inorder(L)) + x + listSum(inorder(R))。 -
左边和右边化简后形式相同,故归纳步骤成立。
-
根据结构归纳法原理,原命题对所有树 T 成立。
这个证明的关键点在于,当应用依赖于“值”的定理或定义时(如引理或 listSum 的第二个子句),必须确保代入的表达式本身是值。我们通常通过引用相关函数的完全性来保证这一点。
05-4:选项类型与变体类型 🎭
上一节我们完成了树上的归纳证明。本节中,我们来看看如何使用代数数据类型来更好地建模数据,处理可能缺失的值。
考虑一个函数 last,它返回列表的最后一个元素。对于空列表,没有“最后一个元素”。我们可能想返回一个默认值(如-1),但这会与确实包含-1的列表产生歧义。我们也可以抛出异常,但这会让调用者难以处理。
SML 提供了一个优雅的解决方案:选项类型 (option type)。
datatype 'a option = None
| Some of 'a
'a option 类型表示一个可能存在的 'a 类型的值。
None:表示没有值。Some(v):表示存在值v。
现在,我们可以重写 last 函数:
fun last ([]) = None
| last ([x]) = Some(x)
| last (_::xs) = last(xs)
调用者可以清晰地处理两种情况:
case last(myList) of
None => (* 处理空列表情况 *)
| Some(x) => (* 使用最后一个元素 x *)
选项类型是一种变体类型或和类型。列表和树也是变体类型。变体类型的关键在于,一个值可以是有限多个不同“变体”中的某一个,并且我们可以通过模式匹配来安全地处理所有情况。

05-5:代数数据类型的威力:使非法状态无法表示 🔒
上一节我们看到了选项类型如何改进接口设计。本节中,我们将进一步探索代数数据类型的核心哲学:使非法状态无法表示。

假设我们需要比较两个整数,结果可能是小于、等于或大于。一种糟糕的做法是返回整数代码(如-1, 0, 1),因为调用者可能忘记处理其他整数。另一种稍好的做法是返回字符串(“LESS”, “EQUAL”, “GREATER”),但调用者仍可能拼写错误或遗漏情况。
最佳实践是定义一个专用的比较结果类型:
datatype order = LESS
| EQUAL
| GREATER
fun compare (x, y) =
if x < y then LESS
else if x = y then EQUAL
else GREATER
现在,调用者的代码是完备且安全的:

case compare(x, y) of
LESS => ...
| EQUAL => ...
| GREATER => ...

编译器能确保所有情况都被处理,且不会出现拼写错误,因为 LESS、EQUAL、GREATER 是仅有的三个构造器。
更复杂的例子:人员信息建模

假设我们要表示人员信息:有些人被雇佣,有些是学生,有些失业。每种情况关心的字段不同。
一种笨拙的方法是使用一个大元组,为不相关的字段填充 option 类型,但这会导致许多必须手动维护的不变量(例如,“如果被雇佣,则课程数应为 None”)。
更好的方法是使用代数数据类型,将数据与对应的变体直接关联:
datatype class = Employed of real * string
| Student of real * int
| Unemployed of (int * string) option
type person = string * class


这样,每个 class 变体只携带它确实需要的数据。创建一个被雇佣的人员变得简单且无误:

val bob = ("Bob", Employed(2000.0, "Bank of America"))
这种设计消除了无效状态的可能性。如果你有一个 Student 值,它必然包含学费和课程数,而不可能包含公司名。类型系统为我们保证了这一点。

这种根据问题领域“铸造”出合适类型的能力,就是代数数据类型强大的表现力所在。它让我们将复杂的约束编码在类型中,从而在编译期捕获大量错误,而非等到运行时。
总结


本节课中我们一起学习了:
- 树的定义与递归操作:使用
datatype声明递归的树结构,并编写递归函数对其进行处理。 - 树的遍历:掌握了前序、中序、后序遍历及其 SML 实现,理解了通过遍历将树转化为列表以复用代码的思想。
- 树上的结构归纳法:将归纳法推广到树结构,需要两个归纳假设,并学习了在证明中处理表达式“值性”的重要性。
- 选项类型与变体类型:使用
option类型优雅地处理可能缺失的值,并理解了变体类型通过模式匹配实现安全解构。 - 代数数据类型的哲学:通过自定义数据类型(如
order和精细化的class)来精确建模问题域,核心目标是“使非法状态无法表示”,从而利用类型系统在编译期捕获错误,编写出更健壮、更清晰的代码。
06:渐进分析 🧮




在本节课中,我们将学习如何分析代码的性能。我们将从数学角度理解渐进分析,学习大O记号的原理,并通过建立和求解“工作量”递推关系来量化函数的运行时间成本。最后,我们将探讨函数式编程中一个热门话题——并行性。
渐进复杂度概述
到目前为止,我们主要专注于函数式编程的编码和代码证明。然而,性能同样重要。性能很难精确衡量,因为我们通常用计算机执行的“步数”来讨论,但“一步”的含义在不同硬件或实现细节下可能不同。为了进行与硬件无关的数学分析,我们关注数据规模趋于极限时的性能,这就是大O记号的用武之地。
大O记号理论 📈
大O记号用于描述函数在输入规模趋于无穷时的渐进行为。我们从一个描述运行时成本的函数 f 开始,其类型为自然数到自然数(f: ℕ⁺ → ℕ⁺)。输入大小是数据规模的某种度量(例如,列表的长度或数字的值)。
形式化定义:我们说函数 f 属于 O(g),如果存在常数 n₀ 和 C,使得对于所有 n ≥ n₀,都有 f(n) ≤ C * g(n)。
n₀的作用:它允许我们忽略函数前期的行为,只关心“最终”当n足够大时,f(n)是否被g(n)控制。这让我们能忽略常数加性因子。C的作用:它允许我们对g(n)进行常数倍的缩放。这确保了我们只关心函数的增长趋势,而不关心具体的常数系数。例如,0.5n和n具有相同的线性增长趋势。
在课程中,我们主要关心以下几种复杂度类别,它们构成一个层次结构:
O(1) < O(log n) < O(n) < O(n log n) < O(n²) < O(n³) ...
O(1):常数时间,例如访问列表头部、数字运算。O(log n):对数时间,例如在宽度为n的区间内进行二分查找、在n个节点的二叉搜索树中查找。O(n):线性时间,例如计算列表长度、查找列表最后一个元素。O(n log n):线性对数时间,例如归并排序、快速排序。O(n²):平方时间,例如插入排序、选择排序。
我们的目标是找到函数所属的最紧上界。
工作量与递推关系 ⚙️
大O记号很有用,但我们需要一种方法来得到描述运行时成本的函数 f。我们通过建立和求解递推关系来实现。
递推关系是一种递归地描述函数工作量的数学方程。“工作量”是运行时成本的抽象度量。
以下是分析函数渐进复杂度的通用步骤:
- 确定规模参数:明确输入规模
n的度量标准(例如,列表长度、数字值、元组元素之和)。 - 建立递推关系:根据代码结构(基本情况、递归情况)写出分段函数。递推关系应遵循代码逻辑,并考虑最坏情况。
- 简化并求解递推关系:通过“展开”递推式几次来展示规律,然后推导出闭合形式(不包含递归调用的表达式)。
- 得出渐进界:根据闭合形式,确定其大O复杂度。
案例分析:length 函数
考虑计算列表长度的函数。
1. 确定规模参数:n 是输入列表的长度。
2. 建立递推关系(最坏情况):
- 基本情况:
W_length(0) = c₀(对空列表返回0的常数成本) - 递归情况:
W_length(n) = c₁ + W_length(n-1)(c₁是处理一个头部元素并递归的常数成本)
3. 求解递推关系:
通过展开:
W_length(n) = c₁ + W_length(n-1)
= c₁ + (c₁ + W_length(n-2)) = 2c₁ + W_length(n-2)
= ...
= n * c₁ + W_length(0)
= n * c₁ + c₀
4. 得出渐进界:
闭合形式 n * c₁ + c₀ 是 n 的线性函数。因此,length 函数的时间复杂度是 O(n)。
案例分析:rev 函数与 append
现在分析反转列表的 rev 函数,它使用了 append (@)。
首先分析 append:
- 规模参数:
n是左列表的长度。 - 递推关系:
W_append(0) = c₀W_append(n) = c₁ + W_append(n-1)
- 求解:类似
length,可得W_append(n) = n*c₁ + c₀,属于O(n)。
然后分析 rev:
- 规模参数:
n是输入列表的长度。 - 递推关系(最坏情况):
W_rev(0) = c₀W_rev(n) = c₁ + W_rev(n-1) + W_append(n-1)(反转剩余列表并追加头部元素)
- 求解:代入
W_append(n-1) = (n-1)*c₂ + c₃并展开W_rev:
W_rev(n) = c₁ + W_rev(n-1) + (n-1)*c₂ + c₃
展开几次后,会发现W_rev(n)包含形如(n-1) + (n-2) + ... + 1的项,即∑_{i=1}^{n} (n-i)。 - 数学事实:
1 + 2 + ... + n = n(n+1)/2,属于O(n²)。 - 得出渐进界:因此,
rev函数的时间复杂度是O(n²)。
相比之下,尾递归的 rev 版本时间复杂度是 O(n)。这展示了算法设计对性能的影响。
并行性与跨度 📊
函数式程序具有纯函数性,使得它们天生是线程安全的,有利于并行计算。我们引入跨度的概念来分析并行性能。
- 工作量:使用单个处理器完成计算所需的总时间。
- 跨度:假设拥有无限多个处理器时,完成计算所需的最短时间。它由任务依赖图中的最长路径决定。


在计算跨度时,我们假设元组中的表达式可以并行求值。我们用 S 表示跨度递推关系。
案例分析:列表上的 length(跨度)
- 规模参数:
n是列表长度。 - 递推关系:
S_length(0) = c₀S_length(n) = max(c₁, S_length(n-1)) + c₂(可以并行求值1和length(xs),但加法+必须等待)
- 求解:由于
S_length(n-1)至少是常数,max的结果通常是S_length(n-1)。因此递推式简化为S_length(n) = S_length(n-1) + c₂。 - 得出渐进界:这与工作量递推式相同,因此跨度也是
O(n)。列表是固有的顺序结构,难以并行化。
案例分析:树上的 sum(跨度)
对于树结构,并行化可能带来收益。考虑计算树节点值之和的函数。
- 规模参数:
n是树的节点总数。 - 情况一:不平衡树(最坏情况)
- 递推关系类似于列表:
S_sum(n) = S_sum(n-1) + c,跨度仍为O(n)。
- 递推关系类似于列表:
- 情况二:平衡树
- 规模参数(更精确):对于平衡树,使用树高
h作为规模参数更合适,且h ≈ log₂(n)。 - 递推关系:
S_sum(h) = max(S_sum(h-1), S_sum(h-1)) + c = S_sum(h-1) + c。(左右子树高度均为h-1,可并行计算) - 求解:
S_sum(h) = h * c + c₀。 - 得出渐进界:由于
h = O(log n),因此跨度是O(log n)。
- 规模参数(更精确):对于平衡树,使用树高
在平衡树上,并行化将跨度从线性减少到了对数级,展示了并行计算的潜力。
关于无限处理器的说明:虽然无限处理器不现实,但跨度分析给出了并行运行时的理论下界。实际运行时介于工作量和跨度之间,并受到实际处理器数量的限制。
总结 🎯
本节课我们一起学习了:
- 渐进分析的核心:使用大O记号从数学上描述代码在输入规模增长时的性能趋势,忽略常数因子和低阶项。
- 性能量化方法:通过建立工作量递推关系来建模函数的运行时成本,并通过展开等方法求解递推式,最终确定其大O复杂度。
- 并行性分析:引入了跨度的概念,用于分析在理想并行条件下算法的最短运行时间。我们了解到列表操作通常难以并行,而平衡树结构则能通过并行化显著降低跨度(从
O(n)到O(log n))。

掌握这些分析技术,能够帮助我们理解算法效率,并在设计函数式程序时做出更明智的选择。
07:排序与并行性 🧠

在本节课中,我们将学习如何分析递归函数的运行时间,特别是关注并行性带来的加速潜力。我们将通过分析树遍历和排序算法(如归并排序)来深入理解工作(Work)与跨度(Span)的概念。
树深度分析 🌲
上一节我们介绍了使用节点数量来分析树函数。本节中,我们来看看另一种方法:使用树的深度进行分析。
以下是分析 treeSum 函数跨度的过程,其中 D 代表树的深度:
- 基本情况:
S_treeSum(0) = c0 - 递归情况(不平衡树):
S_treeSum(D) = max(S_treeSum(D-1), c1, c2) + c3- 这简化为
S_treeSum(D) = S_treeSum(D-1) + c。 - 通过展开,我们得到
S_treeSum(D) = O(D)。
- 这简化为
- 递归情况(平衡树):
S_treeSum(D) = max(S_treeSum(D-1), S_treeSum(D-1), c1) + c2- 这同样简化为
S_treeSum(D) = S_treeSum(D-1) + c。 - 因此,平衡树的跨度也是
O(D)。
- 这同样简化为
初看之下,平衡树和不平衡树的跨度都是 O(D),这似乎有悖直觉。关键在于理解深度 D 与节点数 N 的关系:
- 对于极度不平衡的树(如左倾链),深度
D约等于节点数N。因此,O(D)等价于O(N)。 - 对于完全平衡的树,节点数
N与深度D的关系是N ≈ 2^D,这意味着D ≈ log N。因此,O(D)等价于O(log N)。
通过深度分析,我们得到了与节点分析相同的结果,但有时深度分析更为直观。
树方法 🌳
上一节我们使用了展开法求解递推式。本节中,我们来看看一种更强大的通用方法:树方法。它特别适用于分析具有多个递归调用的函数。
树方法的核心思想是将递归调用过程可视化为一个计算树,然后分层求和。以下是分析中序遍历(inorder)函数工作量的步骤:
- 绘制计算树:根节点代表对大小为
N的树的调用。它产生两个子调用,分别处理大小约为N/2的左子树和右子树。此过程递归进行。 - 确定每层的参数:
- 层数:
log N(平衡树情况下)。 - 第
i层的节点数:2^i。 - 第
i层每个节点的输入大小:N / 2^i。 - 第
i层每个节点所做的非递归工作:与输入大小成线性关系,即c * (N / 2^i)。
- 层数:
- 计算总工作量:将每一层所有节点的非递归工作相加。
- 第
i层的总工作 = 节点数 × 每个节点的工作 =2^i * c * (N / 2^i) = c * N。 - 由于有
log N层,总工作量 =c * N * log N = O(N log N)。
- 第
通过树方法,我们清晰地得出了平衡树中序遍历的工作量是 O(N log N)。
中序遍历的复杂度分析 📊
我们使用树方法和递推式分析了 inorder 遍历在不同情况下的复杂度。
以下是 inorder 遍历的复杂度总结:
| 情况 | 工作量 (Work) | 跨度 (Span) |
|---|---|---|
| 平衡树 | O(N log N) |
O(N) |
| 不平衡树 | O(N^2) |
O(N^2) |


关键洞察:原始的 inorder 实现由于使用了 append 操作(其工作量为 O(左列表长度)),在不平衡树中会导致较差的 O(N^2) 性能。尽管在平衡情况下跨度能优化到 O(N),但更好的方法是改进算法本身。
改进的中序遍历 🚀
上一节我们看到原始中序遍历性能不佳。本节中,我们来看看如何设计一个更优的版本。
改进的思路是使用一个累加器(acc)来构建结果列表,避免昂贵的 append 操作。我们采用 “右-根-左” 的顺序进行遍历,以便在遍历过程中高效地在列表头部添加元素。
改进版中序遍历的核心代码如下:
fun inorderImp (Empty, acc) = acc
| inorderImp (Node(L, x, R), acc) =
let
val acc_with_R = inorderImp(R, acc)
val acc_with_x = x :: acc_with_R
in
inorderImp(L, acc_with_x)
end
- 首先递归遍历右子树,将结果存入累加器。
- 然后将当前节点值
x添加到累加器头部。 - 最后递归遍历左子树,并传递更新后的累加器。
复杂度分析:
- 工作量:每个节点只被访问一次,并且只进行常数时间的
::操作。因此,在任何树形结构下,工作量都是O(N)。 - 跨度:注意,对左子树的调用 依赖于 对右子树调用的结果。这种数据依赖意味着这两个递归调用无法并行执行。因此,跨度也是
O(N)。
与原始版本对比,改进版在所有情况下都将工作量从 O(N log N) 或 O(N^2) 降低到了 O(N),实现了显著的性能提升。
排序算法简介 🔢
前面我们分析了树遍历。本节中,我们来看看计算机科学中的一个经典问题:排序。我们将实现并分析两种排序算法。





插入排序 的思路是:将一个元素插入到一个已排序的列表中,并保持列表有序。
- 插入函数
ins:遍历已排序列表,找到新元素的正确位置并插入。工作量为O(N)。 - 排序函数
insertionSort:递归地对列表尾部进行排序,然后将头部元素插入到已排序的尾部中。- 递推式:
W_insertionSort(N) = W_insertionSort(N-1) + O(N) - 解为
O(N^2)。由于存在严格的数据依赖,无法并行化。
- 递推式:

插入排序简单但效率较低。我们寻求一种既能高效工作又能利用并行性的算法。
归并排序 🏎️
我们的目标是找到一个具有良好并行潜力的排序算法。本节中,我们来实现和分析归并排序。
归并排序遵循 “分治” 策略:
- 分割:将列表大致分成两半。
- 排序:递归地对两半进行排序。
- 合并:将两个已排序的半部分合并成一个完整的已排序列表。

以下是关键函数的实现:
1. 分割函数 split
fun split [] = ([], [])
| split [x] = ([x], [])
| split (x::y::xs) =
let
val (a, b) = split xs
in
(x::a, y::b)
end
它交错地将元素分配到两个列表中,最终得到长度最多相差1的两个子列表。
2. 合并函数 merge
fun merge (xs, []) = xs
| merge ([], ys) = ys
| merge (x::xs, y::ys) =
if x <= y
then x :: merge (xs, y::ys)
else y :: merge (x::xs, ys)
它线性地遍历两个已排序列表,每次选取较小的元素添加到结果中。
3. 归并排序函数 mergeSort
fun mergeSort [] = []
| mergeSort [x] = [x]
| mergeSort xs =
let
val (a, b) = split xs
val sorted_a = mergeSort a
val sorted_b = mergeSort b
in
merge (sorted_a, sorted_b)
end
复杂度分析:
- 工作量:递归树每层的工作量之和为
O(N),共有O(log N)层,因此总工作量为O(N log N)。 - 跨度:在理想并行情况下,
sorted_a和sorted_b可以同时计算。合并操作是顺序的,工作量为O(N)。因此跨度递推式为S(N) = S(N/2) + O(N),解为O(N)。
归并排序展示了如何通过巧妙的算法设计,在保持高效工作量 (O(N log N)) 的同时,获得潜在的线性跨度 (O(N)) 加速。
总结 📝
本节课中我们一起学习了:
- 使用深度分析树函数,并理解了其与节点分析的等价性。
- 掌握了树方法,这是一种通过可视化计算树来求解复杂递推式的强大技术。
- 深入分析了中序遍历的复杂度,并实现了一个工作量降至
O(N)的改进版本。 - 实现了插入排序和归并排序,并分析了它们的工作量和跨度。归并排序是体现分治法和并行性优势的经典案例。

关键要点是:在设计函数式算法时,我们不仅要考虑总工作量,还要考虑数据依赖关系,以评估其在多处理器下的潜在加速能力(跨度)。
08:多态性
在本节课中,我们将学习多态性,这是一种编写更通用、可重用代码的强大技术。我们将从类型推断的回顾开始,然后深入探讨参数化多态性,学习如何定义和使用多态数据类型,最后将之前学过的排序算法重写为多态版本。
类型推断回顾
上一节我们介绍了代码复杂度分析。本节中,我们来看看类型推断,这是SML确定表达式类型的过程。
类型推断的核心是检查表达式的每个组成部分,并应用简单的规则。例如,加法运算符 + 的类型是 int * int -> int。对于更复杂的表达式,如函数,SML会为未知类型引入类型变量(例如 'a),然后根据表达式中的使用方式添加约束。如果约束发生冲突(例如要求一个变量同时是 int 和 string 类型),程序就是类型错误的。
参数化多态性
上一节我们回顾了类型推断的基本原理。本节中,我们来探讨参数化多态性,它允许我们编写适用于多种类型的通用代码。
考虑计算列表长度的函数。我们不想为 int list、string list 等每种类型都重写一遍。多态性允许我们只写一次通用版本。
一个函数的类型如果包含类型变量,它就是多态的。例如,恒等函数 fn x => x 的类型是 'a -> 'a。这意味着它可以用于任何类型:int -> int、string -> string 等。这种通用性带来了巨大的代码复用好处。
代码示例:
(* 多态恒等函数 *)
val identity = fn x => x
val a = identity 5 (* a : int *)
val b = identity "hi" (* b : string *)
需要注意的是,SML使用的是 let多态性。这意味着多态性通常是在 let 绑定(或顶层声明)之后才被泛化的,而不是在函数定义体内。在函数体内过早地使用特定类型会限制其多态性。
参数化数据类型
上一节我们看到了多态函数。本节中,我们来看看如何定义我们自己的多态(参数化)数据类型。
就像函数可以有类型参数一样,数据类型也可以。list 和 option 就是内置的参数化数据类型。我们可以类似地定义自己的类型。
数据类型定义示例:
(* 定义多态二叉树 *)
datatype 'a tree = Empty
| Node of 'a tree * 'a * 'a tree
(* 定义多态选项类型 *)
datatype 'a option = NONE
| SOME of 'a
这里,'a 是一个类型参数。int tree、string tree、(int list) tree 等都是 'a tree 的实例。这允许我们编写像计算树节点数这样的通用函数,其类型为 'a tree -> int,适用于任何类型的树。
多态排序
上一节我们学会了定义多态数据类型。本节中,我们应用这些概念,将之前学过的归并排序重写为一个多态排序函数。
一个通用的排序函数不能直接比较任意类型的元素,因为比较方式取决于具体类型。解决方案是让调用者提供一个比较函数作为参数。


比较函数的类型是 'a * 'a -> order,其中 order 是 LESS、EQUAL、GREATER 之一。排序函数则接受这个比较函数和待排序列表作为参数。
排序函数类型签名:
val sort : ('a * 'a -> order) -> 'a list -> 'a list
以下是修改归并排序以使用传入的比较函数 cmp 的关键部分:
代码示例:
(* 在归并函数中使用比较函数 *)
fun merge (cmp, [], ys) = ys
| merge (cmp, xs, []) = xs
| merge (cmp, x::xs, y::ys) =
case cmp (x, y) of
LESS => x :: merge (cmp, xs, y::ys)
| _ => y :: merge (cmp, x::xs, ys)
(* 主排序函数 *)
fun sort cmp L = ... (* 实现归并排序,将 cmp 传递给 merge 和递归调用 *)
现在,我们可以通过传递不同的比较函数来对各种类型进行排序:
Int.compare用于整数排序。String.compare用于字符串排序。- 自定义函数用于反向排序、按模12排序等。
这实现了高度的代码复用和灵活性。
总结

本节课中我们一起学习了多态性。我们从类型推断的机制开始,理解了SML如何推导类型。然后,我们深入探讨了参数化多态性,它允许我们编写适用于多种类型的通用函数和数据类型。我们看到了如何定义像 'a tree 这样的多态类型,并编写了像 'a tree -> int 这样的通用函数。最后,我们应用这些知识,实现了一个多态的归并排序函数,它通过接受一个比较函数作为参数,能够对任何类型的列表进行排序。多态性是实现代码复用、提高代码描述性和模块化的关键工具。
09:高阶函数

概述
在本节课中,我们将要学习高阶函数。高阶函数是函数式编程中一个非常核心且强大的概念,它允许我们将函数作为参数传递,或者将函数作为结果返回。通过高阶函数,我们可以抽象出代码中的通用模式,从而编写出更简洁、更灵活、更可复用的程序。
上一节我们介绍了参数多态性,它允许我们编写适用于多种类型的通用代码。本节中我们来看看高阶函数,它将这种通用性提升到了一个新的层次,允许我们不仅抽象类型,还能抽象行为本身。
高阶函数简介
我们之前编写的函数大多是“一阶”函数。它们接收普通的值(如整数、列表)作为参数,并返回一个普通的值。高阶函数则不同,它们可以接收函数作为参数,或者将函数作为返回值。
函数在SML中是一等公民,这意味着函数可以像任何其他值(如整数、字符串)一样被传递、存储和操作。高阶函数正是利用了这一特性。
一个典型的例子是我们之前见过的排序函数。它接收一个比较函数和一个列表作为参数。通过传入不同的比较函数,我们可以实现升序、降序、按模12排序等多种排序行为。这个排序函数既是多态的(适用于任何类型的列表),又是高阶的(接收一个函数作为参数),这种组合赋予了它强大的灵活性。
柯里化与部分应用

柯里化是一种将接收多个参数的函数转换为一系列接收单个参数的函数的技术。以加法函数为例,标准的加法函数类型是 int * int -> int。
我们可以定义一个柯里化版本的加法函数 cadd:
fun cadd x y = x + y
其类型为 int -> int -> int。这等价于:
fun cadd x = fn y => x + y
这意味着 cadd 接收一个整数 x,然后返回一个新的函数,这个新函数接收另一个整数 y 并返回 x+y 的结果。
柯里化带来的一个巨大好处是部分应用。我们可以只给函数提供部分参数,得到一个“部分应用”的新函数。
以下是使用 cadd 的几种等价方式:
cadd 2 3
(cadd 2) 3
val addTwo = cadd 2 (* 部分应用,得到一个将输入加2的新函数 *)
addTwo 3
通过柯里化和部分应用,我们可以轻松地基于通用函数(如排序函数)创建特定用途的新函数,而无需显式地为每个新函数编写代码。

高阶函数动物园
现在,让我们探索几个极其常用且强大的高阶函数,它们构成了函数式编程的“标准工具箱”。掌握它们能极大地提升你的编程效率。
Map:列表转换器
map 函数用于将一个函数应用到列表中的每个元素上,并返回一个包含结果的新列表。这是一种非常常见的模式。

类型签名:(‘a -> ‘b) -> ‘a list -> ‘b list
实现:
fun map f nil = nil
| map f (x::xs) = (f x) :: (map f xs)



使用示例:
假设我们想将一个整数列表中的每个元素加1,或者将一个布尔列表中的每个元素取反。如果没有 map,我们需要分别编写 increment_all 和 flip_all 函数。但有了 map,我们可以这样做:
(* 递增列表所有元素 *)
val inc_list = map (fn x => x + 1) [1, 2, 3] (* 得到 [2,3,4] *)
(* 定义递增函数 *)
val increment_all = map (fn x => x + 1)
(* 翻转布尔列表所有元素 *)
val not_list = map (fn x => not x) [true, false, true] (* 得到 [false, true, false] *)
map 抽象了“对列表中每个元素进行某种变换”这一通用操作。
Filter:列表过滤器
filter 函数用于从列表中筛选出满足特定条件(谓词)的元素。
类型签名:(‘a -> bool) -> ‘a list -> ‘a list
实现:
fun filter p nil = nil
| filter p (x::xs) =
if p x then x :: (filter p xs)
else filter p xs
使用示例:
筛选出列表中的偶数。
fun is_even n = (n mod 2 = 0)
val keep_evens = filter is_even
val result = keep_evens [1, 2, 3, 4] (* 得到 [2,4] *)
函数组合
在数学中,函数组合写作 (g ∘ f)(x) = g(f(x))。在SML中,我们可以定义自己的组合运算符,使其代码更简洁。
类型签名:(‘b -> ‘c) -> (‘a -> ‘b) -> (‘a -> ‘c)
实现(SML中已内置为 o 运算符):
fun compose g f = fn x => g (f x)
(* 等价于 infix o; fun g o f = fn x => g (f x) *)
使用示例:
之前我们为了定义 is_odd 函数,写了 fn x => not (is_even x)。使用组合运算符可以更优雅:
val is_odd = not o is_even
(* 然后可以用于filter *)
val keep_odds = filter is_odd
这避免了显式定义lambda表达式,让代码意图更清晰。
Fold:列表折叠器
fold(或称为reduce、inject)是最高阶也最强大的列表操作之一。它接收一个二元函数、一个初始累加值和一个列表,通过将二元函数依次应用于累加值和列表的每个元素,最终“折叠”或“汇总”整个列表为一个单一的值。
这抽象了“遍历列表并不断更新某个状态(累加器)”的模式,是许多迭代和递归算法的本质。
类型签名:(‘a * ‘b -> ‘b) -> ‘b -> ‘a list -> ‘b
Foldl(从左折叠)
foldl 从左到右处理列表元素。
实现:
fun foldl f acc nil = acc
| foldl f acc (x::xs) = foldl f (f (x, acc)) xs
执行过程:foldl f acc [x1, x2, x3] 等价于 f(x3, f(x2, f(x1, acc)))。注意,第一个处理的元素是 x1,但最终它被嵌套在最内层。
使用示例:求和。
fun sum lst = foldl (fn (x, acc) => x + acc) 0 lst
(* foldl (op +) 0 lst 是另一种写法 *)
(* 计算 sum [1,2,3] 的过程:
foldl + 0 [1,2,3]
-> foldl + (1+0) [2,3]
-> foldl + (2+1) [3]
-> foldl + (3+3) []
-> 6
*)
Foldr(从右折叠)
foldr 从右到左处理列表元素。
实现:
fun foldr f acc nil = acc
| foldr f acc (x::xs) = f (x, foldr f acc xs)
执行过程:foldr f acc [x1, x2, x3] 等价于 f(x1, f(x2, f(x3, acc)))。第一个处理的元素是 x3(最右边的)。
使用示例:使用 foldr 和 :: 可以方便地重建列表(而 foldl 和 :: 则会得到反转的列表)。
(* 使用foldr重建列表,相当于什么都没做 *)
val identity_list = foldr (fn (x, acc) => x :: acc) nil [1,2,3] (* 得到 [1,2,3] *)
(* 使用foldl重建列表,会得到反转列表 *)
val reverse_list = foldl (fn (x, acc) => x :: acc) nil [1,2,3] (* 得到 [3,2,1] *)
foldr 的递归结构通常更符合我们对列表的直观递归定义(先处理尾部,再处理头部)。
总结
本节课中我们一起学习了高阶函数这一函数式编程的核心支柱。
我们首先理解了高阶函数的概念,即可以操作其他函数的函数。接着,我们探讨了柯里化和部分应用,它们让我们能够轻松地创建函数工厂和特化函数。
然后,我们深入研究了几个关键的高阶函数:
map:用于转换列表中的每个元素。filter:用于根据条件筛选列表元素。- 函数组合 (
o):用于将多个函数串联成一个新函数,使代码更简洁。 foldl/foldr:用于将整个列表汇总(折叠)为单个值,是迭代和递归的通用抽象。

这些高阶函数不仅仅是工具,它们代表了一种编程范式:通过组合小型、纯粹、可复用的函数来构建复杂的行为。掌握它们,你就能以更声明式、更优雅的方式思考和编写代码,这正是函数式编程的魅力所在。在接下来的课程中,我们将看到这些概念在更复杂场景下的应用。
10:组合子与分阶段

在本节课中,我们将要学习分阶段的概念,这是柯里化的一个重要推论。我们还将探讨如何分析高阶函数的成本,以及如何将高阶函数(如 map 和 fold)应用于树结构,从而简化我们的编程。最后,我们会看到如何使用高阶函数(如管道操作符和 bind)来编写更清晰、更易读的代码。
分阶段:优化计算时机
上一节我们介绍了高阶函数和柯里化的概念。本节中,我们来看看一个重要的优化技术:分阶段。
分阶段的核心思想是:如果某个计算不依赖于后续的参数,那么就不应该等待这些参数,而应该提前进行计算。这就像在建造展台时,你不需要等待朋友把所有木板都刷完漆,就可以先把那些不需要刷漆的木板运到现场。
考虑以下函数:
fun mystery x y = (horrible_computation x) + y
假设 horrible_computation 需要运行三年。如果我们多次调用 mystery,例如 mystery 2 4、mystery 1 2、mystery 2 5,那么 horrible_computation 2 会被计算两次,总共需要九年。
然而,horrible_computation 的结果只依赖于 x,与 y 无关。因此,我们可以将计算分阶段,提前计算不依赖于 y 的部分:
fun mystery_staged x =
let val z = horrible_computation x
in fn y => z + y
end
现在,我们可以先计算 val f = mystery_staged 2 和 val g = mystery_staged 1,然后再分别应用 f 4、g 2 和 f 5。这样,horrible_computation 2 只计算了一次,总时间缩短为六年。
关键点:通过识别计算之间的数据依赖关系,并将不依赖于后续参数的计算提前,我们可以显著提升性能。源代码应该清晰地反映计算的时机。
高阶函数的成本分析
在分析高阶函数(如 map)的成本时,我们需要特别小心,因为传入的函数 f 本身的成本是未知的。
以 map 函数为例:
fun map f nil = nil
| map f (x::xs) = (f x) :: (map f xs)
其工作量递归式可以表示为:
W_map(f)(n) = W_f + C + W_map(f)(n-1)
其中 W_f 是函数 f 在单个元素上的工作量。
最终,我们得到 W_map(f)(n) = O(n) * W_f。更准确的说法是:map 会对列表中的每个元素调用一次 f,因此总共进行 O(n) 次 f 的调用。f 本身的成本需要单独考虑。
树上的高阶函数
我们已经学会了在列表上使用 map 和 fold。本节中,我们来看看如何将它们应用到二叉树上。
首先,定义多态二叉树类型:
datatype 'a tree = Empty | Node of 'a tree * 'a * 'a tree
树的映射

treeMap 函数将函数 f 应用到树的每个节点上:
fun treeMap f Empty = Empty
| treeMap f (Node(l, x, r)) = Node(treeMap f l, f x, treeMap f r)
其类型签名为:('a -> 'b) -> 'a tree -> 'b tree。它的工作原理与列表的 map 类似,只是递归地应用于左右子树。
树的折叠
treeFoldL 函数以中序遍历的顺序折叠树:
fun treeFoldL f acc Empty = acc
| treeFoldL f acc (Node(l, x, r)) =
let
val leftAcc = treeFoldL f acc l
val rootAcc = f (x, leftAcc)
in
treeFoldL f rootAcc r
end
其类型签名为:('a * 'b -> 'b) -> 'b -> 'a tree -> 'b。它首先递归地折叠左子树,然后将当前节点值 x 和累积结果 leftAcc 通过函数 f 结合,最后递归地折叠右子树。
树的搜索

我们可以编写一个通用的树搜索函数,它接受一个谓词函数 p,并返回树中第一个(按中序遍历)满足该谓词的元素:
fun search p Empty = NONE
| search p (Node(l, x, r)) =
case search p l of
SOME v => SOME v
| NONE => if p x then SOME x else search p r
其类型签名为:('a -> bool) -> 'a tree -> 'a option。它体现了高阶函数的强大之处:我们可以编写与具体谓词逻辑无关的通用搜索框架。
使用高阶函数提升代码可读性
高阶函数不仅能捕获通用模式,还能极大地提升代码的可读性。
管道操作符
考虑一系列的函数调用:foo (bar (baz (qux x)))。这种嵌套的写法是从右向左阅读的,不直观。
我们可以定义一个管道操作符 |>:
infix |>
fun x |> f = f x
现在,我们可以将上面的调用改写为:
x |> qux |> baz |> bar |> foo
这就像一份食谱清单,从左到右清晰地展示了操作步骤:先做 qux,然后 baz,接着 bar,最后 foo。代码的意图一目了然。
处理可能失败的操作:Bind 组合子
在实际编程中,许多操作可能失败(例如,读取不存在的文件)。这些操作通常返回 option 类型。连续调用这些函数会导致大量的模式匹配,代码变得冗长且难以阅读。
以下是处理一系列可能失败操作的“丑陋”写法:
fun findStudentGrade (student, assign) file =
case readFile file of
NONE => NONE
| SOME contents =>
case parseGrades contents of
NONE => NONE
| SOME grades =>
case lookup (student, assign) grades of
NONE => NONE
| SOME grade => SOME grade
我们可以定义一个 bind 函数来抽象这种“成功则继续,失败则短路”的模式:
fun bind opt f =
case opt of
NONE => NONE
| SOME x => f x
bind 的类型是 'a option -> ('a -> 'b option) -> 'b option。
利用 bind,我们可以将上面的函数重写得更清晰:
fun findStudentGrade‘ (student, assign) file =
bind (readFile file) (fn contents =>
bind (parseGrades contents) (fn grades =>
lookup (student, assign) grades))
为了更接近管道风格,我们可以定义中缀运算符 >>=:
infix >>=
fun opt >>= f = bind opt f
最终,代码可以写成:
fun findStudentGrade‘’ (student, assign) file =
readFile file >>= parseGrades >>= lookup (student, assign)
这种写法极大地提升了代码的简洁性和可读性,自动处理了错误传播。bind 是函数式编程中一个极其重要的组合子(通常与“单子”概念相关)。
总结
本节课中我们一起学习了:
- 分阶段:通过调整计算与柯里化参数的相对位置来优化性能,将不依赖于后续参数的计算提前。
- 高阶函数的成本分析:分析像
map这样的函数时,需明确其进行了 O(n) 次传入函数f的调用。 - 树上的高阶函数:将
map和fold的概念推广到树数据结构,编写了treeMap、treeFoldL和通用搜索函数。 - 提升代码可读性:
- 使用管道操作符
|>将嵌套的函数调用转换为从左到右的线性序列,使代码流程更清晰。 - 使用
bind组合子(>>=)来优雅地处理一系列可能失败(option类型)的操作,避免深层嵌套的模式匹配,实现清晰的错误传播。
- 使用管道操作符

高阶函数的核心威力在于“用代码编写代码”,它允许我们构建高度抽象、可复用且表达力强的程序框架。掌握这些概念和工具,是成为优秀函数式程序员的关键一步。
11:续延传递风格 (Continuation-Passing Style)
在本节课中,我们将学习一种称为“续延传递风格”的编程技术。我们将了解什么是续延,如何将普通函数转换为CPS风格,以及这种转换对程序控制流和尾递归的影响。通过本教程,你将能够理解并手动进行简单的CPS转换。
概述:从管道操作到显式控制流
上一节我们介绍了高阶函数和管道操作符。管道操作符(|>)允许我们以更线性的方式组织计算,例如 x |> g |> f 比 f (g x) 更易读。
然而,当我们需要多次使用同一个中间结果时,管道写法会变得笨拙。例如,计算一个字符串列表(代表数字)的平均值:
(* 原始写法,需要lambda来绑定列表以便复用 *)
["1", "2", "3"]
|> map Int.fromString
|> (fn l => (foldr op+ 0 (Option.valOf l)) div (length l))
这种写法不够“显式”,因为操作的顺序依赖于我们对SML求值顺序的理解。我们希望每一步操作都清晰明了地写在单独一行。
我们可以通过引入显式的lambda表达式来改进:
["1", "2", "3"]
|> (fn l1 => map Int.fromString l1)
|> (fn l2 => map Option.valOf l2)
|> (fn l => foldr op+ 0 l)
|> (fn sum => length ["1", "2", "3"])
|> (fn len => sum div len)
现在,每个操作都在单独一行,并且中间结果都被显式命名。但这样写非常冗长。
核心概念:什么是续延 (Continuation)?
一个“续延”本质上是一个函数,它告诉你“接下来该做什么”。它代表了计算剩余的部分。
在代码中,续延就是一个额外的函数参数(通常命名为 k),原函数不再直接返回结果,而是将结果“传递”给这个续延函数。
公式:对于一个类型为 t1 -> t2 的函数 f,其CPS版本 f_cps 的类型变为 t1 -> (t2 -> 'a) -> 'a。它接受一个原始参数和一个续延 k(类型为 t2 -> 'a),并承诺会调用 k 于其结果之上。
代码示例:加法函数的普通版本与CPS版本。
(* 普通版本 *)
fun add x y = x + y
(* CPS版本 *)
fun add_cps x y k = k (x + y)
CPS版本 add_cps 接受参数 x, y 和续延 k,它计算 x+y,然后将结果传递给 k。
从管道到“酷”函数 (Cool Functions)
上一节我们通过管道和lambda实现了显式控制流。我们可以进一步消除管道,将“接下来要做的事”(即lambda)直接作为参数传递给前一个操作。我们称这样的函数为“酷”函数。
规则:一个“酷”函数接受一个续延 k 作为额外参数,并保证将其计算结果传递给 k。
之前计算平均值的例子可以重写为使用“酷”函数的形式:
map_cool Int.fromString ["1","2","3"] (fn l2 =>
map_cool Option.valOf l2 (fn l =>
foldr_cool op+ 0 l (fn sum =>
length_cool ["1","2","3"] (fn len =>
(sum div len)))))
其中,每个 _cool 函数都遵循 f_cool ... k = k (f ...) 的约定。通过引用透明性,可以证明这与之前的管道lambda版本等价。
为何需要CPS?尾递归的挑战
你可能会问,为什么不直接用 let ... in ... end 绑定中间值?那样可读性更好。
let
val l1 = ["1","2","3"]
val l2 = map Int.fromString l1
val l = map Option.valOf l2
val sum = foldr op+ 0 l
val len = length l1
in
sum div len
end
对于简单的顺序计算,这确实很好。但问题出现在递归函数中。
考虑阶乘函数:
fun fact n =
let val rec_res = fact (n-1)
val res = n * rec_res
in
res
end
这种写法不是尾递归的!因为在递归调用 fact (n-1) 之后,我们还需要执行乘法操作。这会导致栈空间随着递归深度线性增长,效率低下。
我们希望有一种系统化的方法,能为任何函数(包括递归函数)生成尾递归版本。这就是CPS风格的核心目标之一。
CPS三原则
一个真正的CPS风格函数必须满足以下三个原则:
- 酷原则:它接受一个续延
k作为参数。 - 尾调用原则:所有对自身(递归)或其他CPS函数的调用都必须是尾调用(即该调用是函数体最后执行的操作)。
- 续延尾调用原则:续延
k只能在尾调用位置被调用。
满足这三点的函数,其所有递归都将是尾递归,从而可以被编译器优化为循环,避免栈溢出。
CPS转换实战:阶乘函数
让我们将普通的阶乘函数转换为CPS风格。
原始函数:
fun fact 0 = 1
| fact n = n * fact (n-1)
转换步骤:
- 添加续延参数:函数类型从
int -> int变为int -> (int -> 'a) -> 'a。 - 履行约定:在所有原本返回结果的地方,改为调用续延
k。 - 处理递归调用:将递归调用
fact (n-1)替换为一个“占位符”,然后将整个剩余计算包装成一个新的续延,传递给递归调用。
CPS版本:
fun fact_cps 0 k = k 1
| fact_cps n k = fact_cps (n-1) (fn rec_res => k (n * rec_res))


关键理解:fn rec_res => k (n * rec_res) 这个续延就是一个“待办事项清单”。递归过程不是在“计算后记住要做什么”(非尾递归),而是“提前写下接下来所有要做的事”(尾递归)。递归调用 fact_cps 的唯一任务就是带着这个越来越长的“待办事项清单”继续深入。当到达基础情况(n=0)时,我们开始逐项执行这个清单。
执行追踪(以 fact_cps 3 (fn x => x) 为例):
fact_cps 3 k0调用fact_cps 2 (fn r1 => k0 (3 * r1))。新续延k1 = fn r1 => k0 (3 * r1)。fact_cps 2 k1调用fact_cps 1 (fn r2 => k1 (2 * r2))。新续延k2 = fn r2 => k1 (2 * r2)。fact_cps 1 k2调用fact_cps 0 (fn r3 => k2 (1 * r3))。新续延k3 = fn r3 => k2 (1 * r3)。fact_cps 0 k3调用k3 1。- 展开
k3:(fn r3 => k2 (1 * r3)) 1=>k2 (1 * 1)=>k2 1。 - 展开
k2:(fn r2 => k1 (2 * r2)) 1=>k1 (2 * 1)=>k1 2。 - 展开
k1:(fn r1 => k0 (3 * r1)) 2=>k0 (3 * 2)=>k0 6。 - 最终
k0是初始续延(fn x => x),得到结果6。
可以看到,递归调用是尾调用,所有计算都通过续延的构建和消解来完成。
处理可选类型:成功与失败续延
对于返回 option 类型的函数,CPS转换略有不同。因为结果可能成功(SOME v)或失败(NONE),所以我们使用两个续延:成功续延和失败续延。
原始函数(在树中查找满足谓词p的第一个元素):
datatype 'a tree = Empty | Node of 'a tree * 'a * 'a tree
fun search p Empty = NONE
| search p (Node(l, x, r)) =
if p x then SOME x
else case search p l of
SOME y => SOME y
| NONE => search p r
CPS版本:
类型从 ('a -> bool) -> 'a tree -> 'a option 变为 ('a -> bool) -> 'a tree -> ('a -> 'b) -> (unit -> 'b) -> 'b。
fun search_cps p Empty succ fail = fail ()
| search_cps p (Node(l, x, r)) succ fail =
if p x then succ x
else
search_cps p l succ (fn () => search_cps p r succ fail)
转换要点:
- 原返回
NONE处,改为调用失败续延fail ()。 - 原返回
SOME v处,改为调用成功续延succ v。 - 在原
case ... of进行分派处,将两个分支的逻辑分别转化为新的成功/失败续延,并传递给递归调用。

自定义行为:CPS风格的强大之处在于,我们可以轻松定制成功或失败时的行为。
search_cps (fn x => x mod 2 = 0) myTree
(fn res => "Found: " ^ Int.toString res) (* 成功续延 *)
(fn () => "Did not find") (* 失败续延 *)
总结
本节课我们一起学习了续延传递风格。
- 续延是一个代表“接下来做什么”的函数参数。
- CPS转换是一种将普通函数重写为总是接受一个续延参数,并通过尾调用传递结果的机械过程。
- 核心目的是实现尾递归,优化控制流,并允许调用者自定义计算完成后的行为。
- 转换规则包括:添加续延参数、将返回值替换为续延调用、将递归调用后的处理逻辑包装成新的续延。
- 对于返回
option的类型,我们使用两个续延(成功/失败)来处理不同的结果分支。

虽然手写CPS代码通常可读性不佳,但理解其原理对于掌握函数式编程的控制流、实现编译器优化(如尾调用优化)以及理解某些高级编程模式至关重要。它是一种强大的理论工具和编译技术。
12:异常处理 😊

在本节课中,我们将学习SML中的异常处理机制。我们将了解什么是异常、如何引发和处理异常、如何自定义异常,以及异常如何影响程序的控制流。通过本课,你将掌握在函数式编程中安全、有效地使用异常的方法。



什么是异常? 🤔
上一节我们介绍了表达式的不同行为。本节中我们来看看第三种行为:引发异常。

在SML中,表达式有三种可能的行为:
- 求值为一个值。
- 无限循环。
- 引发一个异常。
例如,表达式 1 div 0 不会求值,也不会无限循环,而是会引发一个名为 Div 的异常。异常为我们提供了一种便捷的“逃生舱口”,当无法返回一个合理的值时(例如除以零),程序可以中止当前计算。
除了 Div,SML中还有其他内置异常,例如:
Match:当进行非穷举的模式匹配时引发。(* 这个函数只匹配输入为1的情况 *) val f = fn 1 => 2 (* f 2 会引发 Match 异常 *)Bind:当绑定尝试失败时引发。(* 尝试将值2与模式1匹配,失败并引发Bind异常 *) val 1 = 1 + 1
从概念上讲,一个非穷举匹配函数 fn 1 => 2 可以看作是 fn 1 => 2 | _ => raise Match 的简化形式。
使用异常 🛠️
我们已经看到内置函数如何引发异常。现在,让我们看看如何在代码中主动引发和处理异常。



引发异常
我们可以使用 raise 关键字来引发一个异常。异常本身是 exn 类型的值。
raise Div
raise e 表达式的类型是任意的(多态类型 'a),因为它永远不会正常返回一个值,所以类型系统允许它适配任何上下文所需的类型。
处理异常

为了从异常中恢复,我们使用 handle 构造。它的语法类似于 case 表达式。
以下是 handle 的一般形式:
e handle p1 => e1 | p2 => e2 | ... | pn => en
e是可能引发异常的表达式。- 每个
pi => ei是一个处理分支,pi是exn类型的模式,ei是处理表达式。 - 关键规则:表达式
e和所有处理分支ei必须具有相同的类型。这是为了保证无论是否发生异常,整个表达式的类型都是一致的。
异常传播:如果表达式 e 引发了一个异常,并且该异常与某个处理分支 pi 匹配,则整个 handle 表达式的值就是对应的 ei 的值。如果没有匹配的分支,异常会继续向外层传播。

让我们看一个例子,它安全地计算列表的平均值,并在列表为空时给出友好提示:
fun averageGrade (L: int list) : string =
let
val avg = (foldl (op +) 0 L) div (length L)
in
"Your grade is " ^ Int.toString avg
end
handle Div => "Error: no grades found"
- 如果列表
L非空,计算正常进行。 - 如果
L为空,length L为0,导致div操作引发Div异常。 handle捕获Div异常,并返回一个错误信息字符串。
异常与选项类型的对比
我们之前学过 option 类型(SOME v 或 NONE),它也可以表示可能失败的计算。例如,可以定义一个安全的除法函数:
fun safeDiv (m: int, n: int) : int option =
if n = 0 then NONE else SOME (m div n)
使用 option 类型是更可取的做法,因为它将可能的失败显式地体现在了类型签名中。调用者必须通过模式匹配来处理 NONE 的情况,这保证了安全性。

相比之下,像 div 这样的函数,其类型 int * int -> int 完全没有提示它可能引发异常,这对调用者是不透明的,容易导致未处理的崩溃。
那么,为什么还需要异常?
有时,我们非常确信某些错误情况(如前置条件)几乎不会发生,为它们编写处理代码显得冗长且不必要。异常提供了一种“快速失败”的机制。然而,必须谨慎使用这种“信任”,因为现实中“不可能”的情况时常发生。
经验法则:
- 优先使用
option(或后续会学的Result)类型来显式处理错误。 - 仅在错误非常罕见,且作为“不可恢复的程序错误”时,考虑使用异常。
- 绝对不要使用通配符捕获所有异常(
handle _ => ...),这会隐藏你未曾预料的错误,使得调试极其困难。


自定义异常 🎨

上一节我们使用了内置异常。本节中我们来看看如何创建自己的异常类型,以便更精确地描述错误。

SML中的 exn 类型是可扩展的。这意味着我们可以在程序任何地方声明新的异常构造器。
exception FactorialNegative
现在,FactorialNegative 就成了一个类型为 exn 的新异常值。我们可以在阶乘函数中使用它:
fun fact_exn (n: int) : int =
if n < 0 then raise FactorialNegative
else if n = 0 then 1
else n * fact_exn (n - 1)
自定义异常的好处是:
- 描述性:异常名称(如
FactorialNegative)清晰表达了错误性质。 - 可精确匹配:在
handle中可以精确捕获这个异常,而不会意外捕获到其他无关的异常。 - 可携带数据:异常可以携带额外的信息。
在上例中,exception Error of string fun runProcess (thunk: unit -> string) : string = thunk () handle Error msg => "Error: " ^ msgrunProcess函数会运行一个可能引发Error异常的函数。如果发生异常,它会将异常携带的字符串信息整合到返回结果中。
异常控制流 🌀
我们已经看到异常可以用于错误处理。本节中我们来看看如何(谨慎地)利用异常来实现非局部的控制流,这被称为异常处理风格。
考虑一个在树中查找满足谓词 p 的第一个元素的函数。我们可以定义两种版本:
- 选项类型风格:返回
'a option,找到返回SOME x,未找到返回NONE。 - 异常处理风格:定义一个“未找到”异常,找到时返回值,未找到时引发异常。
以下是异常处理风格的示例:
exception NotFound
fun search_ehs (p: 'a -> bool, t: 'a tree) : 'a =
case t of
Leaf => raise NotFound
| Node (l, x, r) =>
if p x then x
else (search_ehs (p, l) handle NotFound => search_ehs (p, r))
在这个实现中:
- 如果当前节点满足条件,直接返回值
x。 - 如果不满足,则递归查找左子树。
- 如果左子树引发了
NotFound异常(意味着左子树中没找到),handle会捕获它,并转而查找右子树。 - 如果左右子树都没找到,异常会最终传播出去。
与续延传递风格对比:这种利用异常“跳转”到备用计算路径的方式,在概念上与续延传递风格有相似之处,都是对控制流的显式操作。但异常风格更不透明,也更危险。
重要警告:这种将异常作为正常控制流机制的做法(EHS)通常是一个坏主意。原因如下:
- 不可见性:函数的类型签名 (
'a -> bool * 'a tree -> 'a) 没有揭示它可能引发NotFound异常。调用者必须依赖文档或阅读源码才能知道需要处理它。 - 调试困难:异常导致的非局部跳转使得程序执行流程难以跟踪。
- 破坏等式推理:在纯函数式代码中,我们通常可以自由重组表达式。但异常会破坏这种性质,因为
raise e1 + raise e2和raise e2 + raise e1会引发不同的异常。
因此,异常应主要用于真正的、非常规的“错误”情况,而非作为常规的数据返回机制。option 类型或类似的模式才是更安全、更清晰的选择。
总结 📚
本节课中我们一起学习了SML中的异常处理。
- 我们首先了解了异常的三种行为,以及内置的
Div、Match、Bind异常。 - 接着,我们学习了如何使用
raise引发异常,以及如何使用handle捕获和处理异常,并理解了异常传播的规则。 - 然后,我们探讨了自定义异常的优势和创建方法,它能让错误信息更精确。
- 最后,我们审视了“异常处理风格”这种利用异常进行控制流的模式,并强调了其潜在的危险性,建议在绝大多数情况下优先使用像
option这样将错误显式化的类型。

核心要点是:异常是强大的工具,但应谨慎使用。优先使用类型系统来强制错误处理(如 option),将异常留给那些真正例外、不可恢复的情况。 这将帮助你编写出更健壮、更易维护的代码。
13:正则表达式 🧩
在本节课中,我们将要学习正则表达式。正则表达式是一种非常酷的工具,在理论和实践中都有广泛的应用。我们将看到,正则表达式是函数式编程的一个很好的用例,也是计算机科学中一个非常重要的概念。这可能是你学到的最实用的东西之一。
文本验证问题
首先,我们来谈谈文本验证问题。其核心思想是验证用户输入。这在实际应用中非常常见:当你从用户那里读取输入时,你需要确保它是安全的,因为恶意输入可能会被存入数据库并执行,从而导致系统崩溃。因此,我们需要阻止这种情况发生。
具体来说,假设我们想要验证电子邮件地址。一个电子邮件地址的结构通常如下:用户名@网站域名.扩展名。例如:name@website.com。
我们希望创建一个名为 validate_email 的函数,它接收一个字符串,并判断其是否符合电子邮件地址的规范。这取决于几个因素:我们需要知道什么是有效的用户名、网站域名和扩展名。
为了简化,我们假设扩展名只能是 .org 或 .com。此外,假设网站域名只能包含字母和数字(即字母数字字符)。而用户名除了字母数字字符外,还可以包含下划线(_)和点(.)。
手动实现验证器
为了完全实现这个函数,我们将定义一些“消费函数”。消费函数的思想是:给定一个字符串(例如我的工作邮箱),我将其转换为字符列表,然后“消费”掉符合我验证规则的部分。
例如,如果我要消费用户名,用户名可以是任意字母数字字符序列,所以我将消费掉 bradon 这些字符,直到遇到 @ 符号,此时我会停止,因为 @ 不属于用户名。
我们将定义两个函数:consume_name 和 consume_website。consume_name 会评估字符列表,消费掉所有属于用户名的前缀字符(字母、数字、下划线、点),直到遇到不属于这些的字符(如 @)。consume_website 类似,但只消费字母数字字符。
以下是 consume_name 的实现思路(伪代码):
fun consume_name cs =
case cs of
[] => []
| c::cs' => if isAlphanumeric(c) then c :: consume_name(cs')
else if c = #"." orelse c = #"_" then c :: consume_name(cs')
else []
consume_website 的实现类似,但只允许字母数字字符。
最后,我们编写 validate_email 函数。首先,使用 explode 函数将字符串转换为字符列表。然后,尝试消费用户名部分。如果成功消费并剩下以 @ 开头的列表,则消费掉 @。接着,尝试消费网站域名部分。如果成功并剩下以 . 开头的列表,则检查接下来的部分是否是 .org 或 .com。如果所有步骤都成功,并且消费后列表为空,则返回 true,否则返回 false。
然而,这里有一个小问题:如果末尾有多余的字符(例如 .comasdf),我们的函数可能仍然会匹配成功,因为模式匹配可能忽略了末尾的垃圾字符。我们需要确保在消费完扩展名后,整个字符串也被完全消费(即剩余列表为空)。修复后,验证过程就完成了。


手动方法的局限性
这种手动编写的函数虽然可能有效,但存在几个问题:它冗长、容易出错(我们刚才就发现了一个bug),并且难以维护。如果我们还需要验证其他模式(如电话号码),就需要为每一种模式编写类似的、繁琐且容易出错的函数。
我们的理念是:代码越多,问题越多。因此,我们希望自动化这个过程。
引入正则表达式
我们将创建一个函数,它接收某种类型的值,并为我们生成一个验证函数。我们将把验证问题的本质编码到一种类型的值中,这种类型称为正则表达式。
正则表达式是描述字符串模式的一种强大方式。它由一些基本构件通过组合规则构成,能够简洁地表示复杂的字符串集合(称为“语言”)。
正则表达式的构成
正则表达式 R 可以是以下六种形式之一:
- 0:匹配空语言(不匹配任何字符串)。
- 1:匹配只包含空字符串
ε的语言。 - 字符 c:匹配只包含单个字符
c的语言。 - R1 + R2:匹配属于
R1的语言 或 属于R2的语言的字符串(并集)。 - R1 R2:匹配一个字符串,该字符串可以拆分为两部分:前缀匹配
R1,后缀匹配R2(连接)。 - R*:匹配将
R匹配的字符串重复零次或多次连接起来得到的任何字符串(克林闭包)。
这是一个递归定义,因为 R1 和 R2 本身也是正则表达式。
示例
假设我们的字母表只包含 {a, b}。
a + b匹配字符串"a"或"b"。(a + b) b匹配"ab"或"bb"。a*匹配空字符串、"a"、"aa"、"aaa"……即任意数量的a。(ab)*匹配空字符串、"ab"、"abab"、"ababab"……即偶数长度的、由"ab"重复组成的字符串。a b*匹配以单个a开头,后面跟着零个或多个b的字符串,如"a"、"ab"、"abb"等。
现在,我们可以用正则表达式来简洁地描述之前的电子邮件模式。假设 AN 是匹配任意字母数字字符的正则表达式(a + b + ... + z + 0 + 1 + ... + 9),那么电子邮件地址的正则表达式可以写为:
(AN + . + _)* @ (AN)* . (org + com)
这表示:零个或多个(字母数字、点或下划线),后跟 @,后跟零个或多个字母数字,后跟点,后跟 org 或 com。
在 SML 中实现正则表达式匹配器
理论是美好的,但我们需要在代码中实现它。我们将定义一个 SML 数据类型来表示正则表达式,并编写一个函数来匹配字符串。
数据类型定义
datatype regexp =
Zero
| One
| Char of char
| Plus of regexp * regexp
| Times of regexp * regexp
| Star of regexp
匹配函数的设计
我们将实现一个函数 match : regexp -> char list -> (char list -> bool) -> bool。这个函数接收一个正则表达式 R、一个字符列表 cs 和一个续延 k。它的语义是:match R cs k 返回 true 当且仅当 我们可以将输入列表 cs 拆分为一个前缀 p 和一个后缀 s(即 cs = p @ s),使得:
- 前缀
p属于正则表达式R所描述的语言。 - 续延
k应用于后缀s时返回true(即k(s)为真)。
续延 k 代表了对剩余后缀的验证条件。通过精心设计续延,我们可以实现各种匹配目标。例如,要检查整个字符串是否完全匹配 R,我们可以将续延设为 null 函数(检查列表是否为空),这样后缀就必须为空列表。
匹配函数的实现
以下是 match 函数针对不同正则表达式形式的实现:
-
Zero:没有字符串匹配Zero,所以无法找到任何有效的前缀。直接返回false。fun match Zero cs k = false -
One:只匹配空字符串。因此,有效的前缀只能是空列表。我们检查续延k是否对整个原始输入cs(此时作为后缀)返回true。fun match One cs k = k cs -
Char c:只匹配单个字符c。因此,我们需要检查输入列表的第一个字符是否是c。- 如果列表为空,失败。
- 如果列表头是
c,则消费掉它,并将剩余列表传给续延k。 - 否则,失败。
fun match (Char c) cs k = case cs of [] => false | c'::cs' => (c = c') andalso k cs' -
Plus (r1, r2):匹配r1或r2。因此,我们尝试用r1去匹配,如果失败,再尝试用r2去匹配。fun match (Plus (r1, r2)) cs k = match r1 cs k orelse match r2 cs k -
Times (r1, r2):匹配r1后跟r2。我们需要找到一个前缀匹配r1,然后对剩余部分,再找到一个前缀匹配r2,并且最终剩余部分满足续延k。
这可以通过嵌套调用match来实现:先用r1匹配,其续延是“用r2匹配剩余部分,并且最终结果满足k”。fun match (Times (r1, r2)) cs k = match r1 cs (fn cs' => match r2 cs' k) -
Star r:匹配零次或多次r。这可以递归地定义为:要么匹配零次(即前缀为空,直接满足续延k),要么匹配一次r,然后继续匹配Star r。
但是,直接这样写会导致无限递归(如果r可以匹配空字符串,我们会不断选择匹配零次而无法前进)。为了解决这个问题,我们要求当选择匹配一次r时,消费的前缀不能为空(即剩余列表必须发生变化)。fun match (Star r) cs k = k cs orelse (match r cs (fn cs' => not (cs = cs') andalso match (Star r) cs' k))
最后,我们可以定义 accept 函数来检查整个字符串是否完全匹配正则表达式:
fun accept r s = match r (explode s) null
这里 null 是检查列表是否为空的函数,作为续延,它确保了在正则表达式匹配之后没有剩余字符。
正确性证明(概述)
我们可以证明 match 函数的实现是正确的。证明通常分为两部分:
- 可靠性:如果
match R cs k返回true,那么一定存在一种将cs拆分为前缀p和后缀s的方式,使得p在R的语言中,且k(s)为true。 - 完备性:如果存在一种拆分
cs = p @ s,使得p在R的语言中且k(s)为true,那么match R cs k一定返回true。
证明通过对正则表达式 R 的结构进行归纳来完成。例如,对于 Plus (r1, r2) 的情况:
- 可靠性:假设
match (Plus (r1, r2)) cs k为真。根据代码,这意味着match r1 cs k或match r2 cs k为真。不失一般性,假设match r1 cs k为真。根据归纳假设(对r1可靠),存在拆分cs = p @ s满足p在r1的语言中且k(s)为真。由于p在r1的语言中,根据Plus的语义,它也在Plus(r1, r2)的语言中。因此,拆分(p, s)也满足Plus的情况。 - 完备性:假设存在拆分
cs = p @ s,使得p在Plus(r1, r2)的语言中且k(s)为真。根据Plus的语义,p或在r1的语言中,或在r2的语言中。假设p在r1的语言中。那么根据归纳假设(对r1完备),match r1 cs k为真。根据match对Plus的实现,match (Plus (r1, r2)) cs k也为真。
其他情况的证明思路类似,但可能更复杂(尤其是 Times 和 Star)。
总结
本节课中我们一起学习了正则表达式。我们从文本验证的具体问题出发,看到了手动实现验证器的繁琐和易错性。为了寻求更优雅、通用的解决方案,我们引入了正则表达式的概念。
我们首先从数学上定义了正则表达式,它由基本构件(空集、空串、单个字符)通过三种操作(并、连接、克林闭包)递归构成,能够描述许多有用的字符串模式。
接着,我们在 SML 中实现了正则表达式的数据类型和一个强大的 match 函数。这个函数利用续延来优雅地处理复杂的匹配逻辑,特别是对于连接和闭包操作。我们还讨论了如何避免在实现 Star 时可能出现的无限递归问题。
最后,我们概述了如何证明这个匹配器的正确性,这体现了将理论思考转化为可靠实践的过程。

正则表达式是理论计算机科学(形式语言与自动机)和实际编程(文本处理、数据验证)的完美结合点。理解其背后的原理,不仅能帮助你更有效地使用它们,也展示了函数式编程和递归思想在解决复杂问题时的强大能力。
14:结构与签名 🧱
在本节课中,我们将学习SML中模块系统的核心概念:结构与签名。我们将了解如何通过模块来组织代码、隐藏实现细节,并建立清晰的接口契约。这对于编写可维护、可协作的大型软件至关重要。


1. 模块与命名空间
到目前为止,我们已经学习了函数、数据类型和异常等语言特性,它们帮助我们解决“如何编写函数”这类问题。然而,软件不仅仅是写出来的,更是为了被阅读、使用和文档化。今天我们要讨论的是如何将软件协作的过程融入语言本身。


一个强大的工具就是命名空间。我们之前使用过的 List、String 等库就是模块,同时也是命名空间。例如,List.compare 和 String.compare 是两个不同的函数,因为它们位于不同的命名空间中,所以不会相互冲突。
结构(Structure)允许我们创建自己的命名空间。以下是一个简单的结构示例:

structure P =
struct
datatype t = Bar of int
val x = Bar 5
exception E of t
end
在这个结构中,我们定义了一个数据类型 t、一个值 x 和一个异常 E。要访问结构内的内容,需要使用前缀,例如 P.x 或 P.E。这有效地将相关代码组织在一起,避免了顶层命名空间的混乱。
2. 签名:定义接口

仅仅将代码打包进结构还不够。我们常常希望只向用户暴露必要的部分,而隐藏内部的辅助函数和实现细节。这就是签名(Signature)的作用。

签名定义了模块的接口,它列出了模块对外可见的组件及其类型,就像一个由编译器检查的“合同”。
考虑我们之前实现的归并排序函数 msort。文件中可能还包含 split 和 merge 等辅助函数。作为库的用户,我们只关心 msort 本身。我们可以通过签名来达成这个目的。
首先,我们定义一个签名:
signature SORT =
sig
val sort : ('a * 'a -> order) -> 'a list -> 'a list
end

这个签名声明了一个名为 sort 的函数,其类型是通用的比较排序函数类型。
接着,我们创建一个结构来实现这个签名,并使用 :> 进行不透明归属:
structure MergeSort :> SORT =
struct
(* split, merge 等辅助函数的实现在这里 *)
fun sort cmp lst = ... (* 归并排序的实现 *)
end
现在,当我们打开 MergeSort 模块时,只能看到 sort 函数,而 split 和 merge 被完全隐藏了。编译器会确保结构体的实现符合签名的规定。
使用签名的好处:
- 隐藏声明:只暴露用户需要关心的部分,减少认知负担。
- 指定类型:为函数提供明确的类型契约,如果实现被错误地修改,编译器会报错。

3. 抽象与信息隐藏
抽象是软件工程的核心。我们不应该总是关注代码底层的具体实现(比如函数在硬件层面是指针),而应该用更高层次、更符合人类思维的概念来思考。

签名帮助我们实现了这种抽象。例如,SORT 签名只告诉我们有一个排序函数,而不关心它是归并排序、快速排序还是插入排序。只要它行为正确且高效,具体实现对我们就是不可见的。
这种隐藏内部信息的能力被称为信息隐藏。它使我们能够:
- 减轻概念负担。
- 防止自己或他人破坏代码内部的不变量。
- 便于后续重构(只要接口不变,内部实现可以任意更改)。

4. 抽象类型与不透明归属
结构不仅可以包含值和函数,还可以包含类型。当我们希望完全隐藏一个数据类型的内部表示时,就需要用到抽象类型。



让我们以整数集合(Set)库为例。我们首先定义一个签名:

signature INSET =
sig
type t
val empty : t
val insert : int * t -> t
val remove : int * t -> t
val member : int * t -> bool
end
注意,签名中的 type t 没有给出具体定义,它只是一个类型规范。

现在,我们可以用整数列表来实现这个集合:


structure InSetList :> INSET =
struct
type t = int list
val empty = []
fun insert (x, xs) = ... (* 确保不重复插入 *)
fun remove (x, xs) = ...
fun member (x, xs) = ...
end

关键点在于我们使用了不透明归属 :>。这意味着对于 InSetList 的用户来说,InSetList.t 是一个完全抽象的类型。用户无法知道它实际上是 int list,也无法直接构造一个 int list 值并将其当作 InSetList.t 来使用。

为什么这很重要?
假设集合的内部不变量是“列表元素不重复”。如果用户能直接操作底层列表,他们完全可以创建一个 [1,1,1] 这样的值并当作集合使用,从而破坏不变量。通过不透明归属,用户只能通过 empty、insert、remove 等接口函数来创建和操作集合,从而保证了不变量的始终维持。

5. 表示独立性



表示独立性是抽象类型带来的一个强大理念。它指的是我们可以独立于具体的数据表示方式来思考和推理代码。

对于我们的集合库,用户应该思考的是数学意义上的集合(包含哪些元素),而不是底层的列表或树。无论内部是用列表 [1,2]、[2,1] 还是二叉搜索树来实现“包含1和2的集合”,在用户看来,它们都是行为等价的,属于同一个“抽象值”。

我们可以将每个具体的内部值(如不同的列表)划分到不同的等价类中,每个等价类对应一个抽象的数学概念(如集合{1,2})。所有库的操作(insert, member等)都是在这些等价类之间进行转换。
表示独立性的威力在于:
我们可以证明两个不同的实现(例如用列表实现的 InSetList 和用树实现的 InSetTree)在行为上是完全等价的。我们可以定义一个关系 R,将两个实现中表示相同抽象集合的具体值关联起来。然后证明,所有操作都保持这个关系。这意味着,无论使用哪个实现,任何操作序列产生的外部可观察行为都是一致的。
这种证明是确保代码重构正确性的强大工具,也是本次课程作业中将会涉及的内容。
总结
本节课我们一起学习了SML模块系统的核心:
- 结构:用于将相关代码组织到独立的命名空间中。
- 签名:用于定义模块的对外接口,实现信息隐藏和契约检查。
- 抽象类型与不透明归属:通过完全隐藏数据类型的内部表示,来强制维持代码不变量,并实现表示独立性。
- 表示独立性:允许我们在更高的抽象层次(而非具体实现细节)上推理代码行为,这是构建可靠、可维护软件系统的关键思想。

虽然这些概念在SML中学习,但其背后的软件工程原则——通过清晰的接口和封装来管理复杂度——是普适的,适用于任何编程语言和大型项目开发。
15:函子(Functors)📦

在本节课中,我们将学习如何通过函子(Functors) 来模块化我们的代码。函子是对上一讲中模块概念的扩展,它允许我们编写依赖于其他模块的代码。我们将从一个简单的字典(Dictionary)实现开始,逐步探索如何构建一个支持任意键类型的、安全的、可复用的多态字典库。
概述
我们将从实现一个简单的字符串字典开始,然后尝试将其泛化为多态字典。在此过程中,我们会遇到几个问题,例如如何保证比较函数的一致性。最终,我们将引入类型类(Type Classes) 和函子(Functors) 的概念,它们能帮助我们优雅地解决这些问题,实现类型安全且高度可复用的代码。
字符串字典的实现
首先,我们定义一个字符串字典的签名(Signature)。签名就像模块的“类型”,它规定了模块必须提供的接口。
signature STR_DICT =
sig
type key = string
type 'a t
val empty : 'a t
val insert : key * 'a * 'a t -> 'a t
val lookup : key * 'a t -> 'a option
end
这个签名 STR_DICT 规定:
key类型被具体指定为string。'a t是一个抽象的字典类型,其值类型为'a。- 必须提供三个操作:创建空字典的
empty、插入键值对的insert和查找键的lookup。
接下来,我们用一个简单的列表结构来实现这个签名。
structure StrDict :> STR_DICT =
struct
type key = string
type 'a t = (key * 'a) list
val empty = []
fun insert (k, v, l) = (k, v) :: l
fun lookup (k, []) = NONE
| lookup (k, (k', v)::xs) =
if k = k' then SOME v else lookup (k, xs)
end
这个实现非常简单:字典就是一个 (key * 'a) list。插入操作直接在列表头部添加新对,查找操作则线性遍历列表。这种实现的查找时间复杂度是 O(n),效率不高。
上一节我们介绍了基础的字符串字典实现,本节中我们来看看如何用更高效的数据结构——二叉搜索树(BST)来实现它。
使用二叉搜索树改进
为了提高效率,我们可以使用二叉搜索树来存储键值对。假设树是平衡的(具体平衡方法将在后续课程讨论),查找和插入的时间复杂度可以降至 O(log n)。
我们需要修改实现,将内部类型从列表改为树。以下是关键的变化部分:
structure StrDictTree :> STR_DICT =
struct
type key = string
datatype 'a t = Empty | Node of 'a t * (key * 'a) * 'a t
val empty = Empty
fun insert (k, v, Empty) = Node(Empty, (k, v), Empty)
| insert (k, v, Node(L, (k', v'), R)) =
case String.compare(k, k') of
EQUAL => Node(L, (k, v), R)
| LESS => Node(insert(k, v, L), (k', v'), R)
| GREATER => Node(L, (k', v'), insert(k, v, R))
fun lookup (k, Empty) = NONE
| lookup (k, Node(L, (k', v'), R)) =
case String.compare(k, k') of
EQUAL => SOME v'
| LESS => lookup (k, L)
| GREATER => lookup (k, R)
end
这个实现的核心逻辑是:根据键的比较结果,递归地在左子树或右子树中进行操作。虽然效率提升了,但我们的字典仍然被限制为只能使用 string 类型的键。
迈向多态字典:第一次尝试
我们希望字典的键可以是任意类型,而不仅仅是字符串。为此,我们需要一个比较函数来指导二叉搜索树的构建。首先,我们修改签名,使其接受两个类型参数:一个用于键('k),一个用于值('v)。
signature POLY_DICT_V1 =
sig
type ('k, 'v) t
val empty : ('k, 'v) t
val insert : 'k * 'v * ('k, 'v) t -> ('k, 'v) t
val lookup : 'k * ('k, 'v) t -> 'v option
end
注意,键的类型 'k 现在是完全抽象的,签名中没有规定如何比较它们。
为了实现这个签名,我们必须将比较函数作为参数传递给 insert 和 lookup 等每个操作。
structure PolyDictV1 :> POLY_DICT_V1 =
struct
datatype ('k, 'v) t = Empty | Node of ('k, 'v) t * ('k * 'v) * ('k, 'v) t
val empty = Empty
fun insert (cmp, k, v, Empty) = Node(Empty, (k, v), Empty)
| insert (cmp, k, v, Node(L, (k', v'), R)) =
case cmp(k, k') of
EQUAL => Node(L, (k, v), R)
| LESS => Node(insert(cmp, k, v, L), (k', v'), R)
| GREATER => Node(L, (k', v'), insert(cmp, k, v, R))
fun lookup (cmp, k, Empty) = NONE
| lookup (cmp, k, Node(L, (k', v'), R)) =
case cmp(k, k') of
EQUAL => SOME v'
| LESS => lookup (cmp, k, L)
| GREATER => lookup (cmp, k, R)
end
这个实现存在一个严重问题:使用者必须在每次调用 insert 或 lookup 时都传入正确的比较函数。这很容易出错,如果混用了不同的比较函数(例如,有时用标准字符串比较,有时用“猪拉丁语”字符串比较),会导致字典行为异常,破坏其不变性。
引入类型类(Type Classes)
为了解决上述问题,我们需要一种方法来将比较函数与字典实例静态地绑定在一起。这就是类型类(Type Class) 的概念。一个类型类定义了一组类型必须支持的操作。
例如,我们可以定义一个 ORD 类型类,它表示“可比较的类型”:
signature ORD =
sig
type t
val compare : t * t -> order
end
任何具有一个类型 t 和一个 t * t -> order 比较函数的结构,都可以作为 ORD 的实例。
以下是几个 ORD 的实例:

(* 字符串的标准比较 *)
structure StringOrd : ORD =
struct
type t = string
val compare = String.compare
end
(* 整数的标准比较 *)
structure IntOrd : ORD =
struct
type t = int
val compare = Int.compare
end
(* 使用“猪拉丁语”规则的字符串比较 *)
fun pigLatinCompare (s1, s2) = ...
structure PigLatinOrd : ORD =
struct
type t = string
val compare = pigLatinCompare
end
注意,StringOrd 和 PigLatinOrd 虽然内部类型 t 都是 string,但使用了不同的比较函数,它们是 ORD 的两个不同实例。


结合类型类的字典签名
现在,我们修改字典的签名,要求使用者必须提供一个 ORD 实例来定义键的类型和比较方式。

signature POLY_DICT =
sig
structure Key : ORD
type 'v t
val empty : 'v t
val insert : Key.t * 'v * 'v t -> 'v t
val lookup : Key.t * 'v t -> 'v option
end
这个签名表示:要有一个字典,你必须先给我一个 Key 结构,它告诉我键的类型 Key.t 以及如何比较它们 Key.compare。然后,我的字典值类型是 'v。
我们可以为字符串键实现这个签名:

structure StrDictTC :> POLY_DICT where type Key.t = string =
struct
structure Key = StringOrd (* 使用字符串类型类 *)
datatype 'v t = Empty | Node of 'v t * (Key.t * 'v) * 'v t
val empty = Empty
fun insert (k, v, Empty) = Node(Empty, (k, v), Empty)
| insert (k, v, Node(L, (k', v'), R)) =
case Key.compare(k, k') of
EQUAL => Node(L, (k, v), R)
| LESS => Node(insert(k, v, L), (k', v'), R)
| GREATER => Node(L, (k', v'), insert(k, v, R))
fun lookup (k, Empty) = NONE
| lookup (k, Node(L, (k', v'), R)) =
case Key.compare(k, k') of
EQUAL => SOME v'
| LESS => lookup (k, L)
| GREATER => lookup (k, R)
end
这里的关键是 where type 子句,它执行了选择性透明(Selective Transparency)。虽然整个结构对 POLY_DICT 签名是不透明(Opaque) 的(隐藏了 'v t 的具体实现),但我们明确指定了 Key.t 就是 string,让外部使用者知道键的类型。这样,编译器就能进行类型检查,我们可以安全地使用 StrDictTC.insert("hello", 42, ...) 这样的代码。
然而,这个方案还有一个缺陷:如果我们想要一个整数键的字典,就必须把上面的代码几乎完全复制一遍,只把 structure Key = StringOrd 改成 structure Key = IntOrd。这违反了代码复用的原则。

最终方案:函子(Functors)



函子(Functors)是 SML 中用于模块参数化的功能。你可以把函子看作是一个“函数”,它接收模块作为参数,并返回一个新的模块。这正是我们需要的:一个能根据给定的 ORD 实例,“生成”对应字典的工厂。
以下是字典函子的定义:
functor MakeDict (Key : ORD) :> POLY_DICT where type Key.t = Key.t =
struct
structure Key = Key (* 参数 Key 成为内部结构 *)
datatype 'v t = Empty | Node of 'v t * (Key.t * 'v) * 'v t
val empty = Empty
fun insert (k, v, Empty) = Node(Empty, (k, v), Empty)
| insert (k, v, Node(L, (k', v'), R)) =
case Key.compare(k, k') of
EQUAL => Node(L, (k, v), R)
| LESS => Node(insert(k, v, L), (k', v'), R)
| GREATER => Node(L, (k', v'), insert(k, v, R))
fun lookup (k, Empty) = NONE
| lookup (k, Node(L, (k', v'), R)) =
case Key.compare(k, k') of
EQUAL => SOME v'
| LESS => lookup (k, L)
| GREATER => lookup (k, R)
end
MakeDict 是一个函子,它接受一个满足 ORD 签名的模块 Key 作为参数。在函子体内,我们直接使用传入的 Key.compare 函数。返回的模块满足 POLY_DICT 签名,并且其 Key.t 类型与传入的 Key.t 一致。

现在,我们可以轻松地创建各种类型的字典,而无需复制代码:

structure IntDict = MakeDict(IntOrd)
structure StringDict = MakeDict(StringOrd)
structure PigLatinDict = MakeDict(PigLatinOrd)

val d1 = IntDict.insert(1, "one", IntDict.empty)
val d2 = StringDict.insert("hello", 42, StringDict.empty)
IntDict、StringDict 和 PigLatinDict 是三个完全独立、类型安全的字典模块。编译器会确保你不会错误地在 StringDict 上使用 PigLatinOrd 的比较逻辑,因为比较函数已经通过函子应用被静态地绑定到了每个具体的字典模块中。

函子的强大之处:组合类型类

函子的威力不仅限于创建基础类型的字典。我们可以编写更高级的函子来组合类型类。例如,创建一个用于比较二元组的函子:

functor PairOrd (A : ORD) (B : ORD) : ORD =
struct
type t = A.t * B.t
fun compare ((a1, b1), (a2, b2)) =
case A.compare(a1, a2) of
EQUAL => B.compare(b1, b2)
| ord => ord
end
这个 PairOrd 函子接受两个 ORD 实例 A 和 B,返回一个新的 ORD 实例,其类型 t 是 A.t * B.t,比较规则是先比较第一个元素,如果相等再比较第二个元素。
然后,我们可以轻松地创建用于 (int * string) 对等复杂键的字典:

structure IntStringOrd = PairOrd(IntOrd)(StringOrd)
structure IntStringDict = MakeDict(IntStringOrd)
通过函子的组合,我们实现了高度的代码复用和类型安全。
总结
本节课中我们一起学习了:
- 字典的演进:从简单的字符串列表字典,到二叉搜索树字典,再到支持任意键类型的多态字典。
- 核心问题:在多态字典中,如何保证与字典实例关联的比较函数始终一致,避免运行时错误。
- 类型类(Type Classes):通过
ORD这样的签名,定义了一组类型所需支持的操作(如比较),为类型赋予“能力”。 - 函子(Functors):作为模块级别的函数,函子接收一个或多个模块作为参数,并返回一个新模块。我们使用
MakeDict函子,根据不同的ORD实例,动态生成类型安全且高效的多态字典模块。 - 代码复用与安全:通过函子,我们避免了为每种键类型复制粘贴代码,同时利用 SML 的类型系统在编译期就保证了比较逻辑的一致性,实现了优雅的模块化设计。

函子是将参数化多态提升到模块层次的重要工具,它允许我们构建高度可配置、可复用且类型安全的软件组件。
16:红黑树 🎄
在本节课中,我们将学习红黑树,这是一种自平衡二叉搜索树。我们将探讨其不变性、实现方式,并通过一个操作序列的最终追踪来加深理解。红黑树是模块抽象的一个绝佳应用案例,因为它允许我们隐藏内部实现细节,确保数据结构的不变性不被外部代码破坏。
红黑树简介
上一节我们讨论了泛型字典,它允许我们使用任意类型的键和数据。今天,我们将重点讨论红黑树,这是一种确保操作时间复杂度为对数级别的数据结构。
红黑树是一种特殊的二叉搜索树,其节点被标记为红色或黑色。它通过维护三个关键的不变性来保证树的平衡。
红黑树的定义
在 Standard ML 中,红黑树可以定义如下:
datatype color = Red | Black
datatype 'a tree = Empty
| Node of color * 'a tree * (key * 'value) * 'a tree
红黑树要么是空的,要么是一个节点。节点包含颜色(红或黑)、两个子树、一个键和一个值。
红黑树的不变性 🛡️
红黑树必须满足以下三个不变性,以确保其平衡性和操作效率。
- 二叉搜索树性质:树必须是一个有效的二叉搜索树。这意味着对树进行中序遍历时,键值必须按非递减顺序排列。
- 红色节点约束:任何红色节点的子节点必须是黑色的。不允许出现两个连续的红色节点(称为“红-红违规”)。
- 黑色高度一致:从根节点到任何空节点的每条路径上,黑色节点的数量必须相同。这个数量称为树的“黑色高度”。
这些不变性共同保证了树的高度大致平衡,使得查找和插入操作在最坏情况下的时间复杂度为 O(log n),其中 n 是树中节点的数量。
维护不变性:插入与再平衡 ⚖️
在红黑树中插入新节点时,我们可能会暂时破坏不变性,但会立即通过“再平衡”操作来恢复它们。我们的策略是:先破坏规则,再迅速修复。
插入步骤
插入操作遵循以下步骤:
- 按BST规则插入:根据二叉搜索树的性质,新节点有唯一确定的位置。
- 将新节点着色为红色:我们总是将新插入的节点初始化为红色。这是因为插入黑色节点会立即破坏“黑色高度一致”的不变性。
- 检查并修复违规:如果新红色节点的父节点也是红色,则违反了“红色节点约束”。此时,我们需要通过“再平衡”操作来修复。
再平衡操作
再平衡的核心是处理局部出现的“红-红违规”。我们只关注违规节点及其父节点、祖父节点构成的“三元组”。通过旋转和重新着色,我们可以将这个局部子树转换为一个符合规则的形态。
一个标准的修复模式是将三元组重组为一个以中间节点为根、两个子节点为黑色的子树,并将根节点着色为红色。这个操作可能会将红色违规向上“推送”到树的更高层,但递归地进行此操作,最终违规会被推到根节点,此时只需将根节点重新着色为黑色即可。
在 Standard ML 中实现红黑树 💻
现在,我们来看看如何在 Standard ML 中具体实现红黑树的插入操作。我们将使用模块来封装实现细节,确保不变性。
辅助函数:restoreLeft 和 restoreRight
我们定义两个辅助函数来处理不同方向的“红-红违规”。restoreLeft 处理左子节点违规的情况,restoreRight 处理右子节点违规的情况。它们的核心是通过模式匹配识别特定的违规树形,并进行旋转和重新着色。
以下是 restoreLeft 函数处理一种情况的示例模式匹配:
fun restoreLeft (Node (Black, Node (Red, Node (Red, a, x, b), y, c), z, d)) =
Node (Red, Node (Black, a, x, b), y, Node (Black, c, z, d))
| restoreLeft t = t
递归插入函数 ins
ins 函数是递归插入的核心。它接收一个红黑树和一个键值对,返回一个可能暂时是“几乎红黑树”(ARBT)的树。ARBT 允许在根节点处存在一个“红-红违规”。
在 ins 中,我们根据比较结果递归地插入到左子树或右子树。插入后,如果当前节点是黑色节点且其子节点变成了ARBT(即可能引入违规),我们就调用 restoreLeft 或 restoreRight 进行修复。
最终插入函数 insert
insert 函数是对外的接口。它调用 ins 进行插入,然后检查返回树的根节点颜色。如果根节点是红色的,就将其重新着色为黑色,以确保最终树是一个完全合格的红黑树。
fun insert (t, k, v) =
case ins (t, k, v) of
Node (Red, l, (k', v'), r) => Node (Black, l, (k', v'), r)
| t' => t'

查找函数 lookup
查找函数与普通二叉搜索树相同,通过比较键值递归地遍历左子树或右子树。
操作示例追踪 📝
让我们通过一个简单的插入序列来追踪红黑树的变化。假设我们向一个空树中依次插入键 0, 1, 2, 3。

- 插入 0:树只有一个红色根节点(0)。
- 插入 1:作为 0 的右子节点插入(红色)。此时没有违规。
- 插入 2:作为 1 的右子节点插入(红色)。此时出现“红-红违规”(1 和 2)。触发
restoreRight操作,进行旋转和重新着色,将 1 提升为根(黑色),0 和 2 作为其子节点(红色)。 - 插入 3:作为 2 的右子节点插入(红色)。此时出现新的“红-红违规”(2 和 3)。再次触发修复操作。这个修复可能会将红色违规向上推送,最终在递归返回时,最外层的
insert函数将根节点着色为黑色。
通过这一系列再平衡操作,树始终保持了大致平衡的状态。
总结 🎓

本节课我们一起学习了红黑树,这是一种高效的自平衡二叉搜索树。我们了解了其三个核心不变性:BST性质、红色节点约束和黑色高度一致。我们掌握了插入新节点的策略:先按BST规则插入并着为红色,再通过局部旋转和重新着色来修复可能出现的“红-红违规”。最后,我们看到了如何在 Standard ML 中利用模块化和模式匹配来实现红黑树,确保其复杂的不变性在抽象屏障后得到维护。红黑树是平衡树算法的一个经典范例,也是函数式编程中数据抽象能力的完美体现。
17:序列


概述
在本节课中,我们将要学习一种全新的数据结构——序列。我们将探讨序列的基本概念、操作方式及其成本模型,并了解如何在函数式编程中高效地使用序列进行并行计算。
序列简介
我们之前已经学习了列表,但序列是一种不同的数据结构。序列类似于数组,但它是不可变的。这意味着我们不能直接修改序列中的元素,而是需要创建一个新的序列。
序列具有固定大小,其中每个元素都具有相同的类型。我们可以通过索引在常数时间内访问序列中的任何元素。如果尝试访问超出范围的索引,运行时将抛出异常。
序列和列表各有优缺点。列表适合进行顺序操作,而序列则更适合并行操作。序列的主要优势在于其并行友好的特性,允许我们在多个处理器上同时处理序列中的元素。
序列的基本操作
以下是序列库中一些核心操作的简要介绍:
nth:在常数时间内通过索引访问序列中的元素。length:在常数时间内获取序列的长度。tabulate:根据给定的函数和长度生成序列。map:将函数应用于序列中的每个元素。reduce:使用一个结合性函数将序列缩减为单个值。filter:根据谓词筛选序列中的元素。
序列是一个抽象类型,我们无法直接查看其内部实现。这意味着我们不能对序列进行模式匹配,而必须使用序列库提供的函数来操作它们。
成本图
为了直观地分析序列操作的性能,我们引入成本图的概念。成本图用于可视化计算中的顺序依赖和并行依赖关系。
成本图由三种基本结构组成:
- 计算节点:表示对某个参数执行函数
f的计算。 - 顺序组合:表示先执行一个成本图,再执行另一个成本图。
- 并行组合:表示同时执行多个成本图,总成本取其中最大的一个。
通过组合这些基本结构,我们可以构建出表示复杂计算成本的成本图。分析成本图时,我们关注其工作量(图中所有节点的总和)和跨度(图中最长路径的深度)。
核心序列函数详解
上一节我们介绍了序列的基本概念和成本图,本节中我们来看看几个核心序列函数的具体行为和成本。
tabulate 函数
tabulate 函数接受一个函数 f 和一个整数 n,生成一个序列 <f(0), f(1), ..., f(n-1)>。
其成本图是 n 个对 f 的调用并行组合而成。因此,如果 f 是常数时间函数,那么 tabulate 的工作量是 O(n),跨度是 O(1)。这非常高效,因为我们可以在理论上同时计算所有元素。
nth 和 length 函数
nth 函数用于索引访问,length 函数用于获取长度。由于序列内部存储了长度信息,并且支持常数时间随机访问,因此这两个操作的成本都是常数时间 O(1)。
map 函数
map 函数将函数 f 应用于序列的每个元素。在实现上,map 可以利用 tabulate 和 nth 来构建。其成本图与 tabulate 类似,但每个并行分支包含一个 nth 操作和一次 f 的应用。
如果 f 是常数时间函数,那么 map 的工作量是 O(n),跨度是 O(1)。
reduce 函数
reduce 函数使用一个二元操作 g 和一个初始值 z 将序列缩减为单个值。为了获得良好的并行性能,reduce 要求函数 g 是结合性的。
结合性意味着对于任意 x, y, z,有 g(x, g(y, z)) = g(g(x, y), z)。这允许我们以任意方式加括号进行计算,从而启用并行化。
reduce 的实现类似于锦标赛配对:首先将相邻元素两两配对并用 g 合并,然后对结果再次两两合并,如此重复,直到得到最终结果。
对于长度为 n 的序列和常数时间的 g:
- 工作量是 O(n)(总共约
n次g调用)。 - 跨度是 O(log n)(配对轮数为对数级)。
filter 函数
filter 函数根据谓词 p 筛选序列中的元素。其实现比列表的 filter 更复杂,因为输出序列的长度事先未知。
直观上,可以先将每个元素映射为一个空序列或只包含该元素的序列,然后使用 reduce 和序列连接操作将它们全部合并。虽然这不是实际的实现(因为连接操作成本较高),但它解释了为什么跨度是对数级的。
对于 filter,其工作量是 O(n),跨度是 O(log n)。
应用示例:二维矩阵求和
现在,让我们应用所学的序列知识来解决一个实际问题:求一个二维矩阵(表示为序列的序列)中所有元素的总和。
假设我们有一个 m x n 的矩阵,即一个长度为 m 的序列,其中每个元素是一个长度为 n 的序列(一行)。
我们的思路是:
- 使用
map对每一行(内层序列)调用reduce进行求和,得到一个包含每行总和的序列(长度为m)。 - 对这个总和序列再次使用
reduce,得到整个矩阵的总和。
对应的 SML 代码可能如下:
fun sumMatrix (mat : int Seq.seq Seq.seq) : int =
let
val rowSums = Seq.map (fn row => Seq.reduce op+ 0 row) mat
in
Seq.reduce op+ 0 rowSums
end
成本分析:
- 映射阶段:有
m个并行的行求和任务。每个行求和(对n个元素做reduce)的工作量是 O(n),跨度是 O(log n)。因此,该阶段总工作量是 O(m * n),跨度是 O(log n)。 - 归约阶段:对
m个行总和进行reduce,工作量是 O(m),跨度是 O(log m)。
因此,整个 sumMatrix 函数的总工作量是 O(m * n),总跨度是 O(log n + log m)。与使用列表的纯顺序实现(跨度 O(m * n))相比,序列的并行实现显著降低了跨度。
总结

本节课中我们一起学习了序列这一重要的数据结构。我们了解到序列是不可变的、支持常数时间访问的类数组结构,其核心优势在于并行友好性。我们引入了成本图作为分析并行计算成本的工具,并详细探讨了 tabulate、map、reduce、filter 等核心操作的成本模型。最后,我们通过二维矩阵求和的例子,展示了如何利用序列的并行操作来设计高效的算法。序列为我们提供了在不牺牲函数式编程不可变性的前提下,进行高效并行计算的能力。
18:惰性编程 🦥
在本节课中,我们将要学习惰性求值(Lazy Evaluation)的概念,了解其与急切求值(Eager Evaluation)的区别,并探索如何在SML中模拟惰性行为。我们将重点介绍惰性列表(Lazy Lists)和流(Streams)这两种数据结构,它们允许我们处理潜在的无限序列,而无需预先计算所有元素。最后,我们会通过一个生成所有质数的炫酷例子来展示惰性编程的强大能力。
惰性求值 vs. 急切求值
上一节我们介绍了课程的整体安排,本节中我们来看看惰性求值的基本概念。
在标准的SML(急切求值)中,当我们绑定一个变量时,会立即计算其表达式的值。例如:
val x = 1 div 0
这行代码会立即引发 Div 异常,因为 div 0 被急切地求值了。
然而,在惰性求值语言中,表达式只有在其值被真正需要时才会被计算。这意味着,如果我们只关心一个元组的第一个元素,那么第二个元素中的计算(即使是除以零)可能永远不会发生。这可以避免不必要的计算,有时能显著提升性能。
急切求值的核心规则是:变量被绑定到值。
惰性求值的核心规则是:变量被绑定到表达式。
这种区别带来了一个关键优势:在惰性语言中,像 val x = <巨型表达式> 这样的绑定是常数时间操作,因为表达式本身被存储起来,但并未计算。
惰性的利弊权衡
上一节我们看到了惰性求值如何避免不必要的工作,本节中我们来权衡其利弊。
惰性求值的主要好处是效率。例如,对一个很长的列表使用 map 函数,如果之后只取前几个元素,那么惰性求值可以避免对整个列表应用函数。
然而,惰性求值也带来了显著缺点:不可预测性。在惰性语言中,当你获得一个数据(如一个整数列表)时,你实际上得到的是一系列尚未执行的计算。这些计算可能包含异常、无限循环或耗时操作。问题在于,这些“定时炸弹”只会在你尝试使用(即“强制求值”)列表中的某个元素时才会爆发。这给程序调试、性能分析和API设计带来了巨大困难。
因此,一个更好的设计原则是:让有争议的语言特性成为可选项,而非默认项。这正是我们将在SML中采取的策略——模拟惰性,而不是默认使用它。
在SML中模拟惰性:Thunk
上一节我们讨论了完全惰性语言的潜在问题,本节中我们来看看如何在急切的SML中有选择地实现惰性。
关键在于利用函数。在SML中,函数体在函数被调用前不会求值。因此,我们可以将任何表达式 e 包装在一个不接受实际参数(或接受 unit)的函数中,从而“暂停”它的计算。
这种包装后的函数被称为 Thunk(或挂起,suspension)。其类型为 unit -> t。
我们可以创建一个模块来规范地表示惰性值:
signature LAZY =
sig
type 'a t
val delay : (unit -> 'a) -> 'a t
val force : 'a t -> 'a
end
structure Lazy :> LAZY =
struct
type 'a t = unit -> 'a
fun delay f = f
fun force t = t ()
end
用户通过 Lazy.delay 创建一个惰性值,通过 Lazy.force 来强制求值。类型 'a Lazy.t 在代码中清晰地标记了哪些值是惰性的。
无限数据结构:惰性列表
上一节我们学会了如何创建单个惰性值,本节中我们将其应用于数据结构,创建可以表示无限序列的惰性列表。

首先,我们定义惰性列表的类型。一个惰性列表要么是空的,要么是一个头元素加上一个表示剩余列表的Thunk。
datatype 'a llist = Nil | Cons of 'a * (unit -> 'a llist)
注意,Cons 的第二个分量是一个函数(Thunk),而不是直接的 'a llist。这意味着我们只需要知道如何生成列表的剩余部分,而不需要立即生成它。

例如,我们可以用这个类型表示所有自然数:
fun nats_from n = Cons (n, fn () => nats_from (n+1))
val all_nats = nats_from 0
all_nats 并不会导致无限循环,因为它只生成了第一个元素 0 和一个知道如何生成后续元素的函数。只有当我们强制求值那个Thunk时,计算才会继续进行。
最大化惰性:流(Streams)
上一节介绍的惰性列表还有一个问题:即使你不想查看第一个元素,在构造 Cons 时,头元素也已经被计算出来了。这还不够“懒”。
我们想要一种最大化惰性的数据结构:不计算任何元素,直到明确表达需要它的意图。这就是流(Stream)。
流的定义使用相互递归的类型:
datatype 'a front = Nil | Cons of 'a * 'a stream
and 'a stream = Stream of (unit -> 'a front)
概念上:
'a stream:一个被延迟的front。你无权查看其元素。'a front:一个被暴露的stream。你可以查看其第一个元素(Cons)或知道它为空(Nil)。
我们通过两个关键函数与之交互:
(* 将一个生成 front 的 thunk 包装成 stream *)
fun delay f = Stream f
(* 将一个 stream 解包,暴露其 front,表达查看意图 *)
fun expose (Stream f) = f ()
delay 和 expose 在代码中清晰地标记了惰性求值的边界。
以下是使用流定义自然数和映射函数的例子:
(* 生成从n开始的自然数流 *)
fun nats n = delay (fn () => nats_front n)
and nats_front n = Cons (n, nats (n+1))
(* 流的映射函数 *)
fun map f str = delay (fn () => map_front f (expose str))
and map_front f Nil = Nil
| map_front f (Cons (x, rest)) = Cons (f x, map f rest)
注意 map 的实现:它先 delay,然后在内部的 thunk 中才 expose 输入流。这意味着仅仅调用 map 不会触发任何计算,只有对结果流调用 expose 时,计算才会发生。这实现了我们想要的“最大化惰性”。
应用实例:筛法求质数
最后,让我们看一个能体现流之优雅的强大例子:用埃拉托斯特尼筛法生成所有质数。
思路是:从自然数流开始,取出第一个数 p(它一定是质数),然后过滤掉流中所有能被 p 整除的数,对剩余流递归地进行筛法。
fun divisible_by x y = (y mod x = 0)
fun sieve s = delay (fn () => sieve_front (expose s))
and sieve_front Nil = Nil
| sieve_front (Cons (x, xs)) =
Cons (x, sieve (filter (not o (divisible_by x)) xs))
(* 从2开始的自然数流 *)
val naturals = nats 2
(* 所有质数的流 *)
val primes = sieve naturals
这段代码简洁地生成了一个包含所有质数的、惰性的无限流。它展示了如何通过组合高阶函数(filter)和惰性流来声明式地表达复杂的算法。
总结 🎯
本节课中我们一起学习了惰性编程的核心思想。我们对比了急切求值与惰性求值,认识到完全惰性可能带来的不可预测性问题。因此,我们在SML中采用了通过Thunk和抽象数据类型来有选择地模拟惰性的策略。

我们重点掌握了两种表示无限序列的数据结构:
- 惰性列表:其尾部是一个Thunk,允许我们逐步生成序列。
- 流:一种最大化惰性的结构,通过相互递归的
stream和front类型,确保只有在明确调用expose时才会计算元素。
我们还学习了为这些结构编写函数(如 map, append, filter)的模式,通常涉及相互递归的、分别处理 stream 和 front 的函数。最后,通过埃拉托斯特尼筛法生成质数流的例子,我们见证了惰性编程在表达无限数据结构和算法方面的强大与优雅。
19:命令式编程 🚨
在本节课中,我们将要学习命令式编程的核心概念——可变性。我们将探讨如何在标准ML中通过引用单元(ref cells)来引入可变状态,了解其基本操作,并学习如何安全、有节制地使用它。虽然可变性会破坏纯函数式的优雅特性,但在某些场景下,它是与真实世界交互的必要工具。
可变性:一把双刃剑
到目前为止,我们一直在使用纯函数式代码,即给定相同输入总是返回相同输出的代码。这带来了可预测的行为。然而,可变性确实存在于标准ML中,它会破坏我们之前建立的许多美好特性。
例如,在存在异常的情况下,加法运算不再满足交换律。考虑表达式 raise Div + raise Bind,如果我们交换两个加数的顺序,先被引发的异常会不同,导致程序行为不同。这破坏了纯函数式的核心假设。
另一个例子是打印函数 print : string -> unit。从类型上看,对于任何字符串 s,print s 都返回 unit。然而,如果我们尝试用 print "hi" 替换程序中所有的 unit,程序的行为将发生巨大变化,因为它会产生大量额外的输出。这表明,一旦引入副作用,外延等价(extensional equivalence)的概念就需要重新审视。
我们无法完全摆脱副作用,因为计算机需要与现实世界交互。关键在于如何安全地使用它们,避免“搬起石头砸自己的脚”(footguns)。
我们的策略不是完全禁止可变性,而是让它成为“可选加入”(opt-in)的特性。这与C语言等默认可变性的语言形成鲜明对比。在标准ML中,我们通过特定的类型来明确标识可变状态。
引用单元:可变状态的容器
引用单元是我们的解决方案。我们引入一个新类型 T ref,它表示一个可变的存储单元,其中存放着类型为 T 的值。你可以把它想象成一个“盒子”,盒子里装着某个值,但这个值在未来可能会被改变。
核心概念:
T ref是一个类型,表示一个可变的、存放T类型值的“盒子”。- 盒子本身是固定的,但盒子里的内容可以改变。
与C语言中可能为空的“空指针”不同,T ref 盒子在创建时必须包含一个值,这避免了空指针解引用这类危险错误。
引用单元的基本操作
以下是操作引用单元的三个基本原语:
-
创建:
ref : 'a -> 'a ref- 函数
ref接受一个值v,将其放入一个新创建的盒子中,并返回这个盒子。 - 重要:每次调用
ref都会创建一个全新且唯一的引用单元。
- 函数
-
读取:
! : 'a ref -> 'a- 操作符
!(读作“bang”)接受一个引用单元,返回当前盒子中存放的值。 - 这是一个不纯的操作,因为多次对同一个引用单元使用
!可能返回不同的值。
- 操作符
-
修改:
:= : 'a ref * 'a -> unit- 操作符
:=(读作“colon equals”或“walrus”)接受一个引用单元和一个新值。它将盒子中的内容替换为新值。 - 这个操作执行一个副作用(改变状态),并返回
unit。任何返回unit的函数都可能涉及副作用。
- 操作符
代码示例:
val r = ref 1; (* 创建一个新盒子 r,里面装着 1 *)
val x = !r; (* x 现在是 1 *)
r := 2; (* 将盒子 r 里的内容改为 2 *)
val y = !r; (* y 现在是 2 *)
除了使用 ! 操作符,我们也可以通过模式匹配来解引用:
case (r1, r2) of
(ref v1, ref v2) => ... (* 在此模式中,v1 和 v2 就是盒子里的值 *)
顺序执行操作符
在命令式编程中,我们经常需要按顺序执行一系列可能带有副作用的操作。使用 let ... in ... end 结构会显得冗长。标准ML提供了顺序执行操作符 ;(分号)。
语法:(E1; E2)
语义:
- 首先求值
E1得到结果v1(其值通常被忽略,特别是当它为unit时)。 - 然后求值
E2得到结果v2。 - 整个表达式的结果是
v2。
代码示例:
(* 冗长的写法 *)
let val _ = r := 150
in
computeSomething()
end
(* 简洁的写法 *)
(r := 150; computeSomething())
(* 多个操作顺序执行 *)
(r := 0; r := 1; r := 5; !r)
引用单元的使用示例:阶乘函数
让我们尝试用引用单元来实现阶乘函数。请注意,这只是一个教学示例,在实际的纯函数式场景中没有理由这样做。
错误尝试(共享引用单元):
val store = ref 1
fun fact 0 = !store
| fact n = (store := n * !store; fact (n-1))
这个实现的问题是,store 是一个全局共享的引用单元。计算 fact 2 会错误地得到结果 4,因为递归调用会重复修改同一个 store,破坏了计算的独立性。
错误尝试(过多引用单元):
fun fact n =
let val store = ref 1
fun fact' 0 = !store
| fact' m = (store := m * !store; fact' (m-1))
in
fact' n
end
这个实现为每次调用 fact 都创建了新的 store,但内部的 fact' 函数是正确的。然而,如果 store 是在 fact' 内部创建的,那么每次递归调用都会创建新盒子,结果永远是1。
正确实现(单一引用单元):
fun fact n =
let val store = ref 1
fun loop 0 = !store
| loop m = (store := m * !store; loop (m-1))
in
loop n
end
这个实现为每次顶层 fact 调用创建一个独立的引用单元 store,内部的辅助函数 loop 共享并修改这个单元。它计算出了正确的阶乘。
这个 fact 函数是观测上纯(observationally pure)的,或者说使用了良性副作用(benign effect)。从外部看,它的行为和纯函数版本的阶乘完全一样,用户无法察觉内部使用了可变状态。
别名与指针追逐

引用单元是值,可以被绑定到多个变量,这称为别名(aliasing)。多个变量指向同一个盒子,通过任何一个变量修改盒子内容,都会影响所有指向它的变量。
代码示例:
val r1 = ref 0 (* 盒子A,内容0 *)
val r2 = r1 (* r2 也指向盒子A *)
val r3 = ref r1 (* 盒子B,内容是指向盒子A的指针 *)
val r1 = ref 1 (* r1 现在指向新盒子C,内容1。盒子A和r2未变 *)
r2 := 3 (* 通过r2修改盒子A的内容为3 *)
val x = !(!r3) (* x = 3,通过盒子B解引用两次得到盒子A的内容 *)
通过引用单元,我们可以构建递归数据结构,甚至是标准ML纯值无法直接表示的循环结构:
datatype 'a mut_list = Nil | Cons of 'a * ('a mut_list ref)

val r = ref Nil
val l = Cons (1, r)
val _ = r := l (* 创建了一个循环链表:Cons(1, ...) 指向自己 *)
遍历这样的列表会导致无限循环。
可变性的实用技巧
尽管需要谨慎,但在某些场景下,有节制地使用可变性可以简化设计或提升效率。
-
生成唯一标识符:
需要一个全局计数器来生成永不重复的ID。val counter = ref 0 fun freshId () = (counter := !counter + 1; !counter)为了更安全,可以将其封装在模块中,隐藏
int类型,只暴露一个抽象类型id和比较函数。 -
钩子函数:
用于解决模块间循环依赖或允许后期注入代码。一个模块提供一个“盒子”(初始为NONE),另一个模块在后期将函数放入盒子(设置为SOME f)。(* 在模块A中 *) val hook : (int -> int) option ref = ref NONE (* 在模块C中(依赖于A) *) val _ = hook := SOME (fn x => x * 2) -
全局设置:
避免将配置参数在函数调用链中层层传递。使用一个全局引用单元来存储设置(如调试标志、详细模式)。val verboseMode = ref false fun debugLog msg = if !verboseMode then print msg else ()这样,任何函数都可以方便地访问
verboseMode设置,而无需修改函数签名。
警告:可变性与并行性
最后,必须强调一个至关重要的原则:可变性和并行性不能混用。
在纯函数式、不可变的设定中,并行计算是安全的,因为线程间无法相互干扰。然而,一旦引入可变状态,在并行环境下访问和修改共享数据将导致数据竞争(data race)和不可预测的行为。程序的状态空间会爆炸式增长,变得完全无法推理。

因此,如果你在编写并行或并发程序,请不惜一切代价避免可变共享状态。




本节课中我们一起学习了命令式编程在函数式语言中的引入方式。我们认识了引用单元类型 T ref 及其三个基本操作 ref、! 和 :=。我们看到了如何用它实现可变状态,并通过阶乘函数的例子理解了正确使用它的模式(避免过多或过少的引用)。我们还探讨了别名、循环数据结构,以及一些在实际工作中安全使用可变性的实用技巧(唯一ID、钩子、全局设置)。最后,我们牢记了可变性与并行性结合的危险性。掌握这些知识,你将能在需要时审慎地使用可变性这一强大工具,同时保持代码主体部分的函数式纯洁与健壮。
20:编译器
概述
在本节课中,我们将要学习编译器的基本概念、历史背景以及其核心工作原理。我们将从编程语言的历史讲起,逐步深入到编译器如何将高级语言代码转换为计算机可以执行的机器代码,并探讨函数式编程在编译器设计中的优势。
编程语言的历史 📜
在遥远的过去,编程语言并不存在。人们最初使用穿孔卡片来控制织布机等简单机器,这可以被视为编程的早期雏形。后来,Ada Lovelace和Charles Babbage设计了分析机,被认为是第一台通用计算机的雏形。
到了19世纪末,Herman Hollerith发明了使用穿孔卡片进行数据处理的机器,并创立了制表机器公司,后来发展成为IBM。Conrad Zuse随后发明了Z3,这是一台可编程的电子计算机,但仍然使用穿孔卡片。
早期的程序员需要手动在卡片上打孔来编写程序。如果出现一个拼写错误,就必须从头开始。他们需要排队等待运行程序,如果卡片掉落,程序就会丢失。在那个时代,编程语言仍然不是真实存在的。
汇编语言的出现 💻
到了20世纪40年代,人们开始使用汇编语言编写程序。汇编语言是一种低级语言,计算机只能理解由0和1组成的机器码,而汇编语言是机器码的一种人类可读的表示形式。
然而,编写汇编语言程序需要极大的智力投入。程序员必须时刻关注程序的整个状态,任何错误的假设都可能导致无法挽回的错误。尽管如此,编程语言仍然不是真实存在的。
编译器的诞生 🎉
在20世纪50年代,John W. Backus在IBM工作,他对汇编语言的繁琐感到不满。他提出了一个想法:是否可以有一个程序,能够将人类可读的编程语言代码转换为汇编语言?
他称之为Fortran。到50年代末,他与一个才华横溢的团队一起,实现了一个能够做到这一点的程序。这就是第一个Fortran编译器。从此,编程语言成为了现实。
编译器与解释器 🔄
编译器是一个将数据从一种形式转换为另一种形式的程序。通常,编译器将用编程语言编写的文本转换为某种计算机的汇编语言或机器语言。
例如,你们本学期一直在使用的SML/NJ就是一个编译器,它将SML程序文本转换为可以在你计算机上运行的机器码。
另一个相关的概念是解释器。解释器读取你的代码并直接执行它,不一定需要显式地将其翻译成其他形式。SML/NJ的REPL(交互式环境)也是一个解释器。
我们可以用SML类型签名来描述这些概念:
compile是一个string -> string类型的函数。它接受SML文本并输出汇编代码。run是一个string -> unit类型的函数。它接受汇编语言文本并直接执行。interpret是一个string -> unit类型的函数。它接受SML文本并直接执行。
理想情况下,解释应该等价于编译后运行:interpret = run o compile。
自举:用语言实现自身 🚀
一个有趣的概念是,可以用一种编程语言来实现该语言自身的编译器。例如,SML/NJ编译器是用SML编写的,Python的PyPy实现是用Python编写的,Ruby编译器是用Ruby编写的。
这是如何实现的呢?我们需要区分“实现编译器”和“使用编译器”。
首先,编程语言是一个概念。在John Backus实现Fortran之前,他脑海中已经有了Fortran的语法,但没有计算机能理解Fortran。
当时我们有汇编语言。用汇编语言,我们可以编写任何函数,包括一个能将Fortran代码转换为汇编的 compile_fortran 函数。
一旦我们用汇编语言编写了 compile_fortran,计算机就能理解Fortran了。现在,Fortran成为了现实。
既然计算机能理解Fortran,我们就可以用Fortran编写任何程序,包括用Fortran重写 compile_fortran。
这个过程被称为“自举”。在实践中,你首先需要用另一种语言(如汇编或C)实现一个基础版本的编译器,然后使用这个编译器来编译用目标语言编写的、更完整或更优化的编译器版本。
编译器实现步骤 🛠️
大多数编译器都具有相同的结构,包含多个处理阶段。我们将以实现一个SML编译器为例,用伪代码进行说明。
以下是编译器的主要阶段:
- 词法分析:将源代码字符串转换为有意义的词法单元列表。
- 语法分析:将词法单元列表转换为抽象语法树。
- 中间代码生成与优化:将AST转换为一种类似汇编但更抽象的中间表示,并在此进行大量优化。
- 代码生成:将优化后的中间表示转换为目标机器的真实汇编代码。
1. 词法分析
词法分析器读取源代码字符串,并将其分解为一系列“词法单元”。这类似于阅读英文时,我们按单词而不是单个字母来理解。
例如,对于SML代码 val x = 2 - 1,词法分析会将其转换为一个Token列表:[VAL, ID(“x”), EQUALS, INT(2), MINUS, INT(1)]。
在SML中,我们可以用一个大的数据类型来定义Token:
datatype token = VAL
| FUN
| TYPE
| ID of string
| INT of int
| PLUS
| MINUS
| EQUALS
| ...
词法分析的结果就是一个 token list。
2. 语法分析与抽象语法树
程序本质上是递归定义的,这使得它们非常适合用递归数据类型来表示。我们使用抽象语法树来捕获程序的结构,而忽略像括号这样的具体语法细节。
例如,表达式 (1 - 2) + 3 对应的AST中,- 是 1 和 2 的父节点(因为减法优先级更高),+ 是 (1-2) 和 3 的父节点。
对于SML程序,我们可以定义AST的数据类型:
datatype exp = Int of int
| Id of string
| Plus of exp * exp
| Minus of exp * exp
...
datatype pat = ...
datatype decl = ValDecl of pat * exp
| FunDecl of string * pat list * exp
...
语法分析器(解析器)的工作就是将Token列表转换为这样的AST。通常使用“递归下降”解析法,为每种语法结构(exp, pat, decl)编写一个解析函数。
例如,解析 val 声明的函数可能如下所示:
fun parseDecl (ts: token list) : decl * token list =
case ts of
VAL :: ts' => let val (p, ts'') = parsePat ts'
val (EQUALS, ts''') = expect (EQUALS, ts'')
val (e, ts'''') = parseExp ts'''
in (ValDecl(p, e), ts'''') end
| ...
每个解析函数都“消耗”掉一部分Token,返回解析出的AST节点和剩余的Token列表,供后续解析使用。
3. 在AST上进行操作
一旦我们有了AST,就可以在其上执行各种有趣的操作,因为AST摆脱了具体语法的束缚。
类型检查:类型检查本质上是在AST上运行的递归函数。例如,对于加法表达式 Plus(e1, e2) 的类型检查规则是:如果 e1 的类型是 int,且 e2 的类型是 int,那么 Plus(e1, e2) 的类型也是 int。
fun typeOf (e: exp) : ty =
case e of
Int _ => IntTy
| Plus(e1, e2) => (case (typeOf e1, typeOf e2) of
(IntTy, IntTy) => IntTy
| ...)
...
优化 - 常量折叠:编译器可以进行优化,例如将 2 - 1 在编译时计算为 1。这可以通过在AST上递归实现的转换函数来完成。
fun constFold (e: exp) : exp =
case e of
Plus(Int i1, Int i2) => Int (i1 + i2)
| Minus(Int i1, Int i2) => Int (i1 - i2)
| Div(Int i1, Int i2) => if i2 <> 0 then Int (i1 div i2) else Div(Int i1, Int i2)
| Plus(e1, e2) => Plus(constFold e1, constFold e2)
... (* 对其他构造也递归调用 constFold *)
需要注意的是,优化必须保持程序行为不变。例如,除以零的操作应该在运行时崩溃,而不是在编译时。
4. 中间代码生成与控制流图
在优化阶段,我们通常会将AST转换为一种更接近汇编、但不受实际机器限制的“抽象汇编”或“中间表示”。在这种表示中,我们假设有无限多个临时变量(称为 temps),并且指令是线性的、无嵌套的。
例如,函数 def f(x, y, z): return x + y + z 可能被转换为:
t1 = y + z
t2 = x + t1
return t2
为了处理条件分支和循环,我们引入“基本块”和“控制流图”的概念。一个基本块是一串顺序执行、没有跳入或跳出的直线代码。控制流图则由这些基本块以及它们之间的跳转边组成。
CFG对于优化至关重要,因为它让我们能够分析程序执行的所有可能路径。例如,它可以帮助我们判断一个计算(如 20 * n)是否可以安全地移出循环(循环不变代码外提),而不会改变程序行为或引入不必要的计算(例如,如果循环可能根本不执行)。
优化可以分为两类:
- 局部优化:在单个基本块内进行的优化,如常量折叠。
- 全局优化:需要分析整个控制流图的优化,如死代码消除、无用变量删除等。
5. 寄存器分配与代码生成
在最终生成真实汇编代码之前,我们需要解决“寄存器分配”问题。抽象汇编假设有无限个临时变量,但真实的CPU只有数量有限的、速度极快的存储单元,称为寄存器。
寄存器分配问题可以类比为:你有一个生日派对,只有8个座位,但你有很多朋友。每个朋友只能在特定的时间段参加。你需要安排一个时间表,让尽可能多的朋友在他们在场的时间段内都有座位坐,同时避免让彼此讨厌的人坐在一起。这是一个NP难问题,通常通过图着色等算法来近似解决。
如果变量太多,寄存器放不下,一些变量就必须被“溢出”到速度慢得多的主内存中。
为什么函数式编程适合编写编译器?🌟
以下是几个关键原因:
- 编译器即树变换:编译器的核心工作就是对AST进行一系列变换。函数式编程擅长处理和变换递归数据结构(如树)。
- 正确性至关重要:编译器绝不能出错。函数式编程强调不可变性、纯函数和强大的类型系统(如代数数据类型、模式匹配),这些特性通过编译器的强制检查,极大地帮助开发者编写出更正确、更可靠的代码。
- 确定性:对于相同的输入程序,编译器应该始终产生相同的输出。函数式编程的纯函数特性天然保证了这一点,避免了因可变状态引入的非确定性。
- 语言爱好者的社区:许多函数式编程的研究者和实践者本身就是编程语言爱好者,这使得函数式编程社区与编译器编写社区有很高的重合度。
相比之下,用C++等命令式语言编写编译器,开发者需要手动管理许多细节,更容易引入错误。

总结
本节课我们一起探索了编译器的世界。我们从编程语言和编译器的历史讲起,理解了编译器如何将高级语言代码转化为机器可执行的指令。我们深入了解了编译器的几个关键阶段:词法分析、语法分析(生成AST)、在AST上进行类型检查和优化、生成中间表示与控制流图、以及最终的寄存器分配和代码生成。
我们还探讨了“自举”的概念,以及为什么函数式编程在编译器设计领域如此强大和受欢迎——主要归功于其对树形结构的天然亲和力、对正确性的内在支持以及确定性。

希望这节课能让你对每天使用的工具(编译器)有更深入的了解,并体会到函数式编程在解决复杂系统问题时的优雅与力量。
21:程序分析 🧠

概述
在本节课中,我们将要学习程序分析。我们将探讨什么是程序分析,为什么它在当今软件无处不在的世界中至关重要,以及我们如何利用它来发现和预防代码中的错误、安全漏洞和不良实践。我们将了解到,尽管程序分析在理论上存在根本性的限制,但通过务实的妥协和巧妙的算法,我们仍然可以构建出强大的工具来帮助改善软件质量。
软件吞噬世界 🌍
上一节我们介绍了编译器,本节中我们来看看程序所处的现实环境。
软件已经渗透到我们生活的方方面面。自“软件正在吞噬世界”这一观点提出以来,软件在经济和社会中的主导地位已毋庸置疑。这意味着全球有数以千万计的软件工程师在编写代码。
然而,并非所有工程师都接受过严格的计算机科学教育,也并非所有人都关注代码的正确性、安全性或优雅性。这导致了一个问题:我们如何确保海量软件的质量?
以下是程序员常犯的一些简单错误示例:
- 拼写错误:例如,在递归调用中写错了函数名。
- 未使用的变量:声明了一个变量却忘记使用它。
- 类型错误:调用函数时没有提供足够的参数。
在像 SML 这样具有强大类型系统的语言中,许多这类错误能在编译时被捕获。但在像 Python 这样的语言中,这些错误可能直到运行时才会暴露,造成潜在的巨大代价(例如,一个运行了数百小时的机器学习模型因一个简单的拼写错误而失败)。
因此,我们的目标是尽可能在编译时(或程序运行前)捕获这些错误,而不是在运行时“蒙着眼睛踩地雷”。
什么是程序分析? 🔍
程序分析是一门通过自动化手段发现程序中不良行为的艺术。这包括影响代码正确性、性能或安全性的任何属性。
程序分析的核心是编写分析其他程序的程序。这通常以两种形式出现:
以下是两种主要的程序分析类型:
- 动态程序分析:通过实际运行程序来分析其属性(例如,模糊测试、性能剖析)。其分析时间受程序运行时间影响。
- 静态程序分析:在不运行程序的情况下分析其属性。这是本节课的重点。由于程序可以表示为抽象语法树(AST),我们可以编写递归函数遍历这棵树来推断程序行为。
静态程序分析的应用非常广泛:
- 语法高亮和代码格式化。
- 类型检查:最基本也是最重要的程序分析形式。
- 安全漏洞扫描(静态应用安全测试)。
一个根本性的障碍:程序分析是不可能的 🚫
在深入技术细节之前,我们必须面对一个核心问题:程序分析在本质上是不可判定的。
这源于计算理论中的莱斯定理:任何关于程序行为的非平凡语义属性都是不可判定的。通俗地说,你无法编写一个程序,对任意程序关于其行为的任何问题都给出确定的是/否答案。
一个经典的例子是停机问题:你无法编写一个程序 halts(f),来判定任意函数 f 是否会终止(停止运行)。
我们可以通过反证法简要说明:
假设存在 halts(f) 函数。那么我们可以构造以下函数 not_halts:
fun not_halts () = if halts(not_halts) then (not_halts ()) else ()
- 如果
halts(not_halts)返回true(表示not_halts会停机),那么not_halts会进入递归调用,从而不停机。 - 如果
halts(not_halts)返回false(表示not_halts不会停机),那么not_halts会立即返回(),从而停机。
这产生了逻辑矛盾,因此halts函数不可能存在。
这个结论“污染”了所有关于程序行为的判定问题。那么,我们该怎么办?
妥协的艺术:三种分析策略 🎭
既然完美地解决问题是不可能的,我们必须降低期望。我们可以放弃以下三者之一:
以下是三种妥协策略:
- A 类分析:放弃“保证终止”。分析可能永远运行下去,但如果它给出答案,答案总是正确的。依赖类型语言的类型检查器属于此类。
- B 类分析:放弃“永远正确”。分析总是能在有限时间内终止,但有时会给出错误答案(误报或漏报)。
- C 类分析:放弃“解决原问题”。转而解决一个更简单、但可判定的相关问题。标准类型检查就是典型的 C 类分析——它不试图判断“程序是否除以零”,而是判断“操作数是否为整数”,后者是可判定的。
在实践中,A 类分析(可能永不终止)通常难以被接受。因此,我们主要关注 B 类和 C 类分析。对于许多重要问题(如“代码是否有 SQL 注入漏洞?”),我们不得不使用 B 类分析。
数据流分析:一个经典的 B 类分析框架 📊
数据流分析是编译器中最经典、应用最广泛的静态分析技术。它是一个 B 类分析框架:总是终止,但可能近似(有时出错)。
其核心思想是:将程序转换为控制流图(CFG),然后在图的节点间传播信息,直到达到一个稳定状态(不再有变化)。
关键洞察:单调性与格理论
为了使分析能够终止,我们设计的信息必须在一个有界偏序集(称为格)上移动,并且我们的更新函数必须是单调的——即信息只能沿着格向上(或保持不变),而不能向下。因为格的高度是有限的,所以单调函数反复应用最终必然会收敛到不动点。
实例:常量传播分析
假设我们想分析一个函数,判断变量在特定点是否是常量。
以下是分析步骤:
- 为每个程序点(基本块之间)关联一个“状态”,记录已知的常量信息。
- 初始化所有状态为“未知”(格底部)。
- 反复遍历控制流图,根据语句更新状态(例如,看到
x = 1,则记录x是常量1)。 - 当控制流汇合时(例如循环入口),合并来自不同前驱的状态。如果同一个变量有不同的常量值,则将其提升为“非常量”(格顶部)。
- 重复步骤 3 和 4,直到所有状态不再变化。
例子:
let
val x = 1
val y = 2
in
while (condition) do (x := x + 2);
return x
end
分析会发现,由于循环可能执行多次,x 在循环后不再是常量,而 y 始终是常量 2。
为什么是 B 类分析? 这个分析是保守的。它可能将某些实际上是常量的变量判定为“非常量”(例如,如果循环条件永远为假,x 其实一直是常量,但分析无法确定这一点)。这是为了确保终止和安全性所做的妥协。
基于 AST 的分析与语义感知模式匹配 🌳


数据流分析通常在低级表示(如控制流图)上进行。但如果我们想直接在抽象语法树(AST) 级别进行分析呢?这有很多好处,尤其是对于多语言支持的工具。


挑战:语言多样性
为每种语言(Java、Python、C++)分别实现 AST 解析器和分析器是巨大的重复劳动。
解决方案:通用 AST(Generic AST)
一个巧妙的想法是:大多数编程语言的核心结构是相似的(变量、函数、循环、条件)。我们可以设计一个通用的 AST 类型,将各种语言的源代码都解析成这个统一的表示。这样,我们只需要为每种语言编写一个到通用 AST 的转换器,而所有的分析逻辑都只需要在通用 AST 上实现一次。这大大降低了支持新语言的成本。

强大的工具:语义感知的模式匹配
在通用 AST 上,我们可以进行强大的树模式匹配。这比简单的文本搜索(如 grep)要精确得多,因为它理解代码的结构。


以下是模式匹配的示例:
- 匹配所有
print(...)调用:模式是函数调用节点,其函数名是"print"。这不会匹配到名为print的变量。 - 匹配所有
ref调用:模式是函数调用节点,其函数名是"ref",参数是一个元变量(可匹配任何子树)。 - 匹配可疑的相等比较
$X == $X:使用同一个元变量$X两次,意味着它匹配的两个表达式必须完全相同。这可以找到像f == f这样可能无意义的比较。 - 匹配低效的列表操作
List.rev [$E] @ $L:这可以找到将单元素列表反转再拼接的低效模式。


这种模式匹配语言让开发者能够以近乎声明式的方式,轻松编写出检测各种代码模式(代码异味、安全漏洞、最佳实践违规)的规则,而无需深入理解复杂的程序分析算法。

总结 🎯

本节课中我们一起学习了程序分析的核心概念:
- 动机:在软件无处不在且质量参差不齐的世界中,自动化分析工具对于保障正确性、安全性和性能至关重要。
- 根本限制:根据莱斯定理,完美的程序分析(对任意程序行为的任何判定)是不可判定的。
- 务实妥协:我们通过定义 A、B、C 三类分析来绕过这一限制,在实践中主要采用保证终止但可能出错的 B 类分析,或解决简化问题的 C 类分析。
- 经典技术:数据流分析是一个强大的 B 类分析框架,它通过在控制流图上单调地传播信息来工作,确保终止但可能近似。
- 现代实践:基于通用 AST 和语义感知树模式匹配的分析方法,使得为多种语言编写高效、易用的分析工具变得可行,让开发者能够轻松捕捉复杂的代码模式。
程序分析是一个将理论限制(不可判定性)与工程实践(近似、妥协、创新)相结合的迷人领域。它使我们能够面对“软件吞噬世界”带来的挑战,主动地改善代码质量,而不是在问题发生后被动响应。通过理解这些原理,你便掌握了构建下一代软件开发工具的基础。
22:终章 🎉

概述
在本节课中,我们将回顾整个学期的学习历程,从最后一讲的内容开始,逐步回溯到课程的开端。我们将总结每一讲的核心概念、关键思想以及它们如何共同构成了函数式编程的完整图景。本节课旨在帮助你巩固所学,并为期末考试做好准备。
课程回顾:从终点到起点
上一节我们概述了本节课的目标,本节中我们将开始倒序回顾整个学期的内容。
第21讲:程序分析
程序是递归的。当你编写一个程序时,它本质上就是一个递归类型,一棵树,一种在其结构中包含递归的东西。这意味着不要害怕递归,不要把它当作藏在暗处的怪物,因为它可以成为你的朋友。正如我们在本课程中所见,它一直是你的朋友。
将像程序这样内容丰富的实体表达出来,可以像定义一个简单的数据类型那样简单:
datatype program = ...
关键要记住的是,当涉及到实际用例、正确做事和产生影响时,程序分析是最后的边界用例之一。它拥有真正特别的东西。
核心思想:不要害怕通过使用清晰的基础、实用的原则和像归纳法这样的主要方法来产生真正的影响。你可以产生真正的影响。
第20讲:编译器
函数式编程是为编译器而生的。这是一个谎言。实际上,它是为人工智能而生的,但那是另一个不同的故事。我们日常使用的工具,在我们计算机中流淌的魔力,与我们通过纯函数、树转换和安全代码所看到的,是完全相同的东西。
所有这些简单的想法在本学期不断累积,最终构建出了我们创造过的最复杂的软件之一,现代编译器。所以不要害怕它,也不要害怕你所学到的东西。珍惜它,珍视它。
核心思想:安全、优雅、表达力。就是这三个想法不断重复。其他一切都是注释。没有什么不能被理解。你可以理解一切。只需将其分解成小块。编写简单树遍历函数时所发现的基础,与运行 SML/NJ 编译器或 GCC 编译器这些世界上最复杂系统的基础是相同的。
第19讲:命令式编程
我们了解到函数式编程不需要是二元的,不需要是一个绝对的概念,因为它有各种深浅和不同的风格。函数式编程只是一种习惯,一个想法,一个范式,但不是绝对的东西。
在这节课中,我们学习了引用单元(ref cells)。我可以有一个包含类型为 alpha 值的盒子,即 alpha ref,或者某个类型 T 的值。我们还了解到这些盒子可以包含其他盒子,这很有趣,并允许我们获得通常无法获得的间接层。
我们学习了创建、访问和修改引用单元的原语。我们看到了一些漂亮的图片,它们看起来像这样。你应该把它们想象成盒子。通过选择使用这些盒子,我们可以增加我们的表达力,但不会以引入“脚枪”(foot guns)为代价。可变性是一种“脚枪”。但如果我们选择性地、谨慎地、明智地使用它,并坚持这些原则,我们就会没事。
核心思想:我们不应该仅仅因为感觉应该或者那种氛围而固守某些想法。我们发明这些东西是为了让它们为我们工作。可变性并不可怕,只要有选择。如果你有选择,坏东西也可以是好的。
第18讲:惰性编程

我们了解到可以挂起计算。如果我有一个 thunk,像这样 fn () => e,我就有一个 e 的挂起,其中 e 永远不会被求值,因为我冻结了 lambda 的内容。在我得到 () 之前,我无法求值它。
这是一个非常简单但强大的想法。这源于将 lambda 视为值,并能够将东西放入 lambda 中。我们看到了 'a stream 数据类型,它看起来像这样:
datatype 'a stream = Susp of (unit -> 'a front)
and 'a front = Empty | Cons of 'a * 'a stream
通过编写相互递归的函数,我们可以编写无限的数据结构。无限也没关系,因为我可以延迟其余部分。这是一种你可能在其他上下文中见过的非常不同的编程风格。它被称为“共归纳”(coinductive)。拥有控制计算何时发生的能力,是非常强大的。
核心思想:简单想法的重复应用可以带来伟大的成果。我们不需要花哨的契约,不需要花哨的自我。我们只需要 lambda 和一个想法。

第17讲:序列
序列本质上是列表,但用于数据的批量操作。我们用数学符号表示它们,并尽可能将它们视为不可变数组。它们提供与列表相同的操作,但适用于不同的用例,因为如果你在做顺序操作,可能不应该使用它们,因为 cons 操作代价高昂。
通过使用一个非常简单的数学思想——结合律,我们可以以快得多的时间(O(log n))进行归约(reduce),而不是像折叠(fold)那样需要 O(n) 时间。这非常强大,因为 log n 实际上是常数级的。仅仅通过采用这种分而治之的简单递归思想,我们就可以对序列做所有我们想做的事情。
我们还有成本图(cost graph)的概念,这是我们通过组合这些成本图来诊断和确定函数成本的方法。例如,tabulate 的成本图只是说并行执行所有这些操作。如果我想做 stab f n,我首先需要支付 length 的成本,然后是第 n 次调用的成本,然后是 f 的成本。我所做的只是将这些单独的成本图组合在一起。
核心思想:你可以理解任何东西,只需将其分解成简单的、通常是递归的部分。函数式程序是并行友好的。厨房里的厨师不会互相妨碍,你的盘子里永远不会出现一盘意式番茄酱。易于组合的批量操作,这就是英雄主义。
第16讲:红黑树
我们学习了红黑树,这是一种自平衡的二叉树,具有三个不变式。最重要的不变式是:任何路径上的黑高相同,并且红节点的子节点必须是黑的。通过采用先破坏再恢复不变式的策略,我们确保能够清晰地实现插入操作,并且始终确保我们的不变式得到尊重:破坏它,然后通过将红节点向上推来修复它。
这是一个非常优雅的想法。我们看到,通过采用这种旋转策略,将红节点向上推,我们最终能够在向上回溯的过程中持续重新平衡,从而得到那个能保持我们不变式的漂亮插入操作。
但这里真正要认识到的是,我们在模块讲座中学到的抽象类型,使我们能够获得更安全的代码,因为你可以超越接口思考。我不需要考虑那些微小的不一致性,我可以通过不变式来思考。
核心思想:编程时,请遵循不变式。
第15讲:函子
函子是模块的映射。我可以有一个接收模块并输出另一个模块的模块。这真的与高阶函数没有什么不同。但我们能做的是拥有类型类(type classes)的概念,它们是签名,是规定特定软件部分的方式。我们可以将值与类型关联起来。例如,我可以让 int 类型关联一个像 intOrd 这样的比较函数。
但这让我们能够模块化我们的代码。我们可以以一种比简单的高阶函数更可扩展的方式,编写依赖于其他代码片段的代码。这一切之所以可能,完全归功于 PL 研究人员数十年的研究。
我们看到,这主要让我们能够定义一个多态字典。我们可以定义一个字典,其中我们的键现在可以参数化于任何类型类,使得这个键可以是任何类型,以及任何符合某个签名的值。然后我就可以像那样定义我的字典。
核心思想:好的软件应该可以由其他软件组合而成。如果不是,你将一遍又一遍地重写相同的东西,结果会像我的 LaTeX 幻灯片一样糟糕。
第14讲:结构与签名(模块)
这是我们第一次学习如何将东西组织在一起。模块让我们将代码组织并分离到命名空间中,因为我们可能希望与列表相关的东西放在这里,与整数相关的东西放在那里,其他所有东西可以放在任何地方。
我们还使用签名,它们是模块的类型。我希望你看到这个类比:类型对应签名,模块对应值,函数对应函数。例如,我们可能有一个集合的签名。
但模块真正给我们的是信息隐藏。当我看到这个签名时,这就是我对这个软件部分的全部了解,也是我需要知道的全部。我不在乎它是一棵树、一个列表,还是穿着戏服的巴尼恐龙。我只关心这是一个集合类型,然后我可以有空集、插入、移除和检查成员资格。
这就是我所知道的全部,也是我关心的全部。所以,把自己分成两个人。你是实现者和使用者。作为实现者,你知道一切;作为使用者,你对其内部一无所知。这会让你成为一个更有效的程序员。
核心思想:清晰地分离你的接口。这使软件在组合时更强大,概念上更简单。
第13讲:正则表达式
正则表达式是一种递归数据类型,用于匹配我们感兴趣的语言。这是一个非常实用的技能。
我们看到,我们可以简单地根据正则表达式的定义来定义它所匹配的语言。这种简单的数学定义无处不在,它催生了更好的代码,因为我们编写了一个匹配函数,根据相同的定义递归地分解来进行匹配。
但我希望你们从这节课中带走的是这个“通过图片证明”的图片。如果你对正则表达式一无所知,在期末考试中,能够画出这张图。因为如果我给你一个关于正则表达式的问题,它很可能是在测试你从概念上理解这张图意味着什么的能力。
核心思想:通过规范推理比通过逐步跟踪代码推理更强大。通过图片推理更好。前缀由 R 匹配,后缀满足 K。就是这样。
第12讲:异常
我们看到了异常。它是一个可扩展的类型,我们可以向其添加构造器。它很有帮助,然后我们可以将其用作逃生通道。如果你的代码中间出了问题,遇到了你不想处理的情况,那就抛出(raise),让它失败。因此,你现在是一名软件工程师了。恭喜。
异常处理风格看起来像 CPS,但我们可以做的是,与其有一个显式的失败延续,不如抛出 NotBound 并在我的递归调用处处理它。这就是我需要做的全部。
核心思想:采取不那么可维护的捷径是可以的,但要小心,要明智。尽量不要走到那一步。我想对引发的异常负责,然后有人因此对我生气。
第11讲:延续传递风格(CPS)
现在我们真正回到了深水区。这是课程的中点。CPS 很难,但如果你能以正确的方式思考它,它就没那么难。
我们这里有树求和函数。我希望你认识到,这实际上只是一个算法。直接递归的想法是“做它”。CPS 的想法是“在延续中做它”。只需将你本应在递归调用中做的操作,放在一个延续中完成。
所以,如果我从 treesum 开始,我首先用占位符变量替换我的递归调用。然后我添加对我的递归函数的调用,这些调用通过 lambda 管道传递,并将其绑定到那个变量。然后我们说,与其通过管道传递到这个延续,不如我将延续作为参数给出,我把它变成一个叫做 k 的函数。所以我“杀死”了管道,添加了这个 k 函数。然后当我返回结果时,我通过管道传递给 k。我必须遵守我的约定,即将我的递归结果传递给 k。
核心思想:复杂的事情可以变得简单。你只需要一个算法来解决它。然后任何傻瓜都能做到,机器也能做到。
第10讲:组合子与暂存

我们讨论了暂存(staging),这个想法是:如果我有一个函数 foo(x, y),其中包含一个对 x 的可怕计算,这个计算需要很长时间。我可以这样写:
fun foo x = let val result = horrible_computation x in fn y => result + y end
在这里,我不是立即接受当前参数 y 并调用可怕计算,而是先调用可怕计算,然后返回一个接受 y 的 lambda。为什么这样做?这意味着如果我部分应用 foo,比如 val f = foo 2,这需要两三年才能运行,但随后的每次调用 f 都是常数时间。
我们可以聪明地安排我们的工作在哪里进行。我们只需要有像柯里化这样的简单概念。然后我们还学习了管道(|>),它让代码读起来像一系列操作,像一个食谱。
核心思想:语法糖是可以的,但仅限于代码。
第9讲:高阶函数
我们学习了柯里化,它只是一个通过返回接受额外参数的函数来接收多个参数的函数。我们现在已经很熟悉了,看到了很多例子。
例如,如果我有 add(x, y) = x + y,它接受一个整数对。但柯里化形式的 addC 是一个返回 lambda 的 lambda。柯里化非常有用,它比使用元组有用得多,因为它也给了我们部分应用的想法。
我们还看到高阶函数非常重要,因为它让我们能够提取代码中的常见模式。如果我有一个像 sum 和 concat 这样的函数,它们求和列表中的所有元素或连接所有字符串,当我查看这段代码时,我看到的是相同的东西,相同的函数。也许你也开始看到了,因为当你查看这段代码时,你看到了一个折叠(fold)。事实证明,当所有这些代码都展现出相同的模式时,函数式编程真正伟大的地方在于,我们可以隔离这些模式,组合它们,并通过几个简单、原始的高阶函数来定义所有函数。
核心思想:高阶函数是函数式编程中的重要工具。编写代码是好的,编写能编写代码的代码更好。这为许多其他事情打开了大门,比如模式匹配、CPS、惰性,都源于函数可以作为值这个简单的想法。
第8讲:多态性
在我们学习高阶函数之前,我们需要一种方法来泛化函数的类型。这非常重要。我们如何做到这一点?我们发现,我们可以用简单的类型变量来实例化我们的函数。我们可以通过为每个类型分配一个变量来实现。
例如,对于函数 fun f(x, y) = if x then y else 2,我们收集每个变量的约束。我们从为每个变量分配一个简单的多态类型变量开始,所以 x 从 alpha 开始,y 从 beta 开始。然后我们看到对 x 做了 if,因此它应该是 bool。我们看到返回 y,这里不能多说,但此外,这个类型需要和那个类型相同,因此 y 应该是 int。然后我的函数的返回类型与 y 或 2 相同,也就是 int。所以我最终得到 bool -> int -> int。
核心思想:简单的程序性规则就是我们解决这类问题所需要的全部。但在类型中增加一点灵活性,就会带来天壤之别。我们得到了最通用类型(most general type)的概念。仅仅通过稍微调整类型结构,我们就在代码中获得了具体的好处。只需要一点点,就能让我们走得很远。
第7讲:排序与并行性
我们回到了数学部分,更关注分析代码的形式化部分。我们学习了树方法(tree method),它让我们能够解决形式为 W(n) = a * W(n/b) + f(n) 的递归式,因为我们对由这些调用引起的树的每一层的工作量求和。
例如,对于归并排序这个特定的递归式,我可能会这样做,其中红色是我的递归调用,紫色是我的非递归工作。我们看到它诱导出了这种树结构,其中我的规模每次除以二,但每次我也将我的工作除以二。总的来说,我最终得到每层 c * n/2 的工作量。非常简单。你需要做的就是能够对每一层求和。
我们还学习了跨度(span)。如果我们有无限多的处理器,它并不能解决所有问题,因为我们有内在的数据依赖。但思考你的代码在并行情况下如何运行是很重要的,因为有时我们对暴力解决旅行商问题感兴趣。所以我们有兴趣拥有跨度这个并行成本的概念。
我们在归并排序的用例中看到了这一点,我们可以得到 O(n log n) 的跨度。这可能是整个课程中我最喜欢的代码片段之一,因为我认为这是你第一次真正看到 SML 代码并觉得它漂亮、优雅。
核心思想:复杂的事情可以很简单,只需递归地使用它们,递归的数学分析也会很容易。
第6讲:运行时分析
在我们讨论并行复杂度之前,我们必须先讨论一般的运行时分析。我们做到了。记住,那时你甚至不知道递归式是什么,但我们可以通过表示某些操作的恒定成本,然后求解递归式(一个递归公式)来为我们的抽象成本单位推导出递归式。
例如,我们从代码中的基本情况得到了树求和的基本情况,从递归代码中得到了递归情况。我们必须定义某种规模的概念:如果是树,可能是节点数或深度;如果是列表,通常是列表长度;如果是数字,通常是数字的大小。这个规模度量很重要。我们可以用 N_L 和 N_R 来求解,它们根据树是否平衡而不同。
核心思想:相信数学。如果你只用数学,事情会变得非常容易理解。
第5讲:树
我们学习了树,它是数据类型声明的一个实例。我们看了几个例子:编译器优化中的程序类型、正则表达式、包含各种食物的类型。当我们设计它们来适应这些特定场景时,可能看起来有些愚蠢和做作。但其优势在于它们完全能够适应这些场景。
结构归纳法可以在任何递归类型上进行。可能看起来不像列表或数字的东西,因为归纳法是一个普遍原则,而递归数据类型比数字更广泛。以前你在概念课或高中数学中学到的归纳法是一个狭隘的概念,但我们可以将其扩展到更广泛、更优雅的东西。
核心思想:证明遵循代码。当你在这门课中写证明时,看看代码。代码以及其中的引理和各种定义是你所知道的全部。当你不知道下一步该做什么时,回头参考代码,因为它会告诉你下一步该做什么。当你写证明时,证明推理的方式与你推理代码正确性的方式相同。只需遵循代码。编写代码就像编写证明。训练这种能力,因为如果你没有这种能力,你最终会通过做足够多次来获得它。我从不纸上谈兵地写正确性或完全性证明,但当我第一次编写函数时,我会在脑海中做证明。我在思考递归的信念飞跃时,会思考我的归纳假设。然后我会得出“哦,感觉对了”或“感觉错了”的结论。很多时候,这会让我意识到“哦,我知道为什么错了”。训练这种肌肉,能够通过编写代码来编写证明。一个贫乏的编程观是将问题适配到某些类型。但作为函数式程序员,我们将类型适配到问题。我们有这种特权。
第4讲:结构归纳法
这实际上是归纳法的升级版。在这一点上,我们认为归纳法只适用于自然数,但我们可以对列表和树进行归纳。n 和 n+1 与 xs 和 x::xs 真的没有太大不同。通过类比来思考。
我介绍了“解析而非验证”(parse, don't validate)的想法。尽可能通过类型表达信息。没有人希望代码看起来像这样,使用一堆愚蠢的访问器,这耗费我们的时间、空间和脑力,因为我不想思考所有这些随机发生的废话。
简单地写,清晰地写。此外,这是我们开始学习完全性(totality)和证明的地方。完全性是一个工具。我们使用完全性来获得某些表达式的值,这让我们能够使用依赖于可求值性的定理、定义或引理。完全性不重要,值才是我们关心的。
核心思想:解析,而非验证。通过类型表达你的数据。类型是你最好的朋友。
第3讲:归纳与递归
在我们讨论结构归纳之前,我们必须讨论普通的、无聊的归纳法。但我们也讨论了递归的想法。我们看到,自然数上的归纳实际上只是对这个数据类型的结构归纳。所以结构归纳严格来说更强大,也更有趣。坚持用它。基本情况,归纳假设,归纳步骤。重复。你们已经做过很多次了。我知道你们已经掌握得很好了。坚持下去,你就会没事。
我们学习了递归的信念飞跃。如果你写一个递归函数,假设它已经有效,然后碰巧,天哪,它真的有效了。事情会按照它应该的方式发展。我不相信命运,但我相信递归。解决无限多问题于有限空间的想法,我们有一个词来形容它,叫做归纳或递归。它们是同一回事。所以不要在你的大脑中耗费精力去思考所有这些完全不相关的事情。不要单步跟踪代码。做递归的信念飞跃。像旅鼠一样跳下悬崖。这对你有好处。
核心思想:递归的信念飞跃。跳下悬崖。顺便说一句,旅鼠不会真的死,我也不确定它们是否跳崖。那是民间传说。
第2讲:基础
在这一点上,我们什么都不知道。你们对这门课的量子一无所知,但我试图发展一个想法,那就是在这一点上,我们仍在学习 SML 的基础。我们讨论了扩展等价性(extensional equivalence),这在整个课程中多次出现。
我无法向你强调能够谈论代码等价性是多么重要。有时我写代码,我会重构代码,然后我会想,等等,这太简单了,我可以直接把它移到这里,因为我知道它没有副作用,不会做任何事情,没关系。然后我会给我的队友发消息说,我太感谢代码等价性了,因为如果没有它,我就得解开这团指针和废话的乱麻,那将非常可怕。我的工作不是解乱麻。
所以我们引入了绑定的概念,它不同于赋值,因为当你绑定一个变量时,它的值永远不会改变。当我说 val x = 2,然后又说 val x = 3 时,我并没有改变 x 的值,我是引入了一个绑定到 3 的不相关的 x。相同的名字,不同的人。这就是全部。但这个非常简单的想法就是不变性的思想。确保你的变量不改变。然后猜怎么着?你不必处理“脚枪”。你不必知道程序直到特定点的全部历史才能理解它的作用。
核心思想:绑定不是赋值。仅仅通过思考不变性这个想法,我们就可以获得许多好处。它在各种语言中都有体现。
第1讲:序幕
这是我们开始的地方。我们从一个承诺开始。我向你介绍了类型规则,介绍了标准 ML,并向你做出了一些承诺。我不是一个骗子。我做了几个承诺。我想确保你觉得我遵守了它们。
首先,我谈到了这三个论点,我在整个课程中定期回顾它们。现在我们已经知道了我们所知道的,拥有了我们拥有的所有信息,让我们来谈谈它们。我还向你提出了这个主张:函数式编程不过是对我们编程能力的改进。它是我们沟通能力的精炼,因为编程就是沟通。
所以,把我当作你编程的关系教练。我们可以更好地沟通。那么,我是如何兑现这些承诺的?我是如何兑现我的铺垫、我的思考、我的预览的?
课程主题
在第一节课上,我向你提出了这个问题:什么是编程?什么是好的编程?好的编程应该是什么?有三个要点。编程应该是描述性的、模块化的、可维护的。
描述性:我给了你 goto 的例子,这是完全不可读、非描述性的代码。当我们看带有到处乱指的指针的意大利面条代码时,简直是胡说八道。我们已经看到了许多描述性代码的例子。我们看到了代码应该做什么的形式化数学规范。我们看到了代码应该做什么的图片。这就是描述性。我们看到了描述代码部分的接口,它们描述了行为,因为类型是什么?不过是对程序可能做什么的描述。所以,给我们的代码这些不变式可以帮助我们编写描述性代码,而像代数数据类型、参数多态性、高阶函数这样的东西使我们能够表达编程中如此多不同的问题。我们不必处理那些愚蠢的编码技巧,比如函数指针,来近似实现我们功能的一小部分。
模块化:你大概猜到了我会和你谈论字面意义上的模块。它们是我们可以混合搭配并通过其接口(仅仅是接口)查看的软件组件。把自己分成两个人:实现者和使用者。作为使用者,你不知道内部发生了什么,这很好。你因此变得更强大。例如,对于集合,内部的实现我不在乎。隐藏它,使其成为不透明的描述。这使我们的代码模块化,因为当我重构一个不透明描述的模块时,程序中其他任何东西都不需要改变。我只改变这个局部部分。强调类型也会导致模块化的代码。如果我有一个表达式和一个值,我有一个非常具体的类型。也许它是一个更大类型的实例,但通常非常具体。
可维护性:这是一门关于更好编程的课程,也是一门关于为大多数人(至少你们中的大多数人)编写软件的原型人的课程。如果你不编写可维护的代码,和你一起写代码的人会恨你,你也会恨自己。不要恨自己。可维护的代码,拥有扩展等价性(或者我称之为重构引理),意味着我们可以用相等替换相等,这是一个超能力。能够做到这一点是最强大的事情。但如果你不能做到,你就完蛋了。此外,拥有类型有助于可维护的代码,因为如果我在重构代码,我有一个 int 而我意外地把它变成了 int tree,我将无法运行我的代码,编译器可能会对我大喊大叫。函数式代码不仅仅是好看,如果你有简洁易懂的代码(我认为我们迄今为止已经看到了),它将导致更高的可维护性。你必须先理解代码,然后才能维护它。
三个论点
那么这三个论点呢?递归问题,递归解决方案。不要让递归成为怪物。不要让它成为床底下的妖精。你不需要害怕它。我们整个学期都在处理递归。我们是专业人士。所以不要害怕它。递归不是用来害怕的,而是用来使用的。它是一个工具。让它为你工作。我不是说到处都用它,但它出现的地方比你想象的要多。如果你用规范和不变式思考,递归就是第二天性。与代码一起编程,而不是对抗它。树状结构和链表之类的东西,它们天生就是递归的,这是一件好事。
编程思维就是数学思维:我在第一天就告诉过你们,计算机科学家首先是数学家。所以,激发你内心的数学家。在你编写任何代码之前,你必须确信它能工作,你必须确信你能解决问题。数学恰好是关于解决问题的。工作和跨度、归纳法、扩展等价性、形式化规范,所有这些都让你以更数学化的方式思考代码。所以,这样做,更好地理解问题,更好地解决问题。
类型指导结构:我实际上认为这可能是整个学期最重要的一点。让类型指导你的思想,闭上眼睛,在睡觉时看到类型不匹配。类型是基础,是软件构建的蓝图。你不可能不先建造房屋的结构就建造房屋。所有这些东西最终都通过类型来编纂:数据的构造、销毁、相互作用。柯里化、高阶函数、CPS、惰性、多态性,所有这些都源于“如果我们的类型中有 X、Y、Z 小部件会怎样”的想法,然后付诸实践。我们的代码因此变得更好。让类型指导结构。让类型成为你的精神动物。
除了这些,我还有几个反复出现的说法。
通过变得愚蠢来变得聪明:我觉得函数式编程名声不好,因为人们会说,哦,这些人太聪明了,他们太理论化了,他们是教授等等。有些人甚至可能认为我喜欢函数式编程是因为我聪明,我喜欢类型是因为我聪明。我喜欢类型是因为我真的很笨,因为如果没有类型作为我的安全网来指导我,我写的代码 90% 都会是垃圾。我喜欢函数式编程不是因为它聪明或酷,而是因为它可靠,因为它能以可理解的方式完成工作。类型检查器在第一天是你的敌人,现在是你最好的朋友。它让我免于犯愚蠢的错误。这就是我需要的全部。学习编程,就像你现在是学生一样,你正在学习各种东西,学习魔法、CPS、量子计算,都很酷很棒,但你必须意识到,那是为了学校,我不想剥夺它,但学习如何良好编程对我来说意味着学习如何抑制那种“哦,这太聪明了,太酷了”的冲动。因为猜怎么着?聪明并不可维护。没有人想读你那聪明的代码。给我可读的代码。编写能自我表达的代码。编写简单、可表达并能完成工作的代码,因为这样你就能完成工作。
防御自己:这是一门关于学习如何良好编程的课程。我知道你知道如何编程,但我希望现在你觉得你学会了如何更好地编程。学会与自己作为一个程序员(也希望作为一个人)共存。我们常常是自己编程时最大的敌人,因为我们写了些东西,后来回来看,心想,这他妈是谁写的?哦,提交者是七天前的 Brandon Wu。哦,好吧,真糟糕。不要在你的代码上留下陷阱。不要把工作推给未来。为你自己和你一起工作的人,尽可能让事情变得简单。这是学习如何良好编程的第一步,防御你自己。
我们从事的是编写正确代码的业务:如果你编写不正确的代码,你什么也没写。你哪儿也没去。你只是在原地踏步了 20 分钟。有些人从事编写代码的业务,他们只是为了产出大量代码。我不想与此有任何关系,但如果你想做一些事情,想写一些重要的东西,或者人们会喜欢的东西,写正确的代码符合你的最大利益。关于规范的推理、类型安全、扩展等价性,所有这些都只是朝着减少错误迈出的额外步骤,而这门课的论点就是:减少错误。
做得更好:我们在这里是因为你知道如何编程,但你需要被塑造成型。这是《花木兰》里的训练场景。我们像森林一样平静,但我们必须像火一样燃烧。关键是自我提升。首先,你必须对提升持开放态度。所以我们把你分解成你的组成原子,然后通过 CPS、DPLL 等作业,在整个课程中把你重新组合起来,这样我们就能提高我们的编程能力。这就是我们想要的。知道如何做某事很酷,但知道如何真正擅长某事也很酷。例如,征服用例:与其一遍又一遍地重写函数,不如使用高阶函数;与其在运行时遇到错误,不如在编译时捕获它们;与其使用冗余的表示,不如使用恰好适合你问题的代数数据类型,就像手套一样。力量在你手中,只取决于你如何使用它。
关于部落主义的临别赠言
我夹克背面写着“函数是值”,这是我们课程的模式,函数是一等值。这是一个重要的想法,一个好想法。在池塘的另一边,我们有我们的好朋友,他们认为函数是指针。我不需要告诉你这是一个争论点。
我的临别赠言是:函数是整数。没人在乎。我认为函数是值,我认为你学会这种方式非常重要。但语言攻击别人是不对的,把世界分成两个部落阵营然后说这些人是好的那些人是坏的,这是不对的。我在这门课里这样做通常是为了喜剧效果,但这并不重要,因为这些事情是情境性的。函数式编程,我喜欢它,但它对某些事情有好处,对我感兴趣的事情有好处。它对其他事情不那么好。
所以这是关于同理心的说明。要有同理心。语言攻击是不对的,根据别人的范式来评判他们是不对的。想要教育别人、教别人是可以的,但不要试图做出那种评判。我想说这个是因为我经常看到这种情况。在学校里,你们就像孩子。但我想让你们知道这一点。SML 是一个工具。它有很好的用例,我教这门课非常重要。但不要为此做个混蛋。要有同理心。这就是我想表达的全部。这不是一个明确定义的东西,不值得浪费你的精力去定义。他们是圈内人,我们是圈外人,我们知道得更好。不,世界可以变得更函数式一些。我同意,但这是一个光谱。它从来不是二元的。
函数式编程,正如我在第一节课所说,正是我所说的。它是一种心态,一种习惯,一种风格,一种范式。这只是你如何使用它的问题。从这里开始,你所做的一切,你上的每一门课,你都可以函数式地思考。这并不意味着你需要像我一样用 SML 做 15-451。它可能只是意味着你多思考一点安全性,多思考一点规范,在开始编码之前多思考一点。这也是一个非常重要的细节。
安全性、简单性、表达力:我认为这些是我们本学期一直在使用的非常重要的想法。我向你保证,函数式编程是一种提高我们编程能力的方式。我看到了这一点,我全心全意地相信。我希望你也能以某种方式看到它,因为编程只是一种语言现象,只是沟通。函数式编程只是关于我们如何使这种沟通变得更好。
我要从 Michael Erdman 那里借用另一件事:代码可以是艺术。代码可以是美丽的。代码可以是富有表现力的。代码有时比你自己用语言更能解释一个想法。代码可以改变你的思维方式。我希望这门课改变了你的思维方式。所以这是你余生的第一章,正如我向你承诺的那样。但知道了你所知道的,你无法回头。这就是我在第一天打算做的。正如 Robert Harper 喜欢说的,我打算毁了你,永远地毁了你。现在门关上了。这是一扇单向门。你永远无法回头。因为你永远是一个函数式程序员。
致谢与告别
这门课是我过去六个月(实际上,在那之前是四年)生活的爱的劳动。如果不以某种形式说再见,我会觉得不对劲。但有一件事我需要做,我有个小规则:每次我讲一个不是我自己想的笑话时,我必须注明出处。我不被允许讲不是我自己想的笑话,因为让人们认为我比实际上更有趣是不公平的。
教学不是一个人在这里做所有事情。这是我与所有人合作的共同努力,我的工作人员,以及我多年来与之一起教学和被教导的人们。所以很多人在这门课中露面了,尽管你不知道他们的名字,但他们在这里。名单很长,但我要试试。
我教这门课很多年了,自从大二开始,基本上三年半了。通过 150 的岁月,我遇到了我视为朋友、导师的人,以及我会认识一辈子的人。他们每次我教学时都影响着我。所以名单是:所有我曾经与之一起教学的人。因为无论大小,他们都在某种程度上出现在这门课中。名单很长,但他们对你本学期所看到的一切产生的影响并不小。有些人,我可以特别点名感谢,但他们知道他们是谁。这是我的助教们,他们关心你们,始终把你们的利益放在心上。他们在幕后做了很多工作,你们不知道工作量有多大。还有我的朋友们,他们在这个夏天支持我,让我保持理智,我真的很感谢他们。Dilson Kear,我们可爱的教师导师,他在这个学期给了我很多建议。还有 S,他们不仅同意我来这里教学,基本上是从工作中请假,而且还给了我一些金币。T恤还没到,抱歉。本来应该到的,但晚了。
最后,对特定人物的致谢:Mike Erdman,他教会我,即使只有他的一点点同情心也太多了。他是我见过的最有同情心的人之一。Bob Harper,他向我展示了有激情是可以的,也是强大的,在教室里这样做是正确的。Jacob Newman,没有他,这些幻灯片根本不会存在。Anil Ada,他教我交替评分方案和考试中让人画东西的小框的想法。Ryan O'Donnell,他教我如何有风格地开始一堂课。Mona Har'el Baltar,他教我上课时发糖果有多好。Suhail Kakar,他向我展示了海獭的力量。Brian Maing,他向我展示了课堂练习有多重要。Pat Virtue,他向我展示了在教学中考虑个体是可能的。
我讨厌告别。我二月离开公司时,我们都在公交车旁告别,我当时想,我要去那边的树林里站着,没人能看到我。但现在我吸引了你们所有人的注意力。我该怎么办?我讨厌告别。好吧。
我从来都不擅长说再见。现在,我要脱下这件夹克。因为。我很高兴。我很高兴我有这个机会。但现在我们都是 150 的校友了,只是方式不同。我可能再也不会教书了,但这没关系,因为我被给予了这次生命租赁来做这件事,因为这是我在这个时刻能做的最重要的事情。尽管我喜欢抱怨你们的邮件,抱怨处理一切,但这是值得的,因为这个想法值得教授。你们一直是值得教授的东西。
我讨厌告别,我无法表达自己,只能用愤怒的方式,所以让我们喊出来吧。让我们喊出来。值得教授的东西,这就是这门课的意义。我的时间即将结束,这没关系。在第一节课上我说了一些话,我要为你们最后一次说出来:我爱函数式编程,你不必说,因为我在第一节课上意识到这无关紧要,我现在意识到没有什么比这更重要了,因为我爱函数式编程,愿意来到这里,打乱我的生活,愿意现在就在这里。我爱函数式编程,这意味着这是值得热爱的东西,这是值得教授的东西。
所以我希望,对你们来说,这门课一直是值得学习的东西,也是值得教授的东西。
非常感谢你们。你们还有期末考试。但谢谢你们来。这就是结束。请拿一些海獭,如果你们愿意的话,请保持联系。

总结
在本节课中,我们一起回顾了整个学期的学习旅程,从程序分析、编译器、命令式编程,到惰性计算、序列、红黑树、函子、模块、正则表达式、异常、CPS、组合子、高阶函数、多态性、排序与并行性、运行时分析、树、结构归纳、归纳与递归,以及课程的基础。我们总结了课程的核心主题:编程应该是描述性、模块化和可维护的;以及三个核心论点:递归问题递归解决、编程思维即数学思维、让类型指导结构。最后,我们探讨了“值得教授的东西”这一深刻主题,并以此作为对课程、对 CMU、对 150 的告别。希望这门课不仅教会了你函数式编程,更激发了你的热情,帮助你找到了自己“值得教授”的使命。

浙公网安备 33010602011771号