第 2 部分 · 基础:用 Racket 写点什么
从嵌套到管道——Racket 函数组合的两种方向
Racket 里把几个函数拼成一个新函数,用
compose;想让数据从左流到右、一眼读懂,用管道。两者方向相反,各管一摊。
你写过嵌套调用,知道 (f (g (h x))) 能跑。你也用过 map、filter、foldl,知道它们处理列表很漂亮。但一旦要把好几步串起来——先过滤、再映射、再求和——写出来的还是一层套一层的括号,读起来得从内往外倒着读。
为什么函数组合这么基本的事,写出来却这么拧巴?函数式编程的核心信念是:复杂的程序,由小函数组合而成。这篇讲 Racket 里让“组合”变顺手的两种写法。
嵌套调用,要从内往外读
想象一条工厂流水线:原料进来,过第一道工序,再过第二道,再过第三道,成品出去。
没有人会这样描述流水线:“成品是把第三道工序,应用在‘把第二道工序应用在原料上’的结果上。”但嵌套调用就是这么说话的:
(成品 (第三道 (第二道 (第一道 原料))))
看一个真实的例子。把字符串 "41" 变成 "42":先转数字、加一、再转回字符串。
(number->string (add1 (string->number "41")))
;; "42"
求值顺序是 string->number → add1 → number->string,从内往外。这是数学的写法(f(g(x))),但不是人脑的写法——人想事情是从左往右的。Racket 给了两种工具,把这个“从内往外”翻转过来。
compose:把几个函数拼成一个新函数
compose 接受一堆函数,返回一个新函数。新函数会把输入从右往左依次喂进去:
(define parse-inc-str (compose number->string add1 string->number))
(parse-inc-str "41")
;; "42"
求值顺序还是 string->number → add1 → number->string,从右往左——和嵌套写法一致,只是把“套”变成了“拼”。
注意一件事:这个定义里没有出现输入值。compose 在造一个新函数,不是在算一个具体的值。 这种不提参数的风格叫 point-free,适合把小积木拼成大积木留着复用。
compose 有个细节:它默认会处理“一个函数返回多个值”的情况——强大,但偶尔会让新手愣住。如果你只写单进单出的函数,用 compose1 更安全,它限制每个函数只吃一个参数、吐一个结果:
(define inc-then-square (compose1 (λ (x) (* x x)) add1))
(inc-then-square 3)
;; 16 先 add1 得 4,再平方得 16
管道:让数据从左流到右
compose 把方向从“套”变成了“拼”,但求值顺序还是从右往左。如果你想要真正的从左往右——像流水线那样——Racket 标准库没有直接提供,需要装一个叫 threading 的包:
raco pkg install threading
然后:
(require threading)
(-> "41" string->number add1 number->string)
;; "42"
这一行读起来就是一句话:拿 "41",过 string->number,过 add1,过 number->string。从左往右,符合直觉。
-> 把前一步的结果塞进下一步的第一个参数位置。因为 string->number、add1、number->string 都只吃一个参数,所以正好。但很多处理列表的函数,数据是最后一个参数——(map f 列表)、(filter 条件 列表)、(apply + 列表)——这时用 ->>,它把结果塞到末尾:
(->> '(1 2 3 4 5)
(filter odd?)
(map (λ (x) (* x x)))
(apply +))
;; 35 滤奇数 (1 3 5),平方 (1 9 25),求和
-> 和 ->> 的区别只是数据塞第一个还是塞最后一个,看你下一步用什么函数决定。
组合还是管道
它们不是竞争关系,是两种不同的活儿:
compose / compose1 | 管道 -> / ->> | |
|---|---|---|
| 产出 | 一个新函数,可复用、可传给 map | 一个结果值 |
| 方向 | 从右往左 | 从左往右 |
| 适合 | 造工具、拼积木 | 处理一个具体数据 |
| 能否当函数传 | 能 | 不能(它是宏) |
想留下一个函数以后反复用,用 compose;想一眼读懂这段数据怎么变的,用管道。
实战:一条数据处理流水线
把前面的工具串起来。需求:给定一串数字,留下偶数,各自平方,求和。
(define (sum-squared-evens numbers)
(->> numbers
(filter even?)
(map (λ (x) (* x x)))
(apply +)))
(sum-squared-evens '(1 2 3 4 5 6))
;; 56 偶数 (2 4 6),平方 (4 16 36),求和
如果其中有一步想留下来复用,就用 compose 拼成一个工具,它还能被 map 拿去用:
(define inc->str (compose number->string add1))
(map inc->str '(1 2 3))
;; '("2" "3" "4")
管道处理具体数据,组合造可复用的工具,两者随时切换。
curry 的暗刃
curry 把一个多参数函数固化掉前几个参数,剩下一个新函数,经常和 compose 配合:
(define add10 (curry + 10))
(add10 5)
;; 15
(map (curry * 2) '(1 2 3))
;; '(2 4 6)
但 curry 有一把暗刃:参数没给够时,它不报错,而是返回一个还在等参数的函数。
(define f (curry (λ (x y) (+ x y))))
(f 1)
;; 不报错——返回一个还在等第二个参数的函数
这种沉默会让你调试到怀疑人生:明明少给了参数,程序却不吭声。把 curry 当“做个小工具”的快捷方式就好,别追求用它改写一切。
一个伏笔:-> 是宏,不是函数
-> 看起来像个函数,其实它是个宏——在程序运行之前,它就把 (-> x f g) 改写成了 (g (f x))。所以你没法把 -> 当参数传给 map。
这意味着什么?如果哪天你觉得 Racket 缺一个更好用的管道,你可以自己造一个。Racket 就是这么一种语言:不够用,就自己长。 等你学到宏那一章,你会亲手写出一个自己的 ->。