第 3 部分 · 函数式:不动状态的计算
三种局部绑定——let 的并行、顺序与递归
Racket 里给中间结果起个名字、限定它的可见范围,靠
let家族。绑定相互独立用let,有先后依赖用let*,要引用自己用letrec——区别全在求值时机。
你写过这样的代码:一个长表达式算到一半的值,后面要用好几次,只能重新算一遍,或者把它层层嵌套进下一层括号。你想给它起个名字,让代码顺着读下来。
Racket 给了你 let。但很快你会发现 let 还有一堆亲戚:let*、letrec,甚至还有一种叫命名 let 的循环写法。它们长得几乎一样,凭什么要分这么多种?
答案藏在求值时机里。这几个形式的差别只有一句话: 绑定的右值,在什么环境里求值。 把这句话想透,你就再也不会选错。
let:给中间结果起个名字
最朴素的用途,是把一个中间结果存下来,免得算第二遍:
(let ([len (string-length s)])
(and (> len 0) (< len 10)))
len 被绑到 string-length 的结果上,后面两次用到它,只算一次。读的人也省力——他知道这个值叫 len,是个长度。
也可以一次绑好几个,各自独立:
(let ([user-input (read-line)]
[threshold (* base-value 1.2)])
(process-input user-input threshold))
到这里都很自然。坑在下一个问题:这几个绑定,彼此能看见吗?
并行绑定:右值看不到彼此
看这段代码:
(let ([x 1]
[y (+ x 1)])
y)
;; 这个 x 不是上面那个 x——它指向 let 外部的同名变量,没有就报错
直觉会以为 y 是 2。错。let 的所有右值,都在 let 外部的环境里求值——也就是说,右值看不到同一个 let 里新建的绑定。这里的 x 指向 let 外面那个同名变量;如果外面没有 x,就报未定义。
这就是 let 的并行语义:先在外层环境里把所有右值算完,再一次性把名字绑上去。 绑定之间彼此看不见,只看得见外面的世界。
这个特性最经典的用法是交换两个值,不用临时变量:
(let ([me "Tarzan"] [you "Jane"])
(let ([me you] [you me])
(list me you)))
;; '("Jane" "Tarzan") 两个右值都用外层绑定,于是完成交换
内层 let 的两个右值 you 和 me,都指向外层那两个值——它们同时求值,互不干扰,于是完成了交换。如果绑定期望“从上到下依次生效”,这层 let 就帮不了你,得换成 let*。
let*:后面的能看到前面的
想让后一个绑定用到前一个的值,把 let 换成 let*:
(let* ([x 1]
[y (+ x 1)])
(list x y))
;; '(1 2)
let* 是顺序求值:每绑好一个名字,就把它放进环境,供后面的右值使用。它天然适合分步处理数据的流水线——每一步都依赖上一步:
(let* ([raw (read-line)]
[normalized (string-downcase raw)]
[tokens (string-split normalized " ")])
(process-tokens tokens))
读起来就是一句话:读一行、转小写、按空格切分、交给 process-tokens。 求值从上到下,一步接一步。
letrec:右值能引用自己
如果右值在求值时就需要引用自己——典型场景是局部递归函数——let 和 let* 都办不到,因为右值求值那一刻,这个名字还没绑好。这时候用 letrec:
(letrec ([fact (λ (n)
(if (zero? n)
1
(* n (fact (- n 1)))))])
(fact 5))
;; 120
letrec 的规则是:所有名字在所有右值求值时都已可见。所以 fact 的函数体里可以调用 fact——它指向正在被定义的这个函数本身。
这种写法最常见的用途,是把一个只在某处使用的递归辅助函数就近定义。比如算一棵树的高度:
(define (tree-height tree)
(letrec ([height (λ (t)
(if (leaf? t)
1
(+ 1 (max (height (left t))
(height (right t))))))])
(height tree)))
height 只在 tree-height 内部有意义,外面看不到它。 把一次性的递归辅助函数就近绑住,不污染外部作用域——这是 letrec 最常见的用途。
(顺带一提:letrec 还有个表兄 letrec*,把“所有名字互相可见”和“按顺序求值”叠在一起——可以理解为 letrec 与 let* 的合体。日常写局部递归函数,letrec 就够用。)
三种绑定,怎么选
把它们摆在一起对比:
let | let* | letrec | |
|---|---|---|---|
| 求值方式 | 并行 | 顺序 | 递归 |
| 后面的能看到前面的 | 不能 | 能 | 能 |
| 右值能引用自己 | 不能 | 不能 | 能 |
| 典型用途 | 命名独立中间值 | 有依赖的流水线 | 局部递归函数 |
决策很简单:互相独立用 let,有依赖用 let*,要自引用用 letrec。能用 let 就别急着上 let*——“这几个值互不相干”本身就是一条给读代码的人的信息。
命名 let:藏在 let 里的循环
还有一种特殊写法,叫命名 let,长这样:
(let sum ([lst '(1 2 3 4)] [total 0])
(if (null? lst)
total
(sum (cdr lst) (+ total (car lst)))))
;; 10
let 后面跟了一个名字 sum,这个 let 就不再是绑定中间值,而是定义了一个能递归调用的过程,并立刻用初始参数跑起来。它等价于:
(letrec ([sum (λ (lst total)
(if (null? lst)
total
(sum (cdr lst) (+ total (car lst)))))])
(sum '(1 2 3 4) 0))
;; 10 和上面的命名 let 完全等价
命名 let 就是 letrec 加一次立即调用,专为写循环而生。比起手写一个递归函数再调用它,它把初始值和“每一步怎么推进”摆在一处,读起来更顺——这也是 Racket 里写循环最地道的姿态。
一个伏笔:let 其实是 lambda 的糖
回到最普通的 let。它看着像个特殊的绑定语法,其实可以完全用 lambda 写出来:
(let ([x 1] [y 2])
(+ x y))
;; 和下面这段完全等价:
((λ (x y) (+ x y)) 1 2)
;; 3
let 做的事,就是造一个接受 x、y 的函数,然后立刻把值喂给它。这也解释了为什么 let 的右值看不到自己的绑定——它们本质上是传给函数的实参,实参当然在函数外面求值。
这是 Racket 里反复出现的主题: 很多看着像语法的东西,其实是别的更基本的东西的语法糖。 等你学到宏那一章,你会亲眼看到 let 怎么被展开成 lambda——然后你就能给 Racket 造出自己想要的、新的绑定形式。