λ:函数是值,不是「被调用的东西」
你每天都在写 list.map { it * 2 },把一个函数传给另一个函数。你觉得这天经地义。但它并不天经地义 —— Java 花了二十年(到 Java 8)才把这件事做对,而它做对的那一刻,整个生态被重写了一遍。这一章要问的是:函数凭什么可以是一个「值」?答案会一路带你走到一个惊人的地方:只用 λ,什么都能造出来 —— 真、假、数字、甚至递归本身。
先拆一个糖
你一直在写的 define:
(define (square x) (* x x))
它是这个的语法糖:
(define square
(lambda (x)
(* x x)))
两者完全等价,你可以在 REPL 里验证。而第二种写法把真相摊开了:
define 从来没有「定义函数」这个功能define 只做一件事:给一个值起个名字。
(define x 42) ; 给 42 这个值起名叫 x (define square (lambda (x) ...)) ; 给「一个函数」这个值起名叫 square ; ↑ 这里的 lambda 求值出一个值,和 42 是一个等级的东西
「函数定义」这个概念不存在。存在的只有「值定义」,而函数碰巧是一种值。
对比一下 Java 的世界观:那里有「类」、有「方法」、有「字段」—— 三种不同的东西,三套语法,方法不能脱离类而存在。Racket 只有一种东西:值。
验证一下这句话不是修辞:
> square #<procedure:square> ; ← 它是一个值,能被打印 > (define ops (list + - * /)) ; ← 把四个函数塞进一个列表 > (map (lambda (op) (op 10 2)) ops) '(12 8 20 5) ; ← 依次调用它们
四个运算符,装进一个普通列表,然后 map 一遍。在 Java 8 之前,这段代码需要四个匿名内部类、一个接口定义和大约二十行样板。
「一等公民」到底是哪几等
一个值是「一等公民」,意味着它能做到这四件事。对着看,你会发现你的语言在这四条上的得分未必是满分:
| 能力 | Racket | Kotlin / JS | Java 8 之前 |
|---|---|---|---|
绑定到变量 (define f (lambda …)) | ✓ | ✓ | ✗ 只能包在对象里 |
作为参数传递 (map f lst) | ✓ | ✓ | ✗ 只能传接口实例 |
作为返回值 (lambda () (lambda () …)) | ✓ | ✓ | ✗ |
存进数据结构 (list + - *) | ✓ | ✓ | ✗ |
Java 8 那次「加了 lambda」的更新,本质上是把函数从三等公民提拔成了一等公民。而这一个改动,直接催生了 Stream API、CompletableFuture、以及后来整个响应式生态。一个语言特性,重写了一个生态。这就是「函数是值」这件事的分量。
(顺带说个诚实的:Java 的 lambda 其实还是包在接口里的 —— Function<T,R>、Predicate<T>。它是「用接口模拟出的一等函数」。所以你会遇到那些别扭的地方:函数类型不能直接写、要记一堆 BiFunction / Supplier / Consumer 的名字。Kotlin 的 (Int) -> String 才是真的函数类型。)
高阶函数:两个方向
「高阶函数」= 拿函数当参数,或者返回一个函数。第一个方向你很熟:
(map (lambda (x) (* x x)) '(1 2 3)) ; ⇒ '(1 4 9) (filter even? '(1 2 3 4)) ; ⇒ '(2 4) (sort '(3 1 2) <) ; ⇒ '(1 2 3)
第二个方向 —— 返回函数 —— 才是威力所在,而且大多数人用得不够:
(define (adder n) (lambda (x) (+ x n))) ; ← 返回一个函数 (define add10 (adder 10)) (add10 5) ; ⇒ 15 (map (adder 100) '(1 2 3)) ; ⇒ '(101 102 103)
adder 不是一个函数,它是一个「函数工厂」。你给它一个 n,它制造一个专门加 n 的函数出来给你。
而那个被造出来的函数记住了 n —— 通过什么?通过第 5 章那根指向出生环境的指针。闭包和高阶函数在这里合流了:没有闭包,「返回一个函数」这件事就是无意义的(返回的函数会立刻忘记它需要的一切)。
你写过多少次这样的代码?
// 到处传同一个配置
fun format(value: Double, currency: String, decimals: Int): String { … }
format(x, "NZD", 2)
format(y, "NZD", 2)
format(z, "NZD", 2) // 「NZD, 2」重复了 N 遍
函数工厂的做法:
fun formatter(currency: String, decimals: Int): (Double) -> String =
{ value -> … }
val nzd = formatter("NZD", 2) // 造一个专用的
nzd(x); nzd(y); nzd(z)
你把「配置」和「数据」分成了两次调用。这就是柯里化的实用价值 —— 不是数学游戏,是把参数按「变化频率」分组:变得慢的(配置)先给,变得快的(数据)后给。
DI 框架、middleware 链(fun middleware(next: Handler): Handler)、Compose 的 Modifier 链、Retrofit 的 interceptor —— 全都是这个模式。
柯里化:把多参函数拆成一串单参函数
; 普通的三参函数
(define (add3 a b c) (+ a b c))
(add3 1 2 3) ; ⇒ 6
; 柯里化版本:一次吃一个参数
(define (curry-add a)
(lambda (b)
(lambda (c)
(+ a b c))))
(((curry-add 1) 2) 3) ; ⇒ 6
; ↑ 每次调用返回一个函数,最后一次才返回结果
Racket 自带 curry,不用手写:
> ((curry + 1) 2) 3 > (map (curry * 2) '(1 2 3)) '(2 4 6)
为什么这值得知道?因为它揭示了一件事:「多参数函数」其实是一个幻觉。在纯粹的 λ 演算里,每个函数只有一个参数 —— 多参数只是「返回函数的函数」的语法糖。Haskell 就是这么做的(a -> b -> c 读作 a -> (b -> c)),所以它的部分应用是免费的。
λ 演算:整个计算的地基,只有三条规则
现在往下挖一层。λ 演算(Alonzo Church,1930 年代)是一个只有三种东西的形式系统:
| 叫什么 | 长什么样 | Racket 里 |
|---|---|---|
| 变量 | x | x |
| 抽象(造一个函数) | λx. e | (lambda (x) e) |
| 应用(调用一个函数) | f e | (f e) |
没有数字。没有布尔值。没有 if。没有循环。没有数据结构。就这三条。
而计算规则只有一条 —— 你在第 7 章的步进器里见过它,叫 β 归约:
((lambda (x) 函数体) 实参) ; ⇓ 把函数体里的 x 全换成实参 函数体[x := 实参]
这就是全部。而这套系统是图灵完备的 —— 它能算任何可计算的东西。Church 用它证明了这一点,比图灵机还早一点点(两人的结论后来被证明等价,合称 Church–Turing 论题)。
证明给你看:用 λ 造出「真」和「假」
「没有布尔值」听起来像个致命缺陷。那就造一个。
思路:布尔值的用途是什么?是「在两个东西里选一个」。那我们就直接把「选择」这个行为定义成布尔值:
(define TRUE (lambda (t) (lambda (f) t))) ; 拿两个东西,选第一个 (define FALSE (lambda (t) (lambda (f) f))) ; 拿两个东西,选第二个 (define IF (lambda (c) (lambda (t) (lambda (f) ((c t) f))))) ; ↑ 把两个分支交给条件自己去选 (((IF TRUE) 'yes) 'no) ; ⇒ 'yes (((IF FALSE) 'yes) 'no) ; ⇒ 'no
你刚刚在一个没有布尔值的语言里,用纯函数造出了布尔值和 if。
再造数字。思路同样是「问它有什么用」:数字 n 的用途是「重复 n 次」。
(define ZERO (lambda (f) (lambda (x) x))) ; f 用 0 次 (define ONE (lambda (f) (lambda (x) (f x)))) ; f 用 1 次 (define TWO (lambda (f) (lambda (x) (f (f x))))) ; f 用 2 次 (define (SUCC n) (lambda (f) (lambda (x) (f ((n f) x))))) ; 再多用一次 ; 翻译回人类数字 (define (church->int n) ((n add1) 0)) (church->int (SUCC (SUCC TWO))) ; ⇒ 4
这叫 Church 编码。你可以照这个路子造出加减乘除、列表、树 —— 整个计算机科学,用一个 λ 就够了。
连递归都不需要名字
最后一击。递归看起来必须要有名字 —— fact 得能调用 fact 自己。但 λ 演算里没有 define,函数是匿名的。匿名函数怎么调用自己?
答案是 Y 组合子:
(define Y
(lambda (f)
((lambda (x) (f (lambda (v) ((x x) v))))
(lambda (x) (f (lambda (v) ((x x) v)))))))
; 把「阶乘」写成一个不递归的函数:它把「自己」当参数收进来
(define fact-gen
(lambda (self)
(lambda (n)
(if (= n 0) 1 (* n (self (- n 1)))))))
((Y fact-gen) 5) ; ⇒ 120
看清楚 fact-gen:它没有递归。它不调用自己 —— 它调用一个叫 self 的参数。Y 组合子的工作,就是把这个函数「喂给它自己」,凭空变出递归来。
Y 组合子长得像天书,你不需要看懂它(我也是每次用都要重新推一遍)。你需要知道的只有一件事:递归不是语言的基本特性,它可以被造出来。
你以为是「语言基本特性」的东西 —— 布尔值、数字、if、循环、递归 —— 全都可以用 λ 造出来。
这意味着它们不是地基,是家具。地基只有一个:函数。
为什么这重要?因为它把你的思维从「语言给了我什么」翻转成 「我能造出什么」。这个翻转,就是卷 V(宏)和卷 VI(造语)的全部前提。如果 if 都能造,那还有什么不能造?
回流
① 「返回函数」这个方向,你用得太少。大多数工程师只会「传函数」(map、filter、回调),很少「造函数」。下次你发现自己在反复传同一组参数,试试做一个函数工厂:
// Kotlin:把配置和数据分开
val retry = retrier(times = 3, delay = 100.ms) // 造一个
retry { api.fetch() } // 用很多次
// JS:middleware 就是这个形状
const withAuth = (handler) => (req, res) => { … handler(req, res) }
② 认出 inline 的真正含义。Kotlin 的 inline 高阶函数(map、let、run)之所以「零开销」,是因为编译器把 lambda 的函数体直接搬到了调用点,压根没造那个函数对象。代价是:它只能对字面量 lambda 生效。一旦你把 lambda 存进变量再传进去,对象还是要造。这解释了为什么 crossinline / noinline 存在。
③ 下次看到「策略模式」「模板方法」「访问者模式」,先问一句:这不就是传个函数吗?
GoF 的二十三个设计模式里,有相当一部分是「因为函数不是一等公民」而产生的补丁。Peter Norvig 在 1998 年就指出过:在动态语言里,23 个模式中有 16 个「要么消失,要么简化到看不见」。策略模式 = 传一个函数。命令模式 = 传一个函数。观察者 = 一个函数列表。它们不是「智慧」,是「缺失的语言特性的绷带」。
(这不是说设计模式没用 —— 在 Java 里它们仍然有用。但你应该知道自己在补什么。)
下一章
我们已经知道函数是什么、闭包是什么。但有一件事一直被含糊过去了:这些东西,到底是按什么顺序被算出来的?
下一章我们把求值过程一步一步慢放。你会看到第 1 章那个 myIf 为什么必然崩溃,也会明白为什么 && 能短路而你写的函数不能 —— 这两个问题是同一个问题。