Racket 编程入门

第 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),求和

function-composition-threading-fig01

-> 和 ->> 的区别只是数据塞第一个还是塞最后一个,看你下一步用什么函数决定。

组合还是管道

它们不是竞争关系,是两种不同的活儿:

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 就是这么一种语言:不够用,就自己长。 等你学到宏那一章,你会亲手写出一个自己的 ->。