LECTURE 18

Scheme:把括号当成求值规则来读

换一门语言,不是为了多会一种语法,而是为了看清「一个表达式被求值时到底发生了什么」——因为接下来你要亲手把这套规则实现出来。

教材:Composing Programs §3.2 Functional Programming 对应作业:Lab 09

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)。

Scheme Primitives 幻灯片
Scheme 的原始值清单:数字、字符串(只能用双引号)、布尔值 #t/#f、空表 nil、以及 symbol。最后一行是关键:symbol 是唯一不自求值的原始表达式——它求值成「你之前在环境里给它绑的那个值」。

五类原始值

类别写法Python 里的对应物求值成什么
数字(number)5、5.0、-6int / float自己
字符串(string)"example",只能双引号str自己
布尔值(boolean)#t 或 true;#f 或 falseTrue / 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 求值算子(operator),也就是括号里第一个子表达式,得到一个过程(procedure)。
2 求值每一个算子数(operand),从左到右,各得到一个值。全部都要求值,一个都不能少。
3 把第 1 步得到的过程施加(apply)到第 2 步得到的那些实参值上,得到整个组合式的值。

子表达式本身如果还是组合式,就在第 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。

1 最外层算子是符号 modulo,查环境得到内建过程 #[modulo]。它有两个算子数:(* ...) 和 4。
2 求值第一个算子数 (* (- y x) (expt (+ x 1) y))。它自己又是组合式,算子 *,两个算子数都还是组合式,继续往里。
3 求值 (- y x):算子 -;算子数 y 是 symbol,查到 3;x 查到 1。施加得 3 - 1 = 2。
4 求值 (expt (+ x 1) y):先算它的算子数 (+ x 1),x 是 1,1 + 1 = 2;再算 y,是 3。于是 (expt 2 3) = 2³ = 8。
5 回到第 2 步:(* 2 8) = 16。
6 求值 modulo 的第二个算子数 4,是数字,自求值成 4。
7 施加:(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):

1 (square 2) 的开头符号是 square,不在特殊形式名单里 → 按调用表达式处理。
2 求值算子 square:symbol,去全局帧查,得到那个用户定义的过程。
3 求值算子数 2:数字,自求值成 2。
4 施加:新建一帧,parent 是这个过程被定义时所在的帧(这里是全局帧),在新帧里把形参 x 绑到 2。
5 在新帧里求值函数体 (* 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

外层这对括号常常把人看晕。拆开看:

逐步推演
1 整个东西是一个组合式,开头的子表达式是 (lambda (x y) (+ (* x x) (* y y)))。它不是符号 define/if/… 而是一个组合式,所以整体按调用表达式处理。
2 求值算子:算子本身是个 lambda 特殊形式,它不求值函数体,只是造出一个过程对象(形参 x、y,函数体 (+ (* x x) (* y y)),parent 记为当前帧)。
3 求值两个算子数:3 → 3,4 → 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>) 的形式。幻灯片把求值规则写得很细,值得逐句拆:

逐步推演
1 在当前帧里求值每个 binding 的 <expression>。注意是当前帧——此刻那些新名字还不存在。
2 新建一帧,它扩展(extend)当前环境;在这一帧里把每个 <name> 绑到第 1 步算出来的对应值。
3 在这一帧里按顺序求值 body 里的各个表达式,返回最后一个的值。

第 1 步和第 2 步的顺序至关重要:所有绑定表达式都在旧环境里算完,然后才一次性建帧。所以后面的 binding 看不到前面的 binding:(let ((a 1) (b (+ a 1))) b) 会报 unknown identifier: a(除非外层恰好有个 a,那它用的就是外层那个)。

let 的作用域示例
左边: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 求值中 / 求值后
—— 求值 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 把它们打包成一个表达式。

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 的每一个整数上,再把这些结果加起来。用数学写就是

$$\text{summation}(n, \text{term}) = \sum_{k=0}^{n} \text{term}(k)$$

三个必须抠清楚的细节:

  • 两端都含(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 然后循环加」这条路走不通。函数式语言里表达重复的唯一自然手段是递归,那就按递归的三件套来问:

1 怎么把问题缩小?要加 0…n 这 n+1 项。如果我已经知道 0…(n−1) 的和是多少,那再加上 (term n) 就得到答案了。缩小的方向是 n 减 1。
2 最小的情况是什么?n = 0,只剩一项 (term 0),直接返回它。
3 怎么把小问题的答案拼成大问题的答案?(+ (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 各自待在各自的帧里,互不干扰——这就是递归为什么能工作。

常见误区

误区 1:base case 返回 0

很多人下意识写 (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 的条件和返回值必须配套。

误区 2:递归调用忘了传 term
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 不检查函数体里的调用参数个数,要等到真的调用时才发现。看到这个报错,第一反应应该是「我哪次调用少给/多给了参数」。

误区 3:忘了 base case
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 = 1232112321
位置43210
要不要加✓—✓—✓

所以 (sum-even 12321) = 1 + 3 + 1 = 5。再看 1112:

n = 11121112
位置3210
要不要加—✓—✓

= 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 走,走着走着就不知道现在这一位是第几号了。

1 第一次调用,n = 1112,当前处理的是位置 0。
2 递归下去,n = 111,当前处理的是位置 1。
3 再下去 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 对照与思维切换

把两门语言并排放,能看清哪些是语法差异(换个写法而已),哪些是语义差异(真的会算出不同答案)。后者才是会咬人的。

做什么PythonScheme差在哪
算术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: a
else: 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>,再新建一帧求值 bodybody 最后一个表达式的值
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 时的思维调整

1 别找「语句」。脑子里想「我要写一行代码做某件事」的时候,改成想「我要写一个表达式,它的值是某个东西」。函数体是一串表达式,最后一个的值就是答案。
2 别找循环。凡是想写 while/for 的地方,都改成问「怎么把问题缩小一格、最小的情况是什么、怎么把小答案拼成大答案」。
3 别依赖真假性。条件位置一律写明确谓词:(= n 0)、(null? lst)、(> x 0)。写 (if n ...) 几乎一定是错的。
4 括号从内往外配。每写一个左括号就先补上右括号,再往中间填内容。少一个括号的报错通常出现在离错误位置很远的地方,非常难定位。
5 缩进虽然不影响语义,但一定要缩。它是你唯一的肉眼校验手段。
常见误区:括号不平衡时的症状

括号少一个:在 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,是特殊形式,所以不把三个子表达式都算掉:

1 先求值谓词 (> (modulo 15 4) 2)。它是普通调用,先算算子数:(modulo 15 4) = 3,2 = 2;施加 > 得 (> 3 2) = #t。
2 谓词为真 → 只求值 consequent (+ (quotient 17 5) 1)。(quotient 17 5) 是 17 除以 5 的整数商 = 3,于是 (+ 3 1) = 4。
3 alternative (* 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。

逐步推演
1 在当前帧(Global)里求值两个绑定表达式。2 → 2。x → 去 Global 查,此刻 x 还是 10——新帧还没建,let 里的那个 x 还不存在。
2 新建一帧,parent 是 Global:x → 2,y → 10。
3 在新帧里求值 body (+ x y):x 在本帧查到 2(遮住了全局那个 10),y 是 10。得 12。
4 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——比报错难查一百倍。