Racket 编程入门

第 2 部分 · 基础:用 Racket 写点什么

函数与闭包——Racket 里动作如何成为值

在 Racket 里,函数和数字、字符串一样是一个值——能存进变量、塞进列表、传来传去。而让这一切成立的,是闭包:函数随身带着它出生时的环境。

你定义过函数,也调用过函数。但你有没有想过:一个“动作”本身,能不能像数字 42、字符串 "hello" 那样,成为一个可以传递、可以存储的值?

如果动作也是值,函数就不再是程序里那个特殊的成分,而是和数据平起平坐的东西。这篇讲 Racket 怎么把“动作”变成“值”——以及为什么闭包是这件事绕不开的一部分。

动作本身,能不能成为值

Racket 里最普通的表达式长这样:动作放在第一个位置,数据跟在后面。动作是“施加者”,数据是“被施加者”。

那么动作本身呢?它能不能也从“施加者”变成“被施加者”,像一个数据那样被传递、被存储?数字是值,符号是值,列表是值——那动作本身,为什么不能也是值?

接受这个想法,函数就自然出现了。

lambda:制造动作的表达式

Racket 用 lambda 表达式来构造函数:

(lambda (参数 ...) 表达式 ...)

从结构上看,它就是一张列表:第一个位置是 lambda,第二个位置是参数,后面跟着函数体——和普通表达式同一种形状。

但解释器对 lambda 有一条特殊规则:参数列表不会被求值,函数体也不会立刻被求值。求值一个 lambda,得到的不是一个数,而是一个“待执行的动作”,一个过程值(procedure)——和数字、字符串一样,是某种值:

(lambda (x) (+ x 1))
;; #<procedure>

既然是值,它就能和数字、字符串一样:存进变量、塞进列表、从函数里返回、当作参数传给另一个函数、在运行时动态拼出来。函数成为“一等公民”,不是谁来恩赐的特性。

而是“动作也是值”这个想法的直接结果。

调用,用的还是同一套语法

既然函数是一个值,调用它就不需要任何新规则。把这个值放在表达式的第一个位置,后面跟上参数——和调用 + 一模一样:

((lambda (x) (+ x 1)) 5)
;; 6

不需要专门的“函数调用语法”,也不用区分内置操作和用户自定义函数。同一种括号、同一种求值规则。

让计算以统一的方式发生,不分种类、不分来源。

词法作用域:绑定在书写时就定下

函数体里能看见什么?两样东西:自己的参数,以及在它定义处可见的变量。这条规则叫词法作用域(lexical scope),也叫静态作用域。

“词法”的意思是:一个名字指向哪个绑定,由它在代码里的位置决定,写下代码的那一刻就定下了——不依赖这个函数日后在哪里被调用、被谁调用。与之相对的是动态作用域,名字要等到调用时才去查找。Racket 和绝大多数现代语言一样,用的是词法作用域。

背后其实是一个很朴素的机制:lambda 被求值时,会创建一个新环境,记着自己的参数;同时,它还会把定义处能看到的变量一并“捕获”进来。这就自然引出了下一个概念。

闭包:函数带着出生时的环境

看一个经典的例子——一个“造加法器”的工厂:

(define make-adder
 (lambda (n)
 (lambda (x) (+ x n))))

(define add10 (make-adder 10))

(add10 5)
;; 15

注意内层那个 (lambda (x) (+ x n)):这里的 n 不是它的参数,那它凭什么还记得 n 等于 10?

因为 n 是这个内层函数被造出来时,外层环境里就存在的变量。make-adder 被调用时建起了一个新环境,n 绑定到 10;内层函数就在这个环境里出生,并把它一并带走。日后不管 add10 在哪里被调用,它手里的 n 始终是出生时的那个 10。

闭包并不神秘。

闭包(closure)就是函数,加上它出生时的环境。

racket-closure-capture-environment

求值器在创建一个 lambda 时,顺手为它存好了”当时能看见的所有变量”;只要这个值随身带着环境,它就能跨越任何作用域自由运行。

闭包不是功能,而是必然

回头看,闭包为什么会存在?它来自两个更基本的事实。

一,lambda 是一个表达式,是一个值。二,一个表达式的意义依赖于它所在的环境——同样一行 (+ x n),换一个环境,n 就可能是别的东西。

那么,当这个 lambda 被当作值传出去、离开它原本的位置时,它必须把出生时的环境也一起带上,否则这行表达式就跑不完整。所以闭包不是某个人刻意“设计”出来的功能。

而是语言为了让表达式保持独立、可移动,被迫得出的必然结构。

这是语言一致性的体现:既然动作是值、值又要能到处跑,那它就得自带环境。

由此展开的函数式风格

有了“动作是值”和“闭包”,很多东西自然就长出来了:

  • 高阶函数:函数接收函数、返回函数。map、filter、foldl 都是把一个函数当参数传进去。
  • 工厂函数与部分应用:像 make-adder 这样现场造一个新函数,或者用 curry 固定掉前几个参数,留下一个新函数慢慢用。
  • 更高级的构造:惰性求值器、解释器、内部 DSL——底层都是 lambda 加闭包在撑。

这些都不是语言额外“提供”的能力,而是 lambda 表达式加上闭包的自然后果。随手看一个最常见的场景——把一个函数值喂给 map:

(define double (lambda (x) (* x 2)))

(map double '(1 2 3))
;; '(2 4 6)

double 是个值,map 收下它、对列表里每个元素调用一遍。高阶函数的全部秘密就在这里。

理解了函数与闭包,你掌握的就不仅是 Racket 的语法,更是它的计算模型。

函数式编程不是额外的范式,而是 Racket 表达式体系顺理成章的延伸。