Racket 编程入门

第 7 部分 · 走向生产:工程化与实战

REPL——边想边跑的开发方式

Racket 让你把一个表达式敲进 REPL,按下回车就看到它的值。改一个函数,下一个调用就用新定义,不必重启、也不必重新编译——开发循环从“改、编译、运行、看”缩成“敲一行、看一行”。

你写过 C 或 Java,对那套节奏再熟不过:改一行代码,编译,启动程序,手动把程序一路点到出问题的那个角落,看一眼结果,发现不对,再回去改。真正用来思考的时间不多,大半耗在编译和重启上,外加一遍遍把程序重新“开”回那个状态。

你有没有想过,为什么“看看这段数据经过处理后变成什么样”这么小的一件事,要赔上整个编译-运行的代价?Racket 给了另一种节奏:你敲一个表达式,它立刻把值算出来还给你;你定义一个函数,下一行就能调它;你发现它写错了,重新定义一次,再调一次,新的就生效。整个过程里没有“编译”这一步——或者说,编译在你按下回车的那一刻就完成了。这种工作方式叫 REPL 驱动开发。

读、算、印,再读

REPL 是四个英文词的缩写:Read-Eval-Print Loop,读、算、印、循环。

它干的事就这三件,周而复始:读入你敲的一行表达式,算出它的值,把结果印出来,再把控制权交还给你。没有“先写完整个文件再运行”这一说,表达式来一条算一条。

打开终端,敲一个 racket 回车,你就站在一个 REPL 里了:

$ racket
Welcome to Racket, version 8.14.
> (+ 1 2)
3
> (string-append "hello" " " "world")
"hello world"
> (map (λ (x) (* x x)) '(1 2 3 4))
'(1 4 9 16)

每一行都是一次独立的小实验。你想看看 map 配一个匿名函数会输出什么,不必为此写一个完整程序、存成文件、编译、运行——直接在 > 后面敲,错了就改,对了再往上叠。

REPL 把“想验证一件事”的成本,压成了一行表达式。

DrRacket 的两扇窗:定义与交互

Racket 自带的 IDE DrRacket,把屏幕切成上下两半。上面叫定义区(Definitions),是你写程序的地方——函数、数据结构、模块都写在这里,它就是你的源代码。下面叫交互区(Interactions),是一个活着的 REPL,带 > 提示符。

两块之间的桥梁,是工具栏上的 Run 按钮。点一下 Run,DrRacket 把定义区的代码从头求值一遍,让里面的函数和数据在交互区“活”起来,随后你就能在下方的 > 后面随便调用它们、喂参数、看结果。

假设你在定义区写了一个统计字符串里元音个数的函数,点过 Run,交互区里就能直接试:

> (count-vowels "hello")
2
> (count-vowels "racket")
2

发现答案不对,回定义区改函数,再点一次 Run。Run 会重置交互区、把定义重新求值一遍,新的定义立刻生效。定义区管“程序该长什么样”,交互区管“程序跑起来是什么样”,Run 在两者之间搬一次货。

把整个文件搬进 REPL

如果你更习惯自己的编辑器,那典型姿势是:编辑器里写代码,终端里开一个 REPL。racket 启动的 REPL 提供了一个特殊形式 enter!,能把一整个文件的定义搬进当前 REPL:

$ racket
> (enter! "stats.rkt")

这一下,stats.rkt 里所有 define 出来的函数、所有导出的绑定,都在 REPL 里可用了,就像你刚在提示符后面把它们手敲了一遍。你直接调它们、试它们。

更顺手的是重新加载:你在编辑器里改了 stats.rkt,回到 REPL 再敲一次 (enter! "stats.rkt"),文件被重新读入、重新求值,新定义覆盖旧定义。整个过程不退出 REPL,也不丢你已经在提示符里建起来的测试数据。这就是命令行版的“点 Run”。enter! 由 racket/enter 提供,在默认的 XREPL 里直接可用,背后靠 Racket 的 dynamic-rerequire 检测源文件改动并重新加载。

改一个函数,立刻试

REPL 最值钱的地方,是调试时那个紧凑到几乎没有间隙的反馈环。看一个完整的小故事。你想写一个函数,统计字符串里有几个元音。先在 REPL 里把雏形敲出来:

> (define (vowel? c) (member c '(#\a #\e #\i #\o #\u)))
> (define (count-vowels s)
 (length (filter vowel? (string->list s))))
> (count-vowels "hello")
2

"hello" 里的 e 和 o 是两个元音,结果 2,看着对。再试一个:

> (count-vowels "HELLO")
0

预期也是 2,实际却是 0。问题出在哪:vowel? 只认小写,大写的 H、E 根本不在那张小写元音表里。修法很直接——统计之前先把字符串转成小写。不用关掉 REPL,不用重开,直接在原地重新定义 count-vowels:

> (define (count-vowels s)
 (length (filter vowel? (string->list (string-downcase s)))))
> (count-vowels "HELLO")
2

新定义当场生效,下一次调用 count-vowels 用的就是它。整个过程里 REPL 一直开着,vowel? 还在,你前面敲过的数据都还在。你修的是函数本身,不是“重新跑一遍程序,看这次会不会碰巧对了”。

把中间值摊在桌上

REPL 的另一面优势,是探索。面对一段多步的数据处理,你不必靠脑补去猜中间那一步长什么样——把每一步的结果都摊在 REPL 里看一眼。

假设你拿到一串用逗号分隔的数字,想算它们的和。你不确定 string-split 返回的具体样子,那就一步一步来:

> (define data "1,2,3,4,5")
> (string-split data ",")
'("1" "2" "3" "4" "5")
> (map string->number (string-split data ","))
'(1 2 3 4 5)
> (apply + (map string->number (string-split data ",")))
15

第一步看到 string-split 给的是字符串列表,第二步意识到要转成数字,第三步才 apply + 求和。每一行的中间值就印在它该在的位置,对不对一目了然。等你在 REPL 里把链路摸通,再把成型的表达式搬进定义区、包成一个函数。REPL 让你不必“一次写对整段程序”,而是“一小步一小步地摸过去”,每一步都有反馈。

程序是活的

把这些合在一起看。REPL 改变的不是语法,是“程序是什么”这个心智模型。在编译型语言里,程序是一段静态文本:你写它、编译它、运行它,它产出结果。在 REPL 驱动的开发里,程序更像一个一直运行着的活物:你跟它说话(敲表达式),它回答(印出值);你改它一处(重新定义),它当场就变了样。

调试不再是“改代码、重新编译、重启、重建现场、再看结果”,而是直接问那个活着的程序一句:这个值现在是多少?这个函数这么改对不对?它下一秒就回答你。程序不再是写完才运行的静态文本,而是一个你随时能搭话的活物。

等你写到更复杂的程序,会发现 REPL 配上 Racket 的宏系统还能再上一层:连“语言本身该提供什么”都能在 REPL 里现试现造。那是后面章节的事。在那之前,先把 racket 命令开起来,让一个 REPL 一直挂在手边——这是养成 Racket 工作节奏最快的方式。