Racket 编程入门

第 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 就够用。)

三种绑定,怎么选

把它们摆在一起对比:

letlet*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 造出自己想要的、新的绑定形式。