卷 VI · 造语CH 23深度 23/25

#lang:一门语言就是一个模块

第 2 章我给你留了个悬念:文件第一行那个 #lang racket,不是 import,而是「用哪门语言来这个文件」。现在兑现它。这是 Racket 区别于所有其他 Lisp(乃至几乎所有语言)的终极武器:在 Racket 里,「一门语言」就是一个普普通通的、可以被 require 的模块。你造的语言,能复用 Racket 的读取器、宏系统、错误报告、甚至 DrRacket 的 IDE 支持。上一章你手写解释器手写到累 —— 这一章你会看到,造语言本可以不必那么累。

#lang模块#%module-beginreaderDSL

先看看已经存在的那些 #lang

Racket 不是「一门语言」,是「一个造语言的平台,上面碰巧住着一门叫 racket 的语言」。看看官方和社区已经造出来的:

#lang它是什么
#lang racket完整的 Racket(我们这本书用的)
#lang racket/base精简版,启动快,库少
#lang typed/racket带静态类型系统的 Racket —— 真正的类型检查,不是注释
#lang lazy惰性求值的 Racket,像 Haskell(第 12 章那个自引用的 nat 就靠它)
#lang scribble/base写文档的语言(Racket 自己的文档就是用它写的)
#lang datalog逻辑编程语言,和 Racket 语法完全不同
#lang slideshow做幻灯片的语言
#lang br/...《Beautiful Racket》里教你造的一堆玩具语言(BASIC、正则、栈机……)

它们全都跑在同一个运行时上,能互相 require,共享工具链。一个 typed/racket 模块可以被一个 racket 模块调用。这在别的语言世界里是不可想象的 —— 想象一下「一个 Python 文件里 import 一个用你自己发明的语法写的文件,而且 IDE 还能给两边都做补全」。

关键的思想转变

◆ 一门语言 = 一个模块

#lang foo 的意思,精确地说,是:「用 foo 这个模块提供的读取器来解析这个文件,然后在 foo 提供的绑定下求值它。」

换句话说,一门语言需要提供两样东西:

  1. 一个 reader:怎么把文件的文本读成语法树?(默认可以直接借 Racket 的 S-表达式读取器 —— 那你就不用写 parser
  2. 一组绑定 + 一个 #%module-begin:读出来的每个顶层形式,是什么含义?整个模块怎么组织?

而这两样东西,都写成普通的 Racket 模块。造语言 = 写模块。这就是那句「一门语言就是一个模块」的全部含义。

造语言的两个层次

造一门 #lang,你有两个由浅入深的入口:

层次一:换语义,不换语法(借 Racket 的读取器)

如果你的语言还是用 S-表达式(括号语法),你就完全不用写 parser —— 直接借 Racket 的。你只需要重新定义「这些形式是什么意思」。核心是拦截 #%module-begin(每个模块隐式被它包着)。

举例:造一门「每个顶层表达式自动打印自己的值」的语言(像 REPL,但是文件)。核心就一个宏:

; lang/main.rkt —— 定义语言的语义
#lang racket
(provide (rename-out [my-module-begin #%module-begin])
         #%datum #%app #%top + - * /)   ; 决定这门语言允许用哪些东西

(define-syntax-rule (my-module-begin form ...)
  (#%module-begin
   (displayln form) ...))               ; ← 每个顶层 form,自动打印

然后用它:

#lang reader "lang/main.rkt"
(+ 1 2)          ; 自动打印 3
(* 3 4)          ; 自动打印 12
◆ 那个 provide 列表,就是你在「设计语言」

看上面 provide 里列的东西:+ - * /#%app(函数调用)、#%datum(字面量)。没有 provide 的东西,这门语言里就不存在

不 provide #%app?那这门语言里不能调用函数。不 provide set!?那它没有可变状态「一门语言允许什么」,就是「你 export 了什么」。

这是一个惊人朴素、又惊人强大的想法:语言的边界,就是一个模块的导出列表。教学语言(第 2 章那个 HtDP)就是这么做的 —— 「Beginning Student」之所以功能少,就是因为它的语言模块只 export 了一小部分东西。

层次二:连语法一起换(自己写读取器)

如果你想要非括号的语法,就得提供自己的 read / read-syntax。它们的工作:读文本,吐出语法树。吐出来之后,后面全程复用 Racket 的宏、编译、运行时。

我真的造了一门这样的小语言 —— 后缀表达式(逆波兰式)语言。它的读取器把 3 4 + 这样的后缀记法,读成 (+ 3 4):

; postfix.rkt —— 一门后缀语法的语言(核心逻辑)
#lang racket
(provide (rename-out [my-read-syntax read-syntax] ...))

(define (my-read-syntax src in)
  ; 读入所有 token
  (define toks (读全部 in))
  ; 用一个栈,把后缀式折成一棵前缀树
  (define tree
    (let loop ([toks toks] [stack '()])
      (cond
        [(null? toks) (car stack)]
        [(运算符? (car toks))
         ; 遇到运算符:弹两个,组成 (op a b),压回去
         (loop (cdr toks)
               (cons (list (car toks) (cadr stack) (car stack)) (cddr stack)))]
        [else (loop (cdr toks) (cons (car toks) stack))])))
  ; 把这棵树包成一个能运行的模块
  (datum->syntax #f `(module m racket (displayln ,tree))))

用它写的程序,长得完全不像 Lisp:

#lang reader "postfix.rkt"
3 4 + 5 *
35

它真的跑出了 35(即 (3+4)×5)。这是一门没有括号、用后缀语法的语言,但它跑在 Racket 运行时上,复用了 Racket 的一切基础设施。我只写了那个「把后缀折成树」的读取器 —— 大约二十行 —— 剩下的全是白送的。

◆ 「白送的」到底有多少?这才是重点

造一门传统语言,你要写:词法分析器、语法分析器、AST、类型检查器(可选)、求值器/编译器、运行时、标准库、错误报告、调试器、IDE 插件……几万行,几年工作。

造一门 Racket #lang,你只写你的语言独特的那部分(上面就是那个读取器),其余全部继承:

  • 宏系统(卷 V 那一整套,卫生的)
  • 编译器 + 运行时(编译到和 Racket 一样高效的字节码)
  • 模块系统、包管理(raco pkg)
  • 错误报告(带源码位置)
  • DrRacket 的 IDE 支持 —— 语法高亮、跳转定义、甚至 Macro Stepper,你的新语言免费拥有

这就是「语言就是模块」的真正回报:造语言的边际成本,从「几年」降到了「一个下午」。Racket 因此成了一个语言实验室 —— 研究者用它试验新的语言想法,不用每次都从零搭一整套基础设施。

这件事为什么别人做不到

为什么 #lang 是 Racket 独有的,而不是每门语言都有?因为它需要前面所有章节的东西同时到位:

  • 同像性(第 4 章):代码是数据,所以「语言」可以是一个操作代码的普通模块
  • 卫生宏(第 19 章):语言的语义大量用宏定义,而宏必须能安全组合
  • 可编程的读取器:解析这一步本身是开放的、可替换的
  • 阶段系统(第 17 章):语言定义(展开期)和程序运行(运行期)干净地分开

缺任何一个,#lang 都成立不了。这就是为什么它是 Racket 的皇冠 —— 它是前面每一块拼图拼齐之后才涌现出来的能力。Lisp 家族里其他方言(Clojure、CL)有宏、有同像性,但都没有把「语言」抽象成「模块」抽象到这个程度。

回流

⟲ 回流 · 你不会造 #lang,但你会重新理解「内部 DSL」

你几乎肯定不会在工作里去造一门 Racket #lang但这一章给你的,是一把衡量「DSL 方案」的尺子。

① 认清「内部 DSL」的天花板。你在 Kotlin/JS 里见过的那些漂亮 DSL —— Gradle Kotlin DSL、Compose、Ktor 路由、Jest 的 describe/it、SwiftUI —— 它们全是「内部 DSL」:借宿主语言的语法,凑出一个看起来像新语言的东西。

// Kotlin 的内部 DSL:漂亮,但仍受制于 Kotlin 的语法
routing {
    get("/users") { call.respond(users) }
}

它们的天花板,正是宿主语言的语法。Kotlin 的 DSL 逃不掉 { }->;它们不能有自己的操作符优先级、不能有真正的新语法、不能在编译期做宿主不支持的检查。这就是「内部 DSL」和「真正的语言(外部 DSL)」的分界线 —— 而 Racket 的 #lang 是唯一能轻松跨过这条线的东西。

② 用这把尺子做技术决策。当团队讨论「我们要不要给这个东西做个 DSL」时,你能问出关键问题:

  • 内部 DSL(借宿主语法)够用吗?——大多数时候够,而且便宜。先试这个
  • 需要自己的语法吗?——那就是外部 DSL,你要写 parser(ANTLR、parser combinator),成本陡增。想清楚值不值。
  • 用户是非程序员吗?——那语法友好度就是核心,可能真的需要外部 DSL。

③ 一个视角上的收获:「语言」和「库」的界限是模糊的。你一直以为语言是一回事(神圣、固定),库是另一回事(你随便用)。Racket 告诉你:语言可以是库。这个认识会让你在设计大型框架时更大胆 —— 一个足够好的库(带 DSL、带编译期检查、带工具支持),和一门「语言」之间,只隔着一层你以为存在、其实未必存在的墙。就像第 4 章那堵「代码和数据之间的墙」一样,这也是一堵可以拆的墙。

下一章

造语言的旅程到此为止。最后两章,我们从云端落回地面。

下一章讲 契约(contract) —— 一个比类型晚、比断言强的东西。它回答一个你在动态语言里一定会问的问题:「没有静态类型,怎么保证正确性?」而它对「当出错时,到底是谁的错」这个问题的回答,精妙得会让你重新思考你项目里的每一个 require 检查和 API 边界。