第 6 部分 · 语言设计与计算模型
写你自己的 lang——从内部 DSL 到一门独立语言
上一章你用“数据 + 解释函数 + 宏”造了一门住在 Racket 里的内部 DSL。这一章再往前走一步:造一门有自己
#lang、连源代码长什么样都由你定的独立语言。
卷六走到这儿,你已经集齐了造语言的全部零件:宏让你在编译期改写语法,延续和 generator 让你重塑控制流,模块系统让你组织代码。上一章的 MiniDraw 和 JSON DSL 都是“内部 DSL”——它们住在 Racket 的语法里,用 S-表达式写,靠 match 和宏工作。
但 Racket 给你的能力不止于此。还记得卷一第 3 章说的吗?Racket 是“一门用来创造编程语言的编程语言”。这个承诺的终极形态,就是让你造一门外部 DSL——一门有自己的 #lang、连源代码的写法都和 Racket 不一样的语言。
这一章带你走完最后一步:从零造一门能用的 #lang,看看 Racket 的语言创建机制到底是怎么运作的。
内部 DSL 和外部 DSL,差在哪
先把这个区别钉死。
内部 DSL 用 Racket 的语法写,只是借宏和数据给它定制了“词”。上一章的 MiniDraw 就是——你写的还是 S-表达式,还是用 match 解释,只是加了几个自定义的命令。它的好处是省事:不用写解析器,Racket 的 reader 替你把文本变成了数据。
外部 DSL 走得更远。它不满足于借用 Racket 的语法,它要定义自己的语法。你可能想要一种长得这样的语言:
Hello, world!
就一行纯文本,没有括号、没有 #lang、没有任何 Racket 的影子。用户写它的时候,根本意识不到底下是 Racket。这就是外部 DSL——它有自己的 reader,把这种自定义语法翻译成 Racket 能处理的东西。
为什么要费这个劲?因为有些领域的语言,S-表达式不是最顺手的写法。配置文件、模板、规则引擎,这些场景下人们更习惯贴近自己领域的写法。Racket 让你能为这些场景量身定做一门语言,而且不用从零写一个完整的编译器。
一门语言的三层结构
在动手之前,先看清 Racket 把“一门语言”拆成了哪几层。你在第 41 章见过这张表的雏形,这里把它讲透:
| 层 | 干什么 | 谁负责 |
|---|---|---|
| Reader(读取层) | 把源代码文本读进来,变成 S-表达式 | 自定义 reader 模块 |
| Expander(展开层) | 跑宏、展开语法,变成核心 Racket | Racket 自带 + 你的宏 |
| Runtime(运行层) | 真正执行展开后的代码 | Racket 运行时 |
#lang 指令就是在选这三层。你写 #lang racket,三层都用 Racket 默认的;写 #lang typed/racket,前两层换成了带类型检查的版本;写 #lang scribble/base,连 reader 都换了——scribble 的语法根本不是 S-表达式,而是文档标记。
要造自己的 #lang,你要做的事就是:写一个 reader 模块,定义你的语言怎么被读进来。 展开层和运行层,Racket 替你兜底。
[架构图:Racket 语言的三层管线——自定义 #lang 的核心就是写一个 reader]
第一步:一个最简单的自定义语言
先造一门最简单的语言,叫 hello。它的全部语法就是:用户随便写什么文本,程序运行时把这段文本原样打印出来。
这门语言需要两个文件。第一个是你的语言实现,叫 hello.rkt:
#lang racket/base
;; 这是 hello 语言的核心:把内容打包成一个模块导出
(provide (rename-out [my-module-begin #%module-begin]))
;; 自定义的 module-begin:接收所有顶层内容,打印出来
(define-syntax my-module-begin
(syntax-rules ()
[(_ body ...)
(#%module-begin
(displayln 'body) ...)]))
这段代码定义了一个宏 my-module-begin,它会替换 Racket 默认的 #%module-begin(每个 Racket 模块的内容都包在这个里面)。它把用户写的所有顶层内容 body ... 用 displayln 打印出来。
但这还不够。要让 #lang hello 工作,Racket 需要一个“语言声明”——一个告诉它“hello 这个 lang 该去找哪个模块”的桥梁。
第二步:语言声明文件
Racket 找 #lang 对应的语言时,会去一个固定位置找声明。最简单的办法是建一个叫 lang/reader.rkt 的文件,Racket 看到 #lang hello 就会去找 hello/lang/reader.rkt。
不过在你自己的目录里测试时,更省事的办法是用 #lang s-exp 直接指定模块。但为了讲清楚完整机制,我们走标准流程。在你的项目目录下建这样一个结构:
my-lang/
├─ hello.rkt ;; 语言实现(上面那段)
└─ lang/
└─ reader.rkt ;; 语言声明
reader.rkt 的内容简单到令人发指,它只是告诉 Racket:“用普通的 S-表达式方式读这个文件,然后用 hello.rkt 提供的语言来跑”:
#lang s-exp syntax/module-reader
hello
syntax/module-reader 是 Racket 提供的工具。第一行 #lang s-exp syntax/module-reader 加载它,第二行的 hello 就是你的语言实现模块名。
这个工具替你处理了所有繁琐的 reader 细节——它默认用标准的 S-表达式方式读源代码,然后把读进来的东西交给你的语言模块处理。
现在,如果你把这个 my-lang 目录做成一个 Racket 包安装好(用 raco pkg install),你就能写这样的文件了:
#lang hello
你好
世界
运行它,会打印:
你好
世界
恭喜——你刚造了一门编程语言。虽然它只能打印东西,但它有自己独立的 #lang,用户写它时根本不需要知道底下是 Racket。
第三步:换掉 reader,造真正“不一样”的语言
上面那门 hello 语言还借用了 S-表达式的语法——用户写的还是 你好 世界 这种被 Racket reader 认作符号的东西。真正的外部 DSL 要更进一步:自定义 reader,让源代码可以完全不是 S-表达式。
假设你要造一门叫 literal 的语言:它的源代码就是纯文本,程序运行时把这段文本原样打印。不用括号,不用任何 Racket 语法。
这需要你接管 reader——不再用 syntax/module-reader 的默认读取,而是自己写一个读取函数。修改 reader.rkt:
#lang racket/base
(require syntax/module-reader)
;; 自定义读取函数:把一整行文本读成一个字符串
(define (my-read in)
(read-line in))
(define (my-read-syntax src in)
;; read-syntax 要返回一个 syntax 对象,表示"读到的内容"
(define line (read-line in))
(if (eof-object? line)
line
;; 把这一行文本,包成一个 (module name racket/base (displayln "内容")) 的语法对象
(datum->syntax #f
`(module literal racket/base
(displayln ,line)))))
;; 把我们的读取函数交给 module-reader 框架
(provide (rename-out [my-read read] [my-read-syntax read-syntax]))
这里的关键是 my-read-syntax。它每次被调用时,从输入流里读一行文本,然后用准引用(quasiquote,那个反引号)把它包成一个完整的模块表达式:(module literal racket/base (displayln ...))。
这个表达式会被 Racket 当作“用户写的代码”来编译和运行。
用这个 reader,你的 literal 语言源代码可以是:
#lang literal
这是一行会被打印的文字
没有括号,没有引号,就是一行纯文本。运行它,它打印自己。从用户的角度看,这完全不像 Racket——但从你的角度看,底下用的全是 Racket 的机制。
真实的 #lang 都是怎么造的
到这里你已经摸到了门道。Racket 生态里那些真正的 #lang 语言,走的都是这条路:
#lang scribble:reader 把文档标记语言读成嵌套的结构,用一套宏把它渲染成 HTML 或 PDF#lang typed/racket:reader 基本是标准的,展开层加了一整套类型检查的宏#lang datalog:reader 把逻辑编程的语句读进来,展开成一个完整的逻辑推理引擎
它们的复杂度远超我们这两个小例子,但骨架是一样的:一个 reader 决定源代码怎么读,一组宏和运行时函数决定读进来的东西怎么跑。 Racket 把“造一门语言”这件别处需要从零写编译器的事,拆成了你能在几百行内完成的工程。
这套机制也不只属于「古典」语言。2024 年,William Bowman 用它造了一门和大模型对话的语言:#lang llm。它的源代码就是纯文本 prompt,只有 @ 开头的行才是 Racket 代码。装上 llm 包(raco pkg install llm),在 DrRacket 里建这样一个文件:
#lang llm
@(require llm/openai/gpt4o-mini)
What is 13 + 7?
运行它,模型会回答「13 + 7 equals 20.」,整个编辑区变成了 LLM 对话界面。源代码完全不像 Lisp——但拆开看,还是这一章讲的骨架:一个自定义 reader,决定「纯文本当 prompt、@ 行当代码」怎么读;一组运行时函数,负责把 prompt 发给模型。「造语言的语言」不是句口号,今天依然有人在用它造今天才有的语言。
这恰恰是 Racket 最独特的地方,也是这本书卷一说的“造语言的语言”的真正含义。别的语言给你一套定死的语法,Racket 给你造语法的工具。当你发现现有的语言都不顺手时,在 Racket 里你能造一门顺手的——这是绝大多数语言给不了你的自由。
写到这里,卷六的语言设计之旅就到头了。你已经从宏走到延续,从内部 DSL 走到自己造
#lang,亲手触摸到了 Racket 最核心的“语言创造语言”的能力。接下来的卷七,我们换一个节奏——从“理解语言”转向“用语言做产品”:Web、数据、打包、部署,把这本书学到的东西变成能上线的东西。