第 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里的写法都会跟着变短。