Racket 编程入门

第 3 部分 · 函数式:不动状态的计算

柯里化——把多参数函数拆成一串小函数

柯里化把一个多参数函数拆成一串单参数函数:喂一个参数,拿回一个还在等剩下参数的新函数。

你写过 (define (add x y) (+ x y)),一个函数一次收齐所有参数,干脆利落。但你有没有想过:参数为什么非得一次给齐?如果先给一个、把剩下的留给一个“还没造完”的函数,会发生什么?

这种“一次收齐”其实是多数语言养成的习惯。函数式编程里还有另一种形状:给一个参数,先收着,剩下的下次再说。这就是柯里化(currying)。这篇讲清楚它在 Racket 里长什么样、它和“部分应用”是不是一回事、为什么方向填错了结果就反了,以及它最坑人的那把暗刃。

拆开看:多参数函数怎么变成一串小函数

把 add 稍微改一下形状:不让它一次拿两个参数,而是只拿第一个,把第二个参数交给一个“还没造完”的新函数。

(define (add x)
 (λ (y) (+ x y)))

((add 2) 3)
;; 5

(add 2) 这一步并没有算出结果,它返回的是一个还在等 y 的新函数;再把这个新函数喂给 3,才算完。函数从“一口吃两个参数”变成了“吃一个、吐一个还在等的函数”。

这种逐个接收参数的写法,就是柯里化。它源自数学,在函数式语言里是一等公民。Racket 还提供了更顺手的缩写,把两层 λ 折进定义本身:

(define ((add x) y)
 (+ x y))

((add 2) 3)
;; 5

(define ((add x) y) ...) 读作:add 吃一个 x,返回一个吃 y 的函数。和上面手写的版本完全等价,只是少写一层 λ。

柯里化的本质不是新语法,而是换了一种看函数的形状——函数本身就是一个能逐步构造的东西。

curry:给现成函数预装几个参数

手写柯里化适合你从头设计一个新函数。但更多时候,函数已经写好了、定死了多参数的形状——比如标准库里的 +、string-append,你没法回头改它的定义。Racket 的 curry 就是给这种情况准备的:

(require racket/function)

(define add5 (curry + 5))

(add5 10)
;; 15

curry 拿一个现成的多参数函数,先替它收下几个参数,吐回一个还在等剩下的新函数。+ 本来要 (f a b) 两个参数,(curry + 5) 就把 5 这一位先占上,剩下的那位留给 add5 以后再收。

再看一个工程里更典型的用法。一个日志函数,吃 level 和 msg 两个参数:

(define (log level msg)
 (printf "[~a] ~a\n" level msg))

(define debug-log (curry log "DEBUG"))

(debug-log "boot")
(debug-log "load config")
;; [DEBUG] boot
;; [DEBUG] load config

满系统都要打 DEBUG 日志时,你不必每行都抄一遍 "DEBUG",先用 curry 把 level 这一位焊死,得到一个专管 DEBUG 的小工具。

这种“先把通用函数的某几位焊死、再造一个专用小工具”的套路,是柯里化在工程里最高频的用法。

名字叫柯里化,干的是部分应用

严格说,“柯里化”和“部分应用”是两件事。柯里化指把 (a, b) → c 这种形状改造成 a → (b → c)——函数的形状本身变了,一次只吃一个参数。部分应用指拿一个函数、先喂它几个参数、拿回一个等剩下的函数——形状没变,只是少喂了几个。

Racket 的 curry 干的其实是后者。它不强迫你一次只喂一个参数:

(define c (curry (λ (x y z) (list x y z))))

(c 1 2 3) ;; '(1 2 3),一口气喂完也行
((c 1) 2 3) ;; '(1 2 3)
(((c 1) 2) 3) ;; '(1 2 3)

而手写的 (define ((add x) y) ...) 才是“真·柯里化”:它的形状就是一次一个,你没法 (add 1 2),只能 ((add 1) 2)。

这两件事在大多数语言里都共用同一个名字,Racket 也随了大流;你知道它实际干的是部分应用,就不会被名字绕进去。

curry 填左,curryr 填右

curry 有个容易栽跟头的细节:它填的是最左边那一位参数。对 +、* 这种满足交换律的运算无所谓,填哪边都一样;但遇到 >、-、/ 这种顺序敏感的,填错位结果就反了。

想留下大于 10 的数,直觉上会写 (curry > 10)——但这会把 10 填到左边,得到 (λ (x) (> 10 x)),也就是“10 比 x 大”,筛出来的反而是小于 10 的:

(filter (curry > 10) '(5 12 8 20))
;; '(5 8) 留下的是小于 10

要填右边那一位、得到 (λ (x) (> x 10)),得换用 curryr:

(filter (curryr > 10) '(5 12 8 20))
;; '(12 20) 这才是大于 10

curry 填左,curryr 填右,差别就这一句话。

遇到顺序敏感的函数,先想清楚你要焊死的是左边还是右边,再决定用哪个。

配合 map 和 filter,省掉一排 lambda

柯里化真正顺手的地方,是配合 map、filter 这类高阶函数。这些函数要的本来就是一个“还差一个参数”的函数,而 curry 恰好就是造这个的。

给列表每个元素加 5,不柯里化得现写一个 lambda:

(map (λ (x) (+ x 5)) '(1 2 3))
;; '(6 7 8)

用 curry,这层 lambda 就省了:

(map (curry + 5) '(1 2 3))
;; '(6 7 8)

给一串名字统一加前缀,也是同一个套路:

(map (curry string-append "user-") '("alice" "bob"))
;; '("user-alice" "user-bob")

读起来不再像“定义一个临时函数再映射”,而更像一句话:每个元素,过一遍“加 5”、过一遍“贴前缀”。函数变成了能直接拼上去的零件。

配合 compose,拼一条流水线

把 curry 和 compose 放一起,就能把一串处理步骤拼成一个可复用的函数,而且不必为每一步写 lambda。比如把一个字符串先去两端空白、再转小写、再贴上 user: 前缀:

(require racket/string)

(define tag
 (compose (curry string-append "user:")
 string-downcase
 string-trim))

(tag " Alice ")
;; "user:alice"

compose 从右往左执行:先 string-trim 去空白,再 string-downcase 转小写,最后 (curry string-append "user:") 贴前缀。其中只有“贴前缀”这一步需要固定参数,用 curry 一行搞定;另两步本身就是单参数函数,直接放进去。整段代码几乎就是对处理流程的直接翻译。

当你发现自己反复写 (λ (s) (f 固定值 s)) 时,那层 lambda 多半能换成一次 curry(顺序敏感时换成 curryr)。

暗刃:参数没给够,它一声不吭

curry 最坑人的地方,是参数没给够时它不报错,而是老老实实返回一个“还在等参数”的函数。在 (define f (curry (λ (x y) (+ x y)))) 之后调 (f 1) 表现得很明显:

(define f (curry (λ (x y) (+ x y))))

(f 1)
;; 不报错,返回一个还在等第二个参数的函数

你以为拿到的是一个值,其实拿到的是一个函数。这种沉默在更长的管线里尤其难抓。比如手滑把 (curry + 5) 写成了 (curry +):

(map (curry +) '(1 2 3))
;; '(#<procedure> #<procedure> #<procedure>)

程序不报错,map 也照常跑完,只是每个元素都被喂给了一个“还在等参数”的 curry +,结果是一串过程对象。等你下游拿这串东西去运算,才会炸出莫名其妙的错,而真正的祸根在几行之前。

根子在它的工作方式:靠参数个数判断该不该真正调用原函数,参数不够就继续等,它不会替你检查“你是不是其实想算完”。所以别追求用 curry 改写一切。

把它当随手造小工具的快捷方式就好;一旦某一步冒出怪结果,先怀疑是不是哪里少喂了一个参数。

函数不必一次把参数吃满。先给一个,剩下的留给下一个函数——这种形状一旦看顺眼,map、filter、compose 里的写法都会跟着变短。