Scheme:把括号当成求值规则来读
换一门语言,不是为了多会一种语法,而是为了看清「一个表达式被求值时到底发生了什么」——因为接下来你要亲手把这套规则实现出来。
0. 本讲导读
到这一讲为止,你在 Python 里已经走完了一条很长的路:函数、递归、数据抽象、可变数据、面向对象、链表与树、迭代器与生成器、复杂度分析。你能写出相当复杂的程序了。
然后这一讲突然换了一门语言。这看起来像是从头再来,其实不是。这门课从第一讲起就在讲同一件事:一个表达式被求值时到底发生了什么。Python 的语法太厚了——它有语句和表达式的区别、有缩进决定的代码块、有中缀运算符和优先级、有几十个关键字。这层厚厚的语法把求值规则遮住了一半。Scheme 把这层遮挡几乎全部拿掉:它只有表达式,只有一种写法「左括号 + 若干子表达式 + 右括号」,没有运算符优先级,没有缩进规则。剩下的就是赤裸裸的求值过程本身。
换语言还有一个更实际的理由:这门课的最后一个项目,是用 Python 写一个 Scheme 解释器。也就是说,你要写一个程序,它读进 (+ 1 (* 2 3)) 这样的文本,然后按规则把它算成 7。要写出这个程序,你必须先能精确地说出规则是什么——不是「大概是从里往外算」,而是「拿到一个组合式之后,第一步做什么、第二步做什么、什么时候新建一帧、这帧的 parent 是谁」。这一讲就是在给你那份规则说明书。
课程幻灯片还提到一件事:CS 61A 在 2011 年之前是完全用 Scheme 讲的。Scheme 是 Lisp 的一个方言,诞生于 1970 年代,今天已经很少有人用它写生产代码了。但它的影响散落在你用过的每一门语言里——Python 的 lambda、一等函数、闭包,源头都在这儿。
幻灯片给出的另一个视角是编程范式(programming paradigm),也就是「写代码的风格与思路」:
| 语言 | 范式 | 你告诉计算机什么 |
|---|---|---|
| Python | 多范式:命令式(imperative) + 面向对象(object-oriented) | 一步一步的指令;以及把代码组织成类 |
| Scheme | 函数式(functional) | 一切都是函数(过程)与表达式 |
| SQL | 声明式(declarative) | 只说最终结果长什么样,不说怎么算 |
这一讲讲 Scheme 的原始值、组合式、内建过程、特殊形式,最后用两道递归题(summation 和 sum-even)把它们串起来。下一讲会讲 Scheme 的列表,Lab 09 是本讲和下一讲的配套练习。
- Scheme 里一切都是表达式,每个表达式求值成一个值。没有「语句」这个概念。
- 表达式只有两类:原始表达式(primitive / atomic expression)和组合式(combination)。组合式就是一对括号里放若干子表达式。
- 原始值里只有 symbol 不是自求值的——它要去环境里查绑定。数字、字符串、布尔值、
nil都求值成自己。 - 组合式又分两种:调用表达式(call expression)和特殊形式(special form)。判据是看第一个子表达式:如果它是
define、if、lambda、let、cond、and、or、begin这些关键字之一,就按特殊规则算;否则就是普通调用。 - 调用表达式的求值规则和 Python 完全一样:先求值所有子表达式(算子和全部算子数),再把过程施加到实参上。特殊形式的存在意义恰恰是不遵守这条规则——
if不能把两个分支都算出来。 - Scheme 里只有
#f是假的。0、""、nil全都是真值。这跟 Python 完全不同,是本讲最容易翻车的地方。 - 所有运算符都是前缀(prefix)的:写
(+ 1 2),不写1 + 2。空白与缩进完全不影响语义,只用来分隔符号。
1. 一切都是表达式:Scheme 的原始值
幻灯片第一句话就把整门语言的骨架说完了:Everything in Scheme is an expression which evaluates to some value. 每一样东西都是表达式,每个表达式都求值成一个值。
这句话在 Python 里是不成立的。Python 里 x = 5 是一条语句(statement),它被执行,不产生值;def f(): ... 也是语句;while、if、return 全是语句。Scheme 里没有这个分类——(define x 5) 本身就是个表达式,它也有返回值(返回符号 x)。
#t/#f、空表 nil、以及 symbol。最后一行是关键:symbol 是唯一不自求值的原始表达式——它求值成「你之前在环境里给它绑的那个值」。五类原始值
| 类别 | 写法 | Python 里的对应物 | 求值成什么 |
|---|---|---|---|
| 数字(number) | 5、5.0、-6 | int / float | 自己 |
| 字符串(string) | "example",只能双引号 | str | 自己 |
| 布尔值(boolean) | #t 或 true;#f 或 false | True / False | 自己 |
| 空表(nil) | nil | 类似 Link.empty | 自己,显示为 () |
| 符号(symbol) | x、square、sum-even | 变量名 | 去环境里查它绑的值 |
「自求值(self-evaluating)」这个词值得停一下。5 求值成 5,"goodbye" 求值成 "goodbye"——这些表达式不需要查任何环境,看一眼就知道值是什么。symbol 不行:x 到底是几,取决于当前环境里 x 绑到了什么。这跟 Python 里名字查找是同一件事,也是「环境」这个概念在 Scheme 里唯一的用武之地。
scm> ; 分号开头是注释,一直到行尾
scm> true
#t
scm> nil
()
scm> 5
5
scm> "goodbye"
"goodbye"
注意第二行:你输入的是 true,解释器回显的是 #t。这说明 true 和 #t 是同一个值的两种写法,而解释器统一用 #t 打印。nil 也一样,打印出来是 ()——一对空括号,因为 Scheme 的空列表在概念上就是「什么都没装的那个组合」。
类型判断谓词
Scheme 里判断类型的过程,名字通常以 ? 结尾。这是一个纯粹的命名约定(? 在 Scheme 里是合法的标识符字符),读起来像在问问题:
scm> (atom? 5.0)
#t
scm> (integer? 5.0)
#t
scm> (integer? 5.5)
#f
atom? 问的是「这是不是一个原子(不可再分的原始值)」——数字、布尔、符号、nil 都是原子,只有非空列表不是。integer? 问的是「这个数是不是整数值」。注意 (integer? 5.0) 是 #t:5.0 虽然写成浮点样子,但它的数值是整数,所以答 #t。5.5 就不是了。
Scheme 的标识符允许用 -、?、!、> 这些在 Python 里非法的字符。于是形成了一套很好读的约定:? 结尾表示返回布尔值的谓词(null?、even?、equal?),! 结尾表示会改动数据的过程(下一讲会见到 set-car!)。多词名字用连字符连接:sum-even、add-one——这在 Python 里会被读成减法,在 Scheme 里就是一个普通名字。
Scheme 的字符串只认双引号。习惯了 Python 里 'hello' 和 "hello" 通用,到这里写单引号会出大问题——因为单引号 ' 在 Scheme 里是另一个完全不同的语法(引用,下一讲讲),不是字符串定界符。
另一个坑:Scheme 的 nil 不是 Python 的 None。None 表示「没有值」,nil 表示「空的列表」,更接近你在链表那一讲里写的 Link.empty。真正对应 None 的东西在 Scheme 里叫 undefined,是某些表达式(比如没有 else 分支且条件为假的 if)的返回值,通常不会被打印出来。
2. 组合式:括号里发生了什么
幻灯片的定义很干脆:任何不是原始表达式的表达式都叫组合式(combination)。而组合式只有两种:
- 调用表达式(call expression)——调用一个过程;
- 特殊形式(special form)——按特殊规则求值(第 4 节起细讲)。
形式上,组合式就是「一对括号,里面放一个或多个子表达式」。跟 Python 最大的差别是:Scheme 里所有的过程/运算符都是前缀的。
Python: 1 + 2 max(1, 2, 3) (x - y) * 2
Scheme: (+ 1 2) (max 1 2 3) (* (- x y) 2)
为什么要这么写?因为这样一来,「运算」和「函数调用」就不再是两回事了。Python 里 1 + 2 和 add(1, 2) 长得完全不同,你得分别记住中缀运算符的优先级表(* 比 + 紧、** 右结合……)。Scheme 里没有优先级这回事——括号已经把结构写死了,(* (+ 1 2) 3) 和 (+ 1 (* 2 3)) 一眼就能看出谁先算。整门语言的语法规则短到可以写在一张便签上。
调用表达式的求值规则
幻灯片明确说了:Evaluating a call expression in Scheme is the same as in Python. 也就是第一讲那三步,一字不改:
求值组合式 (<operator> <operand> ...):
子表达式本身如果还是组合式,就在第 1、2 步里递归地用同一套规则求值。这就是为什么求值天然是「从里往外」的:外层要先拿到内层的值才能动手。
拿幻灯片上的那个例子走一遍。设环境里 x 是 1、y 是 3:
(modulo (* (- y x) (expt (+ x 1) y)) 4) 被化简三次。每一步都是「找到一个所有算子数都已经是值的最内层组合式,把它换成它的值」。注意最外层的 modulo 一直等到最后才动手——它必须先拿到 16 才能算。完整表达式:(modulo (* (- y x) (expt (+ x 1) y)) 4),其中 x → 1、y → 3。
modulo,查环境得到内建过程 #[modulo]。它有两个算子数:(* ...) 和 4。(* (- y x) (expt (+ x 1) y))。它自己又是组合式,算子 *,两个算子数都还是组合式,继续往里。(- y x):算子 -;算子数 y 是 symbol,查到 3;x 查到 1。施加得 3 - 1 = 2。(expt (+ x 1) y):先算它的算子数 (+ x 1),x 是 1,1 + 1 = 2;再算 y,是 3。于是 (expt 2 3) = 2³ = 8。(* 2 8) = 16。modulo 的第二个算子数 4,是数字,自求值成 4。(modulo 16 4) = 0。这里最值得记住的一句话是:「从里往外算」不是一条独立的规则,而是那三步规则的必然后果。因为第 3 步(施加)需要实参的值,而值只能靠第 2 步递归地把内层算完才拿得到。你在第一讲画表达式树时用的是同一套逻辑,只是当时表达式外面套着 Python 的中缀语法,没有 Scheme 这么显眼。
写惯 Python 的人最常犯的错是把中缀写法带进来:
scm> (1 + 2)
Error: int is not callable: 1
这个报错信息非常有教育意义。解释器并没有说「语法错误」,因为 (1 + 2) 语法上完全合法:它是一个有三个子表达式的组合式。解释器老老实实执行规则——求值算子 1,得到整数 1;然后想把 1 当过程施加到 + 和 2 上,这才发现 1 不是个能被调用的东西。
同理,多写一层括号也会炸:((+ 1 2)) 会先算出 (+ 1 2) = 3,再试图调用 3,报同样的错。在 Scheme 里,括号从来不是「为了看得清楚」而加的——每一对括号都意味着一次调用。
空白与缩进:完全不重要
Python 靠缩进划分代码块,缩进错了程序含义就变了。Scheme 完全相反:空白字符只用来分隔符号,除此之外没有任何语义。下面两段是同一个表达式:
scm> (+
...> 0
...> 1
...> 2
...> )
3
scm> (+ 0 1 2)
3
...> 是解释器的续行提示符:它数到括号还没配平,就知道你这句话没说完,于是等你继续输入。
「缩进不重要」听上去很自由,实际上是个陷阱——因为结构完全由括号决定,而括号一多,人眼就看不清了。幻灯片给的三条建议是:用自动格式化工具(code.cs61a.org 上有一个按钮)、装带括号配色和 Scheme 语法高亮的编辑器插件、以及自己老老实实按层次缩进。这不是审美问题:Scheme 里最高频的 bug 就是括号多一个或少一个,而缩进是你唯一的肉眼校验手段。
Scheme 有很多方言(dialect),彼此在细节上不一致。这门课以官方的 CS 61A Scheme Specification 和 Scheme Built-In Procedure Reference 为准。这两篇文档里标记为 out of scope 的内容(promises、streams、turtle graphics 等)不在考察范围内。遇到本讲没讲过的过程,去查这两份文档,不要凭「别的 Scheme 是这么写的」猜。
3. 内建过程:算术、输出与相等
算术
scm> (+ 1 2)
3
scm> (+)
0
scm> (/ 32 2 2)
8
scm> (quotient 32 2)
16
scm> (quotient 32 2 2)
Error
scm> (expt 2 3)
8
scm> (modulo 15 4)
3
scm> (abs -6)
6
这一屏里藏着三个真正的知识点,别扫过去。
第一:+、*、/、- 接受任意多个参数。(+ 1 2 3 4) 是合法的,(/ 32 2 2) 意思是「32 除以 2 再除以 2」,从左往右折叠,得 8。这是前缀写法白送的好处:中缀的 + 天生只能连接两个东西,前缀的 + 后面想跟几个跟几个。
第二:(+) 是 0,(*) 是 1。零个参数也合法,返回的是这个运算的单位元(identity)——加法加 0 不变,乘法乘 1 不变。这不是为了炫技,是为了让「把一串数字加起来」这件事在列表为空时也有意义。
第三:quotient 只接受两个参数。这就是 (quotient 32 2 2) 报 Error 的原因。用课程的 Scheme 解释器跑一下,报错信息长这样:
scm> (quotient 32 2 2)
Error: incorrect number of arguments: #[quotient]
#[quotient] 是解释器打印内建过程的方式。看到这种报错,要读出的信息是「我给的参数个数不对」,而不是「这个函数不存在」。
/ 不是整数除法。(/ 7 2) 得到 3.5,跟 Python 的 / 一致。要取整数商用 quotient(对应 Python 的 //),取余数用 modulo 或 remainder(对应 %)。
负数上要小心:(quotient -7 3) 是 -2(向零截断),而 Python 的 -7 // 3 是 -3(向下取整);(modulo -7 3) 是 2,跟 Python 的 -7 % 3 一致。本讲的题目只涉及非负数,但这个差异值得知道。
输出:display / displayln / print
Scheme 有三个打印相关的过程,差别在于要不要换行和字符串要不要带引号:
scm> (display 5)
5scm> (displayln 5)
5
scm> (displayln "hello world")
hello world
scm> (print 5)
5
scm> (print "hello world")
"hello world"
第一行的输出看起来很怪:5scm>。这不是 bug——display 打完 5 不换行,所以紧接着的下一个提示符 scm> 就挤在同一行了。这个细节非常能说明问题:你看到的每一个字符都是某个具体动作打出来的,没有什么是「自动」发生的。
| 过程 | 末尾换行 | 字符串带引号 | 最接近的 Python 写法 |
|---|---|---|---|
(display x) | 否 | 否 | print(x, end='') |
(displayln x) | 是 | 否 | print(x) |
(print x) | 是 | 是 | print(repr(x)) |
换句话说,print 打的是这个值在 Scheme 源码里写出来的样子(所以字符串带引号),displayln 打的是给人看的样子。这跟 Python 里 repr 与 str 的分工是同一回事。
相等:= / eq? / equal?
scm> (= 10 10)
#t
scm> (eq? 10 5)
#f
scm> (equal? 10 10)
#t
三个都在比「相等」,但幻灯片上留了一句提醒:Each expression technically does different things——它们的语义确实不一样,后面的课会展开。现在你需要记住的分工是:
| 过程 | 比什么 | 能用在什么上 |
|---|---|---|
= | 数值相等 | 只能比数字。比字符串会报错。 |
eq? | 「是不是同一个东西」(身份) | 符号、布尔、数字之类的原子。类似 Python 的 is。 |
equal? | 「长得一不一样」(结构) | 任何值,包括字符串和列表;会递归比较里面的每一项。类似 Python 的 ==。 |
用课程解释器实测一组能把差别显出来的例子:
scm> (eq? "hi" "hi")
#f
scm> (equal? "hi" "hi")
#t
两个 "hi" 内容一样,但它们是内存里两个不同的字符串对象,所以 eq? 答 #f、equal? 答 #t。这跟 Python 里 is 与 == 的区别是同一个故事。
写代码时的选择法则很简单:比数字用 =,比别的(尤其是列表)用 equal?。eq? 留给你确实想问「是不是同一个对象」的时候。本讲两道练习题里的所有比较都是数字,所以一律用 =。
另外还有一批很常用的数字谓词,写题时几乎每次都会用到:(even? n)、(odd? n)、(zero? n)、(< a b)、(> a b)、(<= a b)、(>= a b)。注意 < 和 > 也是前缀的:「x 大于 3」写作 (> x 3)。
4. 特殊形式:不守规矩的那些组合式
前面说调用表达式的规则是「先把所有子表达式都求值,再施加」。现在问一个问题:如果 if 也按这条规则走,会发生什么?
(if (= n 0) 1 (/ 100 n))
按调用规则,解释器会先把 (= n 0)、1、(/ 100 n) 三个都算出来。当 n 是 0 时,(/ 100 0) 直接除零崩溃——尽管我们本来就是想在 n 为 0 时避开这个除法。条件分支的全部意义就在于「有些子表达式不该被求值」,所以它必然不能遵守调用表达式的规则。
这就是特殊形式(special form)存在的理由。幻灯片的定义是:组合式中第一个子表达式匹配某个特定符号集合时,就用特殊规则求值。它类比的是 Python 里的关键字(keyword)——if、else、for、while、and 在 Python 里也是「不能当普通函数用」的东西。
解释器拿到一个组合式后,第一件事是看开头的那个符号:
- 如果它是
define/lambda/let/if/cond/and/or/begin之一 → 走这个特殊形式自己的规则,子表达式什么时候求值、求不求值,由它自己说了算。 - 否则 → 走调用表达式的三步规则,所有子表达式都要求值。
你在最后那个项目里要写的 scheme_eval,主干就是这个判断。
后面几节的语法说明沿用幻灯片的记号:<x> 表示必需的部分,[x] 表示可选的部分,... 表示前面那样东西可以出现多次。
define:绑定名字
define 有两种形态。第一种绑定一个值:
(define <name> <expression>)
scm> (define x 5)
x
scm> x
5
scm> (define x (+ x 1))
x
scm> x
6
三件事要注意。
(1) define 的返回值是那个名字本身(一个 symbol)。所以你输入 (define x 5),解释器回显 x。Python 里 x = 5 是语句、没有值;Scheme 里它是个表达式、有值。
(2) <name> 不被求值。这正是它「特殊」的地方——如果按调用规则,第一个算子数 x 会被当成 symbol 去环境里查值,而此时 x 还没定义,直接报 unknown identifier。define 把 x 当作字面上的名字使用,只求值第二个子表达式。
(3) (define x (+ x 1)) 里的先后顺序:先在当前环境里求值 (+ x 1),此时 x 还是 5,得 6;然后才把名字 x 重新绑到 6。跟 Python 的 x = x + 1 完全一样:先算右边,再绑左边。
第二种形态定义过程:
(define (<name> [param] ...) <body> ...)
scm> (define (square x) (* x x))
square
scm> (square 2)
4
注意左边是 (square x)——名字和形参一起被括号包起来,长得跟一次调用一模一样。这个设计是有意的:定义的样子和使用的样子对上了。
<body> ... 说明函数体可以有多个表达式。它们按顺序求值,最后一个的值就是过程的返回值。Scheme 里没有 return 关键字,也不需要——函数体的最后一个表达式天然就是返回值。
(define (square x) (* x x)) 之后再求值 (square 2):
(square 2) 的开头符号是 square,不在特殊形式名单里 → 按调用表达式处理。square:symbol,去全局帧查,得到那个用户定义的过程。2:数字,自求值成 2。x 绑到 2。(* x x):x 在本帧查到 2,(* 2 2) = 4。函数体只有一个表达式,它的值就是返回值。Global frame
square ──→ [procedure square(x)] ; define 在全局帧里绑了这个名字
f1: square [parent=Global]
x ──→ 2
正在求值:(* x x) → 4
返回值 ──→ 4
这张图跟你在第一讲画 Python 函数调用时画的一模一样。语法换了,机制没换:调用 = 新建一帧 + 绑定形参 + 在新帧里执行函数体 + 新帧的 parent 是定义处的帧。
lambda:不带名字的过程
(lambda ([param] ...) <body> ...)
幻灯片强调:过程在 Scheme 里是一等值(first-class value),跟 Python 一样,可以当数据传来传去,可以做高阶函数的参数和返回值。lambda 造出一个没有名字的过程,直接放在算子位置就能调用:
scm> ((lambda (x y) (+ (* x x) (* y y))) 3 4)
25
外层这对括号常常把人看晕。拆开看:
(lambda (x y) (+ (* x x) (* y y)))。它不是符号 define/if/… 而是一个组合式,所以整体按调用表达式处理。lambda 特殊形式,它不求值函数体,只是造出一个过程对象(形参 x、y,函数体 (+ (* x x) (* y y)),parent 记为当前帧)。3 → 3,4 → 4。x → 3、y → 4;在这帧里算 (+ (* 3 3) (* 4 4)) = (+ 9 16) = 25。对照 Python:(lambda x, y: x*x + y*y)(3, 4)。结构完全一致,连「必须给 lambda 加外层括号才能调用」这一点都一样。
而且 define 的两种形态之间有一条明确的等价关系:
(define (square x) (* x x))
; 等价于
(define square (lambda (x) (* x x)))
这跟 Python 里 def square(x): return x*x 和 square = lambda x: x*x 的关系一样——但在 Scheme 里这条等价没有任何折扣:Python 的 lambda 只能装一个表达式,Scheme 的 lambda 函数体可以有任意多个表达式,能力和 define 完全相同。
把 define 的两种形态混着写:
scm> (define square (x) (* x x)) ; 错:把参数表单独写在外面
scm> (define (square) x (* x x)) ; 错:形参跑到函数体里去了
判断方法很机械:看 define 后面紧跟的那个东西是不是括号。是符号 → 第一种形态,后面只能再跟一个表达式;是括号 → 第二种形态,括号里第一个是过程名、其余是形参。
5. let:临时绑定与新建的那一帧
(let ([binding] ...) <body> ...)
其中每个 binding 都是 (<name> <expression>) 的形式。幻灯片把求值规则写得很细,值得逐句拆:
<expression>。注意是当前帧——此刻那些新名字还不存在。<name> 绑到第 1 步算出来的对应值。第 1 步和第 2 步的顺序至关重要:所有绑定表达式都在旧环境里算完,然后才一次性建帧。所以后面的 binding 看不到前面的 binding:(let ((a 1) (b (+ a 1))) b) 会报 unknown identifier: a(除非外层恰好有个 a,那它用的就是外层那个)。
let 里绑了 x 为 5、y 为 10,body 里三个表达式依次求值——先打印 5、再打印 10,最后 (+ x y) 的值 15 作为整个 let 的返回值。右边是关键:let 一结束,帧就没了,再问 x 和 y 直接 Error。scm> (let (
...> (x 5)
...> (y 10)
...> )
...> (print x)
...> (print y)
...> (+ x y)
...> )
5
10
15
scm> x
Error
scm> y
Error
用课程解释器实测,那个 Error 的完整样子是:
scm> x
Error: unknown identifier: x
「unknown identifier」——不认识这个名字。这跟 Python 的 NameError: name 'x' is not defined 是同一类错误,成因也一样:你在一个帧里绑的名字,出了那个帧就查不到了。
—— 求值 let 期间 ——
Global frame
(空)
f1: let [parent=Global]
x ──→ 5
y ──→ 10
依次求值 (print x) → 打印 5
(print y) → 打印 10
(+ x y) → 15 ←—— 最后一个,作为 let 的值
—— let 结束之后 ——
Global frame
(空) ←—— f1 已经用完,全局帧里从来没有过 x、y
let 干的事,你在 Python 里其实一直在用——只不过 Python 用函数调用来实现它。事实上 let 完全可以用 lambda 表达:
(let ((x 5) (y 10)) (+ x y))
; 等价于
((lambda (x y) (+ x y)) 5 10)
看出来了吗?let 就是「立刻调用一个匿名过程」的语法糖。「先在外面算好参数值、再新建一帧绑形参、再在新帧里跑函数体」——这不就是调用表达式的三步规则吗。所以 let 建的那一帧和函数调用建的帧,性质完全相同。
什么时候用 let?当一个中间结果在后面要用好几次的时候。比如要算 (* (+ a b) (+ a b)),写成
(let ((s (+ a b))) (* s s))
既少算一次加法,也把「这两处是同一个东西」的意图写明白了。Scheme 里没有 Python 那种「函数体中间随便写个赋值语句」的写法(虽然函数体内部可以嵌套 define),let 就是引入局部名字的标准手段。
6. 条件与逻辑:if、and/or/not、cond
if
(if <predicate> <consequent> [alternative])
规则:求值 <predicate>。若为真,求值并返回 <consequent>;否则求值并返回 [alternative]。如果这种情况下没有 alternative,返回值是 undefined——类似 Python 的 None。
关键在于「否则」二字:两个分支只有一个会被求值。这就是特殊形式的意义。
scm> (define x 5)
x
scm> (if (> x 3) (print "hello") (print "there"))
"hello"
scm> (if (> x 3) 5 6)
5
scm> (if (< x 3) 5)
scm>
最后一句:条件为假、又没写 alternative,返回 undefined,解释器什么都不打印,直接给下一个提示符。这跟 Python 里函数走到底没 return 返回 None、而交互式解释器不显示 None 是完全一样的现象。
还要注意第一个例子只打印了一次 "hello"——(print "there") 根本没被求值。如果 if 是普通调用,你会看到两行输出。
Scheme 里只有 #f 是假的。别的一切都是真的。包括 0、空字符串、nil:
scm> (if 0 "yes" "no")
"yes"
scm> (if nil "yes" "no")
"yes"
scm> (if "" "yes" "no")
"yes"
如果你带着 Python 的直觉写 (if n ...) 想表达「n 不为 0 时」,这段代码在 n 是 0 时照样会走进 consequent 分支——而且不报任何错,只是默默算出错误答案。这类 bug 最难查。
正确写法是把条件写明白:(if (= n 0) ... ...) 或 (if (> n 0) ... ...)。在 Scheme 里,条件位置永远写一个明确的谓词表达式,不要依赖真假性。
and、or、not
(and [test] ...)
(or [test] ...)
(not [test])
规则跟 Python 的短路逻辑几乎一字对应:
and:按顺序求值各个 test,返回第一个假值。若没有一个是假的,返回最后一个 test 的值。没有参数时返回#t。or:按顺序求值各个 test,返回第一个真值。若都不为真且没有更多 test 了,返回#f。not:跟 Python 的not一样,取反成#t/#f。
scm> (and)
#t
scm> (or)
#f
scm> (and (= 5 5) (even? 6))
#t
scm> (and (= 5 5) "blah")
"blah"
scm> (or (= 5 5) "blah")
#t
scm> (or "blah" (= 5 5))
"blah"
看第四行和第六行:返回的不是 #t/#f,而是某个 test 的原值。(and (= 5 5) "blah") 里第一个 test 为真,于是继续;第二个 test 是最后一个,直接把它的值 "blah" 返回。第六行 (or "blah" (= 5 5)) 里 "blah" 是真值(记住:只有 #f 假),于是立刻返回它,(= 5 5) 压根没被求值。
(and) 返回 #t、(or) 返回 #f 的道理,和 (+) 返回 0、(*) 返回 1 一样——那是各自运算的单位元。
and 和 or 必须是特殊形式,理由和 if 一样:短路的前提是「后面的表达式可以不求值」。(and (> n 0) (> (/ 100 n) 5)) 靠的就是 n 不为正时右边不执行。
cond
(cond <clause> ...)
每个 clause 是 (<test> [expression] ...),也可以写成 (else [expression] ...)。它对应 Python 的 if-elif-else:从上往下依次求值各个 test,第一个为真的 clause 生效,求值它后面的表达式并返回最后一个的值;其余 clause 全部跳过。
scm> (define n -3)
n
scm> (cond
...> ((< n 0) -1)
...> ((> n 0) 1)
...> (else 0)
...> )
-1
这里每个 clause 外面那层括号是必需的:((< n 0) -1) 是一个 clause,里面 (< n 0) 是 test、-1 是结果。所以你会看到连着两个左括号,这在 cond 里是正常的,不是打错了。
忘掉 clause 的外层括号,写成下面这样(n 仍是 -3,本该得到 -1):
scm> (cond (< n 0) -1 (> n 0) 1)
0
答案变成了 0,而且一个错都不报。为什么?解释器把 (< n 0) 整个当成第一个 clause:它的 test 是符号 <,求值得到一个过程对象——过程对象是真值——于是这个 clause 命中;它后面的表达式是 n 和 0,依次求值,返回最后一个,也就是 0。
这类「不报错但算错」的 bug 是 Scheme 里最难查的。写 cond 时把每个 clause 单独一行、括号对齐,是防它最有效的办法。
begin
(begin <expression> ...)
按顺序在当前环境里求值每个表达式,返回最后一个的值。它不新建帧——这是它和 let 的唯一区别。类比 Python,就是「把好几条语句依次执行」。
它存在的理由:if 的每个分支只能放一个表达式。想在一个分支里做好几件事,就得用 begin 把它们打包成一个表达式。
if 的两个分支各是一个 begin。a 是 5、b 是 10,所以 (> a b) 为假,走 alternative:依次打印 b(10)、a(5),最后 "hoo" 作为 begin 的值、也就是整个 if 的值被返回并显示。consequent 那个 begin 一个字都没执行。scm> (define a 5)
a
scm> (define b 10)
b
scm> (if (> a b)
...> (begin
...> (print a)
...> (print b)
...> "woo"
...> )
...> (begin
...> (print b)
...> (print a)
...> "hoo"
...> )
...> )
10
5
"hoo"
输出三行:10 和 5 是 print 打的,"hoo" 是整个表达式的返回值被交互式解释器显示出来的。区分「打印出来的」和「返回值被显示出来的」——这是第一讲就在强调的事,在这里又碰上了。
7. 练习一:summation
课上发的起始文件是 18.scm,第一题的注释是这样写的:
; Define a procedure summation which takes in 2 arguments
; n (a non-negative integer) and term (a procedure)
; and returns the summation of calling term on each
; integer from 0 to n, inclusive.
(define (summation n term)
; YOUR CODE HERE
)
题目到底要什么
翻成人话:给一个非负整数 n 和一个过程 term,把 term 作用在 0 到 n 的每一个整数上,再把这些结果加起来。用数学写就是
三个必须抠清楚的细节:
- 两端都含(inclusive):0 要算,n 也要算。所以一共有
n + 1项,不是 n 项。 term是个过程,不是数。这是一道高阶函数题——你要把参数当函数调用。n可以是 0。这时候只有一项(term 0)。这是最小的合法输入,也就是 base case 的位置。
起始文件给的测试用例把这些都覆盖到了:
(define (identity x) x)
(define (square x) (* x x))
(define (add-one x) (+ x 1))
(expect (summation 0 identity) 0) ; 只有一项:identity(0) = 0
(expect (summation 5 identity) 15) ; 0+1+2+3+4+5 = 15
(expect (summation 5 square) 55) ; 0+1+4+9+16+25 = 55
(expect (summation 3 add-one) 10) ; 1+2+3+4 = 10
expect 是 code.cs61a.org 那个在线编辑器提供的测试形式(点右上角红色试管按钮运行),不是标准 Scheme 过程。把 18.scm 直接喂给别的 Scheme 解释器,会得到 Error: unknown identifier: expect。想在本地验证,就把 expect 那几行删掉,直接调用 (summation 5 square) 看输出。
第四个用例最能说明「inclusive」的重要性:add-one 作用在 0、1、2、3 上分别是 1、2、3、4,加起来 10。要是漏掉了 k = 0 那一项,你会得到 9——而前三个用例照样能过,因为 identity(0) 和 square(0) 都恰好是 0,漏不漏都看不出来。这就是为什么第四个用例存在。
怎么想到的
Scheme 里没有 while,也没有可变的累加器变量可以随手改,所以「开一个 total = 0 然后循环加」这条路走不通。函数式语言里表达重复的唯一自然手段是递归,那就按递归的三件套来问:
(term n) 就得到答案了。缩小的方向是 n 减 1。(term 0),直接返回它。(+ (term n) (summation (- n 1) term))。特别注意第 3 步里 term 要原样传下去。这是新手最容易漏的一笔:递归调用变的只有 n,term 从头到尾是同一个过程。
代码逐行讲
(define (summation n term)
(if
(= n 0)
(term n)
(+
(term n)
(summation (- n 1) term)
)
)
)
| 行 | 在干什么 | 为什么是这样而不是别样 |
|---|---|---|
(define (summation n term) | 用 define 的第二种形态定义过程,两个形参 | 形参名跟题面一致,term 会被当过程用 |
(if | 整个函数体只有这一个表达式 | 必须用 if(特殊形式),否则递归调用在 base case 也会被求值,无限递归 |
(= n 0) | base case 的判据 | 用 = 因为比的是数字。不能写成 (if n ...)——0 在 Scheme 里是真值 |
(term n) | base case 的返回值 | n 为 0 时答案就是 (term 0),不是 0 |
(+ (term n) (summation (- n 1) term)) | 递归情形 | 当前项 + 剩下所有项的和;(- n 1) 让 n 严格变小,保证能走到 base case |
注意这里的排版:(if 后面换行写三个部分、(+ 后面换行写两个加数。前面说过空白完全不影响语义,这样排纯粹是为了让「哪个括号管哪一段」一眼可见。
验证:把 (summation 3 add-one) 真的展开
递归不能靠「感觉它对」,要展开到 base case 再逐层回代。
(summation 3 add-one)
n=3,(= 3 0) 为 #f → 走递归分支
= (+ (add-one 3) (summation 2 add-one))
= (+ 4 (summation 2 add-one))
(summation 2 add-one)
n=2,(= 2 0) 为 #f
= (+ (add-one 2) (summation 1 add-one))
= (+ 3 (summation 1 add-one))
(summation 1 add-one)
n=1,(= 1 0) 为 #f
= (+ (add-one 1) (summation 0 add-one))
= (+ 2 (summation 0 add-one))
(summation 0 add-one)
n=0,(= 0 0) 为 #t → base case!
= (add-one 0)
= 1
(summation 0 add-one) = 1 (summation 1 add-one) = (+ 2 1) = 3 (summation 2 add-one) = (+ 3 3) = 6 (summation 3 add-one) = (+ 4 6) = 10 ✓ 与 expect 一致
把递归调用期间的帧画出来,能看到「n 一层层变小、term 一层层不变」这件事:
Global frame
add-one ──→ [procedure add-one(x)]
summation ──→ [procedure summation(n, term)]
f1: summation [parent=Global]
n ──→ 3
term ──→ [procedure add-one] ; 指向全局那个 add-one
等待中:(+ 4 <f2 的返回值>)
f2: summation [parent=Global] ; parent 是 Global,不是 f1!
n ──→ 2
term ──→ [procedure add-one]
等待中:(+ 3 <f3 的返回值>)
f3: summation [parent=Global]
n ──→ 1
term ──→ [procedure add-one]
等待中:(+ 2 <f4 的返回值>)
f4: summation [parent=Global]
n ──→ 0
(= n 0) 为真 → 返回 (add-one 0) = 1 ←—— 触底
每一帧的 parent 都是 Global,而不是上一层调用帧。原因在第 4 节说过:新帧的 parent 是这个过程被定义时所在的帧,summation 定义在全局,所以每次调用建的帧都直接挂在 Global 下面。四个 n 各自待在各自的帧里,互不干扰——这就是递归为什么能工作。
常见误区
很多人下意识写 (if (= n 0) 0 ...),因为「空的和是 0」听起来天经地义。用课程解释器实测:
scm> (define (bad n term)
...> (if (= n 0) 0 (+ (term n) (bad (- n 1) term))))
bad
scm> (bad 3 add-one)
9
应该是 10,得到 9——正好差了 (add-one 0) = 1 这一项。因为 n 到 0 时函数直接返回 0,(term 0) 从来没被算过。而 (bad 5 identity) 和 (bad 5 square) 会给出 15 和 55,完全正确——所以你跑前三个测试会以为自己写对了。
要真想让 base case 返回 0,那就得把「触底」的位置往下挪一格:
(define (summation n term)
(if (< n 0)
0
(+ (term n) (summation (- n 1) term))))
这样 n = 0 时仍然走递归分支,算出 (+ (term 0) (summation -1 term)) = (+ (term 0) 0),四个用例全过。两种写法都对,但 base case 的条件和返回值必须配套。
scm> (define (bad2 n term)
...> (if (= n 0) (term n) (+ (term n) (bad2 (- n 1)))))
bad2
scm> (bad2 3 add-one)
Error: Incorrect number of arguments to function call
注意 define 那一步不报错——Scheme 不检查函数体里的调用参数个数,要等到真的调用时才发现。看到这个报错,第一反应应该是「我哪次调用少给/多给了参数」。
scm> (define (bad3 n term) (+ (term n) (bad3 (- n 1) term)))
bad3
scm> (bad3 3 add-one)
Error: maximum recursion depth exceeded
n 一路减到 -1、-2、-3……永远不停。这个报错等价于 Python 的 RecursionError: maximum recursion depth exceeded,含义都是「递归没有出口,或者出口永远碰不到」。
8. 练习二:sum-even
; Define a function sum-even which sums the digits
; at even positions in a positive integer n,
; where position 0 is the rightmost digit, position 1 is
; the digit to its left, etc.
(define (sum-even n)
; YOUR CODE HERE
)
(expect (sum-even 5) 5)
(expect (sum-even 10) 0)
(expect (sum-even 12321) 5) ; 1 + 3 + 1 = 5
(expect (sum-even 1112) 3) ; 2 + 1 = 3
题目到底要什么
「偶数位置上的数字之和」——先把「位置」定义清楚,这是整道题的关键:位置从右往左数,最右边那位是 0 号。
| n = 12321 | 1 | 2 | 3 | 2 | 1 |
|---|---|---|---|---|---|
| 位置 | 4 | 3 | 2 | 1 | 0 |
| 要不要加 | ✓ | — | ✓ | — | ✓ |
所以 (sum-even 12321) = 1 + 3 + 1 = 5。再看 1112:
| n = 1112 | 1 | 1 | 1 | 2 |
|---|---|---|---|---|
| 位置 | 3 | 2 | 1 | 0 |
| 要不要加 | — | ✓ | — | ✓ |
= 1 + 2 = 3。另外两个用例:(sum-even 5) 只有一位,位置 0,答案 5;(sum-even 10) 的两位是 1(位置 1)和 0(位置 0),只加位置 0 的 0,答案 0——这个用例专门用来抓「把位置数反了」的错,如果你从左往右数位置,就会得到 1。
怎么想到的
拆数字的老套路你在前面的课里已经用熟了:(modulo n 10) 拿到最右边那一位,(quotient n 10) 砍掉最右边那一位。反复做,就能从右往左把每一位都过一遍。
但这里冒出一个新麻烦:递归只带着 n 走,走着走着就不知道现在这一位是第几号了。
n = 1112,当前处理的是位置 0。n = 111,当前处理的是位置 1。n = 11,位置 2。但函数只看得见 n = 11,它没法从 11 推出「我现在在位置 2」——因为 (sum-even 11) 被直接调用时,11 的位置应该从 0 数起。这就是需要一个额外参数的典型信号:递归过程中有一个状态在变,而原函数的签名装不下它。解决办法是写一个带辅助参数的 helper,让 sum-even 只负责把初始状态填好、然后把活儿交给 helper。
「原函数签名不够用」→「定义一个多带几个参数的内部 helper,外层函数只做一次初始调用」,这是递归里最常用的一个套路,你在 Python 部分应该已经见过。Scheme 里同样支持在函数体内嵌套 define,而且嵌套定义的名字只在外层函数的那一帧里可见:
scm> (define (f) (define y 2) (+ y 1))
f
scm> (f)
3
scm> y
Error: unknown identifier: y
所以 helper 不会污染全局环境。
有了 position 参数,逻辑就清楚了,三种情况:
n已经被砍成 0 了 → 没有数字可加了,返回 0;position是偶数 → 把当前这位(modulo n 10)加上,再递归处理剩下的;- 否则(
position是奇数)→ 当前这位不要,直接递归处理剩下的。
三个分支——正好是 cond 的用武之地。
代码逐行讲
(define (sum-even n)
(define (helper n position)
(cond
((= n 0) 0)
((even? position) (+
(modulo n 10)
(helper (quotient n 10) (+ position 1))
)
)
(else (helper (quotient n 10) (+ position 1)))
)
)
(helper n 0)
)
| 片段 | 在干什么 | 为什么是这样 |
|---|---|---|
(define (helper n position)) | 在 sum-even 体内定义内部过程 | 多带一个 position;内部定义不污染全局 |
((= n 0) 0) | base case | 数字被砍光了,剩下的和是 0。这里返回 0 是对的——跟上一题不同,因为「没有数字」确实贡献 0 |
((even? position) ...) | 偶数位:加上当前位 | (modulo n 10) 取最右一位 |
(helper (quotient n 10) (+ position 1)) | 递归:砍掉最右位,位置号 +1 | 两个参数同时更新,这是 helper 存在的全部意义 |
(else (helper ...)) | 奇数位:跳过当前位 | 递归调用一模一样,只是不加 (modulo n 10) |
(helper n 0) | sum-even 函数体的最后一个表达式 | 从位置 0 起步。它的值就是 sum-even 的返回值 |
注意 sum-even 的函数体有两个表达式:先是那个 define(把 helper 装进当前帧),然后是 (helper n 0)。前面讲过函数体可以有多个表达式、返回最后一个的值——这里正好用上。如果把 (helper n 0) 漏掉,sum-even 就会返回 define 的值,也就是符号 helper,而不是一个数。
验证:把 (sum-even 1112) 真的展开
(sum-even 1112)
→ (helper 1112 0)
(helper 1112 0)
(= 1112 0)? #f
(even? 0)? #t → 加上这一位
= (+ (modulo 1112 10) (helper (quotient 1112 10) 1))
= (+ 2 (helper 111 1))
(helper 111 1)
(= 111 0)? #f
(even? 1)? #f → else 分支,跳过这一位
= (helper 11 2)
(helper 11 2)
(= 11 0)? #f
(even? 2)? #t → 加上这一位
= (+ (modulo 11 10) (helper (quotient 11 10) 3))
= (+ 1 (helper 1 3))
(helper 1 3)
(= 1 0)? #f
(even? 3)? #f → 跳过
= (helper 0 4)
(helper 0 4)
(= 0 0)? #t → base case
= 0
(helper 0 4) = 0 (helper 1 3) = 0 (helper 11 2) = (+ 1 0) = 1 (helper 111 1) = 1 (helper 1112 0) = (+ 2 1) = 3 ✓ 与 expect 一致
看回代那一列:(helper 111 1) 和 (helper 1 3) 这两层原封不动地把下层的值传上来,因为它们走的是 else 分支、不做加法。奇数位就是这样被「跳过」的——不是被跳过调用,而是被跳过累加。
再验一遍最容易错的 (sum-even 10):
(helper 10 0) → (even? 0) 真 → (+ (modulo 10 10) (helper 1 1))
= (+ 0 (helper 1 1))
(helper 1 1) → (even? 1) 假 → (helper 0 2)
(helper 0 2) → n = 0 → 0
回代: (helper 1 1) = 0;(helper 10 0) = (+ 0 0) = 0 ✓
另一种写法:一次砍两位
既然要的就是「隔一位取一个」,那干脆每次砍掉两位,就用不着 position 了:
(define (sum-even n)
(if (= n 0)
0
(+ (modulo n 10) (sum-even (quotient n 100)))))
(quotient n 100) 一次去掉最右边两位,于是每次递归看到的最右位,位置号都比上次大 2——奇偶性不变,永远是偶数位。四个用例实测全过(5 → 5,10 → 0,12321 → 5,1112 → 3)。
这个版本更短,但官方解答的 helper 版本更值得学:因为「位置」这个概念被显式写进了代码,改需求时(比如改成「奇数位求和」,只要把 (even? position) 改成 (odd? position))一眼就知道改哪里。而砍两位的版本要改成奇数位,得先想清楚初始该从哪一位开始砍——它把逻辑藏进了算术技巧里。
(1) 把位置从左往右数。题面写得很清楚「position 0 is the rightmost digit」。数反了的话 (sum-even 10) 会给出 1 而不是 0,其余用例可能碰巧还对。
(2) 用 (if n ...) 判 base case。Scheme 里 0 是真值,这个条件永远成立,于是无限递归,最后 Error: maximum recursion depth exceeded。必须写 (= n 0)。
(3) 把 (helper n 0) 写在 define 里面或者干脆忘了写。忘了写的话 (sum-even 1112) 会返回符号 helper——不报错,但测试当然过不了。
(4) 递归时忘了更新 position。写成 (helper (quotient n 10) position),位置号永远是 0,于是每一位都被加进去,(sum-even 1112) 得到 1+1+1+2 = 5。同样不报错。
9. Python ↔ Scheme 对照与思维切换
把两门语言并排放,能看清哪些是语法差异(换个写法而已),哪些是语义差异(真的会算出不同答案)。后者才是会咬人的。
| 做什么 | Python | Scheme | 差在哪 |
|---|---|---|---|
| 算术 | 1 + 2 * 3 | (+ 1 (* 2 3)) | 语法:前缀 + 无优先级 |
| 整除 / 取余 | a // b、a % b | (quotient a b)、(modulo a b) | 负数上 quotient 向零截断,// 向下取整 |
| 绑定名字 | x = 5(语句) | (define x 5)(表达式,值为 x) | 语义:Scheme 里连定义都是表达式 |
| 定义函数 | def f(x): return x*x | (define (f x) (* x x)) | Scheme 没有 return,函数体最后一个表达式即返回值 |
| 匿名函数 | lambda x: x*x | (lambda (x) (* x x)) | Scheme 的 lambda 体可以有多个表达式,Python 只能一个 |
| 条件 | if c: aelse: b | (if c a b) | Scheme 的 if 有值,更像 Python 的三元表达式 a if c else b |
| 多路分支 | if/elif/else | (cond (t1 e1) (t2 e2) (else e3)) | clause 要额外一层括号 |
| 局部名字 | 函数体里赋值 | (let ((x 5)) ...) | let 新建一帧,出了 body 就消失 |
| 顺序执行多件事 | 写多行语句 | (begin e1 e2 e3) | begin 不新建帧 |
| 真假 | falsy:False 0 0.0 None '' 空容器 | falsy:只有 #f | 语义差异,最容易翻车 |
| 相等 | == / is | =(数字)/ equal?(结构)/ eq?(身份) | Scheme 分得更细,= 只能比数字 |
| 空 | None(无值)/ [](空表) | undefined(无值)/ nil(空表) | nil ≠ None,且 nil 是真值 |
| 循环 | while / for | 没有——用递归 | 范式差异 |
| 代码块划分 | 缩进 | 括号 | Scheme 里缩进无语义 |
特殊形式速查
| 形式 | 语法 | 哪些子表达式会被求值 | 返回什么 |
|---|---|---|---|
define(值) | (define <name> <expr>) | 只有 <expr>;<name> 不求值 | 符号 <name> |
define(过程) | (define (<name> [p]...) <body>...) | 一个都不求值,只是把 body 记下来 | 符号 <name> |
lambda | (lambda ([p]...) <body>...) | 一个都不求值 | 一个过程对象 |
let | (let ([(<name> <expr>)]...) <body>...) | 先在当前帧求值所有 <expr>,再新建一帧求值 body | body 最后一个表达式的值 |
if | (if <pred> <cons> [<alt>]) | <pred>,然后只求值其中一个分支 | 被选中分支的值;无 alt 且假 → undefined |
and | (and [test]...) | 从左到右,遇到假值就停 | 第一个假值,否则最后一个的值;无参 → #t |
or | (or [test]...) | 从左到右,遇到真值就停 | 第一个真值,否则 #f |
cond | (cond (<test> [expr]...)... [(else ...)]) | 依次求值 test,命中第一个就停,只求值该 clause 的表达式 | 命中 clause 最后一个表达式的值 |
begin | (begin <expr>...) | 全部,按顺序,在当前帧(不新建帧) | 最后一个的值 |
把这张表的第三列竖着读一遍,你会发现一个共同点:每个特殊形式都在「哪些子表达式该被求值」这件事上偏离了调用规则。if 只算一个分支,and/or 可能提前停,lambda 一个都不算,define 不算名字。这就是为什么它们不能写成普通过程——普通过程拿到的永远是已经算完的值,无从「选择不算」。
判断一个组合式该怎么求值,只需要问一句:开头那个符号在不在特殊形式名单上?在,查上面这张表;不在,走「求算子 → 求全部算子数 → 施加」三步。你在最后那个项目里写的 scheme_eval,骨架就是这个 if-else,而这张表的每一行对应一个要实现的函数。
从 Python 切到 Scheme 时的思维调整
while/for 的地方,都改成问「怎么把问题缩小一格、最小的情况是什么、怎么把小答案拼成大答案」。(= n 0)、(null? lst)、(> x 0)。写 (if n ...) 几乎一定是错的。括号少一个:在 REPL 里表现为一直显示 ...> 续行提示符、按回车没反应——解释器在等你补完。写在文件里则表现为「后面的定义莫名其妙变成了前一个函数的一部分」。
括号多一个:多出来的右括号会提前关掉某个表达式,剩下的部分被当成新的顶层表达式,报错信息往往指向一个你觉得毫无关系的地方。
两种情况的共同应对办法:把每个特殊形式的各个部分单独一行、右括号跟着对齐。官方解答里那种「右括号单独占一行、和对应的左括号同列」的排版看起来啰嗦,就是为了肉眼数括号。
本讲小结
| 概念 | 要点 | 典型陷阱 |
|---|---|---|
| 表达式 | Scheme 里一切都是表达式,都求值成一个值;没有语句 | 想在函数体里写 return——没有这个东西 |
| 原始值 | 数字、字符串(双引号)、#t/#f、nil、symbol | 字符串写成单引号;把 nil 当 None |
| symbol | 唯一不自求值的原始表达式,要去环境查绑定 | 用了没定义的名字 → Error: unknown identifier: x |
| 组合式 | 一对括号 + 若干子表达式;前缀写法,无优先级 | 写成中缀 (1 + 2) → Error: int is not callable: 1 |
| 调用表达式 | 求算子 → 求全部算子数 → 施加;与 Python 完全相同 | 多加一层括号 = 多一次调用 |
| 特殊形式 | 看开头符号决定;它可以不求值某些子表达式 | 以为 if 是个普通函数 |
| 真假性 | 只有 #f 是假;0、""、nil 全为真 | (if n ...) 在 n 为 0 时照样进 consequent,静默算错 |
define | 两种形态:绑值 / 定义过程;返回值是那个符号 | 把两种形态混着写 → Error: too many operands in form |
lambda | 造无名过程;(define (f x) b) ≡ (define f (lambda (x) b)) | 忘了外层括号就调用不了 |
let | 绑定表达式在旧帧求值,然后新建一帧;本质是立刻调用一个 lambda | 后面的 binding 引用前面的 binding → unknown identifier |
cond | 每个 clause 要外层括号;命中第一个就停 | 漏掉 clause 括号 → 不报错但答案全错 |
begin | 顺序求值、不新建帧、返回最后一个的值 | 把它跟 let 搞混(let 建帧,begin 不建) |
| 递归 | 没有循环;base case 的条件和返回值必须配套 | base case 返回 0 却漏掉了第 0 项 → 结果差一项;没有 base case → maximum recursion depth exceeded |
| helper 参数 | 递归状态装不下时,加内部 helper 多带参数 | 忘写最后那句 (helper n 0),函数返回符号而非数 |
| 空白 | 只用来分隔符号,无语义 | 正因为无语义,不缩进就没人能读懂括号层次 |
常见报错对照
| 报错 | 含义 | 先查什么 |
|---|---|---|
Error: unknown identifier: x | 名字在当前环境链上查不到 | 拼写;是不是在 let/函数体外用了局部名字 |
Error: int is not callable: 1 | 算子位置上的东西不是过程 | 是不是写了中缀;是不是多套了一层括号 |
Error: Incorrect number of arguments to function call | 调用用户过程时参数个数不对 | 递归调用是不是漏传了某个参数 |
Error: incorrect number of arguments: #[quotient] | 调用内建过程时参数个数不对 | 查文档确认这个内建过程接受几个参数 |
Error: operand 1 ("two") is not a number | 把非数字喂给了算术过程 | 该用 equal? 的地方是不是用了 = |
Error: maximum recursion depth exceeded | 递归没有出口 | base case 写了没;参数是不是真的在变小 |
Error: too few operands in form | 特殊形式的必需部分缺失 | 比如 (if) 少了谓词 |
动手练习
练习 1:手算一个嵌套表达式
不许运行,写出下面这个表达式的值,并说明求值顺序:
(if (> (modulo 15 4) 2) (+ (quotient 17 5) 1) (* 2 3))
看答案
值是 4。
开头符号是 if,是特殊形式,所以不把三个子表达式都算掉:
(> (modulo 15 4) 2)。它是普通调用,先算算子数:(modulo 15 4) = 3,2 = 2;施加 > 得 (> 3 2) = #t。(+ (quotient 17 5) 1)。(quotient 17 5) 是 17 除以 5 的整数商 = 3,于是 (+ 3 1) = 4。(* 2 3) 根本没被求值。整个 if 的值就是 4。如果 if 是普通过程,第 3 步的 (* 2 3) 也会被算出来(值 6),然后被丢掉——结果一样,但白算了。真正致命的是当被丢掉的那个分支会出错或有副作用时(除零、无限递归、打印东西),那时候「算不算」就不再是效率问题了。
练习 2:and / or 返回的到底是什么
下面每一行在 REPL 里会显示什么?特别注意:显示的不一定是 #t 或 #f。
scm> (and 1 2 3)
scm> (or #f nil)
scm> (and (= 1 2) (/ 1 0))
scm> (not 0)
scm> (or (and #f 1) (and 2 3))
看答案
scm> (and 1 2 3)
3
scm> (or #f nil)
()
scm> (and (= 1 2) (/ 1 0))
#f
scm> (not 0)
#f
scm> (or (and #f 1) (and 2 3))
3
第 1 行:1、2 都是真值(记住只有 #f 假),没有一个 test 为假,于是返回最后一个 test 的值 3——不是 #t。
第 2 行:#f 不是真值,继续;nil 是真值,于是返回它。nil 打印出来就是 ()。这一题专门抓「以为空表是假的」。
第 3 行:(= 1 2) 是 #f,and 立刻返回它,(/ 1 0) 压根没被求值——所以没有除零错误。这就是短路的实际价值。
第 4 行:0 是真值,取反得 #f。在 Python 里 not 0 是 True,结果正好相反。
第 5 行:(and #f 1) 立刻返回 #f;or 看到假值继续;(and 2 3) 返回 3,是真值,or 返回 3。
练习 3:let 的作用域
scm> (define x 10)
x
scm> (let ((x 2) (y x)) (+ x y))
???
scm> x
???
看答案
依次是 12 和 10。
2 → 2。x → 去 Global 查,此刻 x 还是 10——新帧还没建,let 里的那个 x 还不存在。x → 2,y → 10。(+ x y):x 在本帧查到 2(遮住了全局那个 10),y 是 10。得 12。let 结束,那一帧消失。再问 x,只剩 Global 里的 10——let 从来没有改过全局的 x。Global frame
x ──→ 10 ←—— 全程没变
f1: let [parent=Global]
x ──→ 2 ←—— 遮住全局的 x
y ──→ 10 ←—— 绑定表达式在 Global 里算的,取到 10
body: (+ x y) → (+ 2 10) → 12
如果 let 像 Python 的连续赋值那样「一个一个来」,y 就会拿到 2,答案会是 4。它不是。所有绑定表达式一律在旧环境里算完,然后才一次性建帧。
练习 4:写一个 product
仿照 summation,写一个 product:它接受非负整数 n(保证 n ≥ 1)和过程 term,返回 term 作用在 1 到 n 每个整数上的结果之积。
(product 4 identity) ; 应得 1*2*3*4 = 24
(product 3 square) ; 应得 1*4*9 = 36
(product 1 square) ; 应得 1
看答案
(define (product n term)
(if (= n 1)
(term n)
(* (term n) (product (- n 1) term))))
跟 summation 的骨架完全一样,只改了三处:base case 的条件从 (= n 0) 变成 (= n 1)(因为这次从 1 起算),合并用的运算从 + 变成 *。三个测试实测通过:24、36、1。
展开 (product 3 square):
(product 3 square)
(= 3 1)? #f → (* (square 3) (product 2 square))
= (* 9 (product 2 square))
(product 2 square)
(= 2 1)? #f → (* (square 2) (product 1 square))
= (* 4 (product 1 square))
(product 1 square)
(= 1 1)? #t → (square 1) = 1 ←—— base case
回代:(product 1 square) = 1
(product 2 square) = (* 4 1) = 4
(product 3 square) = (* 9 4) = 36 ✓
思考一下:如果把 base case 写成 (if (= n 0) 1 ...) 行不行?行——(product 1 term) 会变成 (* (term 1) (product 0 term)) = (* (term 1) 1),答案一样。1 是乘法的单位元,正好对应 summation 里加法的 0。但绝不能写成 (if (= n 0) 0 ...)——那样任何输入都会得到 0。
练习 5:找 bug
有人想写「把 n 的各位数字加起来」,写成了下面这样。实际运行 (sum-digits 123) 会得到什么?为什么?怎么改?
(define (sum-digits n)
(if n
0
(+ (modulo n 10) (sum-digits (quotient n 10)))))
看答案
实测得到 0。而且 无论传什么数字进去,答案都是 0,一个错都不报。
作者的本意大概是「如果 n 是 0,就返回 0」。但 (if n 0 ...) 的意思是「如果 n 为真,返回 0」。而在 Scheme 里 123 是真值 → 立刻返回 0,递归分支永远走不到。更糟的是 0 本身也是真值,所以 (sum-digits 0) 也返回 0——base case 恰好「碰对了」,让人更难发现问题。
scm> (sum-digits 123)
0
scm> (sum-digits 99999)
0
改法是把条件写成明确的谓词:
(define (sum-digits n)
(if (= n 0)
0
(+ (modulo n 10) (sum-digits (quotient n 10)))))
scm> (sum-digits 123)
6
这道题要把一个习惯钉进脑子里:在 Scheme 里,if/cond/and/or 的条件位置,永远写一个返回 #t/#f 的谓词表达式。Python 那套「非空即真、非零即真」的简写,搬过来就是静默算错的 bug——比报错难查一百倍。