卷 I · 读CH 02深度 02/25

装机:DrRacket,与两个被埋没的杀器

这一章五分钟就能读完前半段:装上、跑通、认识 REPL。但请你务必读完后半段。DrRacket 里藏着两个工具 —— Stepper 和 Macro Stepper —— 它们能把「求值」和「宏展开」这两个原本只存在于你脑子里的过程,一步一步画在屏幕上。大多数 Racket 教程只字不提它们,而这本书接下来的二十三章,几乎每一章都要用到。它们是你读这本书的两只眼睛。

DrRacketREPLStepperMacro Stepperraco

先确认它活着

你已经装了 Racket。打开终端,确认一下:

$ racket --version
Welcome to Racket v9.2 [cs].

顺便记住三个命令行工具,它们都在同一个安装包里:

命令干什么什么时候用
racket解释器。racket foo.rkt 跑一个文件,racket 直接进 REPL写脚本、快速试
racoRacket 的「瑞士军刀」: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」。它在说一件很大的事:

◆ #lang 这一行的真正含义

「这个文件,请用『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 → 再试。
  • 交互区里可以直接访问定义区的一切,包括你没导出的内部函数。
▶ 动手:感受一下 REPL 的密度

定义区写:

#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:1void:1 —— 这些数字不是行号。它们是卫生标记:Macro Stepper 在告诉你「这个 if 是宏引入的,不是你写的」。第 19 章你会看到,这个小小的数字,就是「宏为什么不会污染你的变量」的全部答案。

③ 展开是有方向的,而且不可逆。宏在求值之前就跑完了。等程序开始跑的时候,你的宏早已尸骨无存 —— 只剩下它生成的代码。这就是为什么调试宏不能靠打日志。

杀器之二:Stepper(注意,它有个限制)

Macro Stepper 给你看「展开」。而 Stepper 给你看的是求值本身 —— 它把 (+ 1 (* 2 3)) 一步一步归约成 7每一个中间状态都画出来。

这正是第 7 章那台「求值步进器」在做的事,而 DrRacket 自带一个真的。

⚠ 但是:Step 按钮不会出现在 #lang racket 里

这是官方文档的原话(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。

✎ 掌故 · HtDP 与那本紫皮书

这几个「教学语言」不是 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 classinlinesuspend 到底生成了什么。JS 有:Babel REPL / TS Playground 的输出栏,看 async/await 被编译成了什么状态机。这些工具和 Macro Stepper 是同一类东西:把「编译器替你做的事」显形。用它们的人和不用它们的人,对语言的理解不在一个层次上。

#lang 这个悬念,先记在心里。你现在只需要知道:在 Racket 里,「一门语言」是一个可以被 import 的、普通的模块。这句话现在听起来像废话,第 23 章你会发现它有多疯狂。

准备好了

你现在有:一个装好的 Racket、一个能对话的 REPL、一台能看见求值的 Stepper、一台能看见宏展开的 Macro Stepper。

这是一整套 X 光机。接下来的二十三章,我们要用它照的第一个东西,是你最讨厌 Lisp 的那个地方:那堆括号。

下一章我会证明给你看:那不是噪音。那是别的语言藏起来、而你每天都在猜的东西。