装机:DrRacket,与两个被埋没的杀器
这一章五分钟就能读完前半段:装上、跑通、认识 REPL。但请你务必读完后半段。DrRacket 里藏着两个工具 —— Stepper 和 Macro Stepper —— 它们能把「求值」和「宏展开」这两个原本只存在于你脑子里的过程,一步一步画在屏幕上。大多数 Racket 教程只字不提它们,而这本书接下来的二十三章,几乎每一章都要用到。它们是你读这本书的两只眼睛。
先确认它活着
你已经装了 Racket。打开终端,确认一下:
$ racket --version Welcome to Racket v9.2 [cs].
顺便记住三个命令行工具,它们都在同一个安装包里:
| 命令 | 干什么 | 什么时候用 |
|---|---|---|
racket | 解释器。racket foo.rkt 跑一个文件,racket 直接进 REPL | 写脚本、快速试 |
raco | Racket 的「瑞士军刀」:raco test 跑测试、raco pkg install 装包、raco exe 打包成可执行文件 | 工程化 |
drracket | 那个 IDE。这本书的主战场 | 学习、调试、看 Stepper |
请用 DrRacket,别用 VS Code。我知道你有一套用了多年的编辑器配置,我也知道换 IDE 很烦。但这本书要用的那两个工具只在 DrRacket 里有,而它们值这个代价。等你读完,想搬回 VS Code(有个不错的 Magic Racket 插件)随时可以。
DrRacket 的三个区
打开 DrRacket,你会看到一个上下分割的窗口:
| 区域 | 它是什么 | 类比 |
|---|---|---|
| 上半:定义区 | 你的源文件。写函数、写定义 | 你的 .kt 文件 |
| 下半:交互区(REPL) | 一个活着的求值器。你敲一个表达式,它立刻算给你看 | Kotlin 的 Scratch 文件 ×10 的威力 |
| Run 按钮 | 把定义区的东西装载进交互区 | 不是「运行程序」,是「把这些定义搬到手边」 |
在定义区敲下这段,然后按 Run:
#lang racket (define (square x) (* x x)) (define (sum-of-squares a b) (+ (square a) (square b)))
看起来什么都没发生 —— 因为你没让它打印任何东西。但事情发生了:那两个函数现在活在下面的交互区里了。在交互区敲:
> (square 7) 49 > (sum-of-squares 3 4) 25 > (square (square 3)) 81 > square #<procedure:square>
注意最后一行:你直接敲了 square 这个名字,没有调用它,它就把自己打印了出来 —— 一个「过程」。它是一个值,和 49 是一个等级的东西。这件事在第 6 章会展开,但你现在就可以先感受一下。
那个 #lang racket 是什么
第一行的 #lang racket 不是注释,也不是「import」。它在说一件很大的事:
「这个文件,请用『racket』这门语言来读。」
注意「读」这个字。它管的不只是标准库 —— 它决定了这个文件的语法本身怎么解析、怎么展开、怎么求值。换个 #lang,同一个文件可以变成一门完全不同的语言:不同的语法、不同的求值规则,甚至可以不用括号。
Racket 里有 #lang racket/base(精简版)、#lang typed/racket(带静态类型)、#lang lazy(惰性求值,像 Haskell)、#lang datalog(逻辑编程)、#lang scribble(写文档的)…… 它们都跑在同一个运行时上。
而你可以造自己的。这是第 23 章的内容,也是 Racket 区别于所有其他 Lisp 的那件事。现在你只需要知道:这一行不是样板代码,它是一个悬念。
本书全程用 #lang racket,除了两个地方:讲 Stepper 时会临时切到教学语言(马上就说),讲 #lang 时会造一门自己的。
REPL 驱动开发:一种你可能没体验过的节奏
你现在的开发循环大概是这样的:
改代码 → 编译(等 30 秒)→ 跑起来 → 点到那个页面 → 触发那个逻辑 → 看 Logcat → 发现打错了一个字 → 回到第一步
Lisp 程序员的循环是这样的:
改一个函数 → 光标放上去 → 按一下键 → 它已经生效了 → 在 REPL 里直接调它 → 看结果 → 不对?改一行 → 再按一下键
区别不是「快一点」,是量级不同。你不是在「运行程序」,你是在和一个活着的程序对话。它一直开着,你一块一块地往里加东西、改东西,随时问它问题。
具体到 DrRacket,姿势是这样的:
- 不要在定义区写
(displayln ...)来调试。那是println思维。 - 在定义区只写定义(
define),然后 Run,然后在交互区里玩。 - 写一个函数 → Run → 在交互区试三个输入 → 发现边界情况错了 → 回去改 → Run → 再试。
- 交互区里可以直接访问定义区的一切,包括你没导出的内部函数。
定义区写:
#lang racket
(define (fizzbuzz n)
(cond
[(= 0 (modulo n 15)) "FizzBuzz"]
[(= 0 (modulo n 3)) "Fizz"]
[(= 0 (modulo n 5)) "Buzz"]
[else (number->string n)]))
按 Run,然后在交互区连着敲这几行,每一行都会立刻给你答案:
> (fizzbuzz 3)
"Fizz"
> (fizzbuzz 15)
"FizzBuzz"
> (map fizzbuzz (range 1 16))
'("1" "2" "Fizz" "4" "Buzz" "Fizz" "7" "8" "Fizz" "Buzz" "11" "Fizz" "13" "14" "FizzBuzz")
最后那一行就是重点。你刚才用一行代码,把整个函数在 15 个输入上跑了一遍并看到了全部结果 —— 没有写测试文件、没有写 main、没有编译。这种「随时能把整个东西摊开看一眼」的能力,会改变你写代码的方式。
杀器之一:Macro Stepper
现在是这一章真正的重点。
第 17 到 20 章,我们要讲宏。宏最大的困难不是写不出来,是你看不见它做了什么 —— 它在你的代码被求值之前就已经把代码改写掉了,等你想 println 调试的时候,早就没有现场了。
Macro Stepper 就是那台把现场录下来的摄像机。
在 DrRacket 里:写好代码 → 菜单栏找到 Macro Stepper(在 View 或 Racket 菜单下,不同版本位置略有出入)→ 点开。它会把你的程序从原始形态到完全展开的每一步展开都列出来,还会高亮每一步动了哪里。
不想开 GUI?它还有个文本接口,直接在终端里就能用:
#lang racket (require macro-debugger/stepper-text) (define-syntax-rule (my-unless c body) (if c (void) body)) (expand/step-text #'(my-unless #f (displayln "hi")))
跑出来是这样(我截了前几步):
Macro transformation (my-unless #f (displayln "hi")) ==> (if:1 #f (void:1) (displayln "hi")) Add explicit #%datum (if:1 #f (void:1) (displayln "hi")) ==> (if:1 (#%datum . #f) (void:1) (displayln "hi")) Macro transformation (if:1 (#%datum . #f) (void:1) (displayln "hi")) ==> (if:1 (quote #f) (void:1) (displayln "hi"))
三件事值得你现在就注意到:
① 你的宏只是第一步。my-unless 展开成 if 之后,展开还在继续:#f 被包成 (#%datum . #f),函数调用被包成 (#%app ...)…… 原来 #lang racket 里连「写一个字面量」「调用一个函数」都是宏。你看到的 Racket,是一层一层宏堆出来的。底下真正的核心语言只有十来个形式。
② 那些 :1 :2 的后缀。if:1、void:1 —— 这些数字不是行号。它们是卫生标记:Macro Stepper 在告诉你「这个 if 是宏引入的,不是你写的」。第 19 章你会看到,这个小小的数字,就是「宏为什么不会污染你的变量」的全部答案。
③ 展开是有方向的,而且不可逆。宏在求值之前就跑完了。等程序开始跑的时候,你的宏早已尸骨无存 —— 只剩下它生成的代码。这就是为什么调试宏不能靠打日志。
杀器之二:Stepper(注意,它有个限制)
Macro Stepper 给你看「展开」。而 Stepper 给你看的是求值本身 —— 它把 (+ 1 (* 2 3)) 一步一步归约成 7 的每一个中间状态都画出来。
这正是第 7 章那台「求值步进器」在做的事,而 DrRacket 自带一个真的。
这是官方文档的原话(drracket/buttons):
「The Step button —— which appears only for the How to Design Programs teaching languages Beginning Student through Intermediate Student with Lambda —— starts the Stepper…」
翻译:Stepper 只在 HtDP 教学语言下可用。你用 #lang racket 时,那个按钮压根不在。
反过来也成立:Debug 按钮在教学语言下不出现,它只在 #lang racket 这类完整语言里出现,而且它是一个常规的断点调试器(下断点、单步、看变量值),不是代数归约。
两个工具,两个世界,不重叠。这是很多人被绊倒的地方 —— 包括你如果照着别的教程去 #lang racket 里找 Step 按钮,会找一晚上。
所以想用 Stepper,你得临时切一下语言:把第一行换成教学语言,或者在 DrRacket 的语言菜单(Language → Choose Language)里选 「Intermediate Student with lambda」。然后 Step 按钮就出现了。
#lang htdp/isl+ ; Intermediate Student with lambda
; ↑ 只是为了用 Stepper。本书其余部分都用 #lang racket
(define (f x) (* x x))
(+ 1 (f (- 5 3)))
点 Step,你会看到:
(+ 1 (f (- 5 3))) → (+ 1 (f 2)) ; 先算最内层的参数 → (+ 1 (* 2 2)) ; f 被应用:函数体里的 x 换成 2 → (+ 1 4) → 5
这四行,就是这本书卷 II 的全部内容。你在第 7 章会反复回到这张图 —— 求值不是魔法,它就是「反复找到一个能动手的子表达式,把它换成它的值,换到没得换为止」。
教学语言的限制(不能用 set!、库函数少一些)在这里反而是优点:Stepper 之所以能画出这么干净的归约链,正是因为教学语言里没有那些让归约变得混乱的东西。这不是残废版 Racket,这是为了让求值过程可见而设计的 Racket。
这几个「教学语言」不是 Racket 的玩具模式,它们背后是一整套课程设计。《How to Design Programs》(HtDP)是 Matthias Felleisen 等人写的编程入门教材,Racket 这门语言最初就是为了教它而造的(那时候它还叫 PLT Scheme)。
HtDP 的核心贡献是「设计配方」(design recipe) —— 一套把「怎么写出这个函数」从灵感变成流程的方法。这是本书第 9 章的内容,也是我认为整本书里最能直接改善你日常工作的一章,哪怕你以后再也不碰 Racket。
另一本紫皮书 SICP(《计算机程序的构造和解释》)用的是 Scheme,走的是另一条路:不教你怎么设计程序,教你语言本身是怎么造出来的。本书的卷 VI(元循环求值器)走的是 SICP 的路。这两本书是这本小书的两个祖宗,第 25 章会告诉你接下来该读哪本。
命令行:当你真的要做点东西的时候
DrRacket 适合学,但你迟早要写脚本、跑测试、发布东西。这些是命令行的活儿:
$ racket hello.rkt # 跑一个文件 $ racket # 直接进 REPL $ racket -i -l racket # 带完整 racket 库的 REPL $ raco test foo.rkt # 跑文件里的测试 $ raco pkg install threading # 装一个第三方包 $ raco exe hello.rkt # 编译成独立可执行文件(!) $ raco docs sort # 查 sort 的文档,直接开浏览器
raco exe 值得单说一句:它能把你的 Racket 程序打包成一个不依赖 Racket 安装的可执行文件(配合 raco distribute 打包依赖)。所以 Racket 不只是教学玩具 —— 它是能发布真东西的。
测试也很轻:
#lang racket (require rackunit) (define (square x) (* x x)) (check-equal? (square 4) 16) (check-equal? (square -3) 9) (check-not-equal? (square 2) 5)
直接 raco test 文件名,或者在 DrRacket 里按 Run —— 测试就是普通的顶层表达式,跑文件就等于跑测试。没有测试框架的仪式感,没有 @Test 注解,没有单独的 test source set。第 24 章讲契约的时候会回到这里。
回流
① 把 REPL 驱动的姿势搬回你的语言。你的语言大概率有一个你从没认真用过的 REPL:Kotlin 有 kotlinc 的 REPL 和 IntelliJ 的 Scratch File(Ctrl+Alt+Shift+Insert),JS 有 Node REPL 和浏览器 Console。大多数工程师只在「验证一个 API 怎么用」时才打开它们,然后就关掉了。试着反过来:先在 Scratch 里把逻辑跑通,再搬进项目。你会发现自己按「编译」的次数少了一大半。
② 找到你的语言里的「Macro Stepper」。Kotlin 有:Tools → Kotlin → Show Kotlin Bytecode → Decompile,它会告诉你 data class、inline、suspend 到底生成了什么。JS 有:Babel REPL / TS Playground 的输出栏,看 async/await 被编译成了什么状态机。这些工具和 Macro Stepper 是同一类东西:把「编译器替你做的事」显形。用它们的人和不用它们的人,对语言的理解不在一个层次上。
③ #lang 这个悬念,先记在心里。你现在只需要知道:在 Racket 里,「一门语言」是一个可以被 import 的、普通的模块。这句话现在听起来像废话,第 23 章你会发现它有多疯狂。
准备好了
你现在有:一个装好的 Racket、一个能对话的 REPL、一台能看见求值的 Stepper、一台能看见宏展开的 Macro Stepper。
这是一整套 X 光机。接下来的二十三章,我们要用它照的第一个东西,是你最讨厌 Lisp 的那个地方:那堆括号。
下一章我会证明给你看:那不是噪音。那是别的语言藏起来、而你每天都在猜的东西。