卷 II · 求值CH 06深度 06/25

λ:函数是值,不是「被调用的东西」

你每天都在写 list.map { it * 2 },把一个函数传给另一个函数。你觉得这天经地义。但它并不天经地义 —— Java 花了二十年(到 Java 8)才把这件事做对,而它做对的那一刻,整个生态被重写了一遍。这一章要问的是:函数凭什么可以是一个「值」?答案会一路带你走到一个惊人的地方:只用 λ,什么都能造出来 —— 真、假、数字、甚至递归本身。

lambda一等函数高阶函数柯里化λ 演算

先拆一个糖

你一直在写的 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 之前,这段代码需要四个匿名内部类、一个接口定义和大约二十行样板。

「一等公民」到底是哪几等

一个值是「一等公民」,意味着它能做到这四件事。对着看,你会发现你的语言在这四条上的得分未必是满分:

能力RacketKotlin / JSJava 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 里
变量xx
抽象(造一个函数)λ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 都能造,那还有什么不能造?

回流

⟲ 回流 · 三个立刻能用的东西

① 「返回函数」这个方向,你用得太少。大多数工程师只会「传函数」(mapfilter、回调),很少「造函数」。下次你发现自己在反复传同一组参数,试试做一个函数工厂:

// 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 高阶函数(mapletrun)之所以「零开销」,是因为编译器把 lambda 的函数体直接搬到了调用点,压根没造那个函数对象。代价是:它只能对字面量 lambda 生效。一旦你把 lambda 存进变量再传进去,对象还是要造。这解释了为什么 crossinline / noinline 存在。

③ 下次看到「策略模式」「模板方法」「访问者模式」,先问一句:这不就是传个函数吗?

GoF 的二十三个设计模式里,有相当一部分是「因为函数不是一等公民」而产生的补丁。Peter Norvig 在 1998 年就指出过:在动态语言里,23 个模式中有 16 个「要么消失,要么简化到看不见」。策略模式 = 传一个函数。命令模式 = 传一个函数。观察者 = 一个函数列表。它们不是「智慧」,是「缺失的语言特性的绷带」。

(这不是说设计模式没用 —— 在 Java 里它们仍然有用。但你应该知道自己在补什么。)

下一章

我们已经知道函数是什么、闭包是什么。但有一件事一直被含糊过去了:这些东西,到底是按什么顺序被算出来的?

下一章我们把求值过程一步一步慢放。你会看到第 1 章那个 myIf 为什么必然崩溃,也会明白为什么 && 能短路而你写的函数不能 —— 这两个问题是同一个问题。