第 6 部分 · 语言设计与计算模型
造一个 DSL——Racket 让你设计自己的语言
SQL、CSS、正则、HTML,都是为某个领域量身定做的小语言。如果有一天你想要的那个小语言没人替你做,你能不能自己造一个?Racket 说能——而且不用从零写解析器。
你已经用过不少“小语言”了:用 SQL 查数据,用 CSS 描述样式,用正则匹配文本,用 Makefile 编排构建。它们都是 DSL(domain-specific language,领域特定语言)——不图通用,只为某一类问题存在,所以在自己的地盘里比任何通用语言都顺手。
你也多半动过“自己造一个”的念头:项目的配置文件越来越长,某种查询长得像一门语言却又不是,模板代码越写越重复。可一旦真要动手,多数语言会把你推向同一条路——写词法分析器、写语法解析器、搭一棵抽象语法树,等于先实现半个编译器。
Racket 的不同之处在于,这些底层活儿它替你做完了。这篇接着系列宏那篇往下讲:宏给你改写语法的能力,而 DSL 是这个能力最值得的去处。
什么是 DSL
DSL 是“为某一类问题量身定做”的语言,和 C、Python、Java 这种“什么都能干”的通用语言相对。
你身边到处都是:
| DSL | 为哪件事而生 |
|---|---|
| SQL | 描述“要什么数据”,不描述“怎么找” |
| CSS | 声明“长什么样”,不声明“怎么画” |
| 正则 | 描述“什么模式”,不描述“怎么匹配” |
| HTML | 描述“页面结构”,不描述“怎么渲染” |
它们都只关心“要什么”,把“怎么做”交给解释器。这正是 DSL 顺手的根源:在自己的领域里,它让你用最短的句子把意思说完。
什么时候你会需要一个 DSL?当你发现自己在一个通用语言里反复写同样形状的代码——配置对象、查询构造、UI 描述——那往往是一个还没被取名的 DSL 在敲你的门。
代码即数据,解析器是免费的
在大多数语言里,源代码是一串文本,把它变成能执行的东西要走一条长长的单行道:
源代码文本 → 词法分析(拆 token)→ 语法分析(建抽象语法树)→ 编译 → 执行
想在主流语言里造一门 DSL,等于在这条管线最前面再插一段你自己的解析逻辑。
Racket 不一样。它的源代码本身就是 S-表达式——一种树形数据结构。
你写的代码,和你用 read 读进来的数据,是同一棵树。 这叫同象性(homoiconicity)。read 能把一段文本直接解析成 Racket 的列表:
(read (open-input-string "(move 10 20)"))
;; '(move 10 20)
这意味着造语言的第一步——把文本解析成树——Racket 已经替你做完了。想自己跑数据,可以用 eval;它需要一个命名空间,make-base-namespace 给你一个带基础库的空环境。这条路能走,但写一个显式的解释函数通常更清楚,也更安全。下面我们就这么干。
Racket 把“语言怎么工作”拆成三层,每一层都是你能换的零件:
| 层 | 干什么 | 你能定制什么 |
|---|---|---|
| Reader(读取层) | 文本 → S-表达式 | 换 #lang 就换 reader |
| Expander(展开层) | 跑宏、改写语法 | define-syntax 在这里工作 |
| Runtime(运行层) | 真正执行代码 | 命名空间、provide 和 require |
文件开头的 #lang racket 就是在选 reader 和一整套语言。换成 #lang scribble/base,同一套机制跑的是文档标记语言;换成 #lang typed/racket,展开阶段就多了一道类型检查。
整个 Racket,本身也是用这套三层机制堆起来的一座语言塔。
这篇先不碰自定义 #lang——那是造一门“外部 DSL”,要单独写 reader 模块。我们用更轻的办法造“内部 DSL”:在 Racket 里用数据加一个解释函数,必要时配一个宏。九成的需求,这样就够了。
造一个会画图的迷你语言
来造一门画图语言,叫 MiniDraw。它能描述三件事:移动笔尖、画直线、设置颜色。我们不接真正的图形库,只把动作打印出来,看一门语言是怎么转起来的。
先设计“语法”。在 Racket 里,这等于设计数据长什么样:
'(draw
(set-color red)
(move 10 10)
(line-to 100 10)
(line-to 100 50))
注意最前面的 '——这一整个东西,在 Racket 眼里不是“代码”,而是一个嵌套列表,一棵树。draw 是根,里面挂着一串命令,每条命令又是 (命令 参数 …) 的列表。
这棵命令树,就是这门语言的全部源代码。
给它写个解释器
光有数据还不算语言,得有东西去解释它。写一个函数,按命令逐条处理:
#lang racket
; 解释单条命令
(define (eval-command cmd)
(match cmd
[(list 'set-color color)
(printf "Set color to ~a\n" color)]
[(list 'move x y)
(printf "Move to (~a, ~a)\n" x y)]
[(list 'line-to x y)
(printf "Line to (~a, ~a)\n" x y)]
[else
(error 'eval-command "unknown command: ~a" cmd)]))
; 解释整段程序:开头必须是 draw
(define (eval-draw program)
(match program
[(list 'draw commands ...)
(for-each eval-command commands)]
[else
(error 'eval-draw "program must start with draw: ~a" program)]))
match 按结构拆列表:(list 'set-color color) 匹配一个两元素列表、第一个是 set-color 符号,把第二个元素绑到 color。commands ... 匹配“剩下的全部”,收成一个列表交给 for-each。
跑一下:
(eval-draw
'(draw
(set-color red)
(move 10 10)
(line-to 100 10)
(line-to 100 50)
(set-color blue)
(line-to 10 50)))
输出:
Set color to red
Move to (10, 10)
Line to (100, 10)
Line to (100, 50)
Set color to blue
Line to (10, 50)
到这里结论很朴素:一个 DSL 的内核就是两样东西,一种数据格式,加一个解释它的函数。 没有 lexer,没有 parser,没有 AST 要搭——Racket 的列表就是 AST。
让语言长出新的词
加功能,等于在 match 里加一条规则。比如 (repeat n 命令 …),把里面的命令重复 n 次:
(define (eval-command cmd)
(match cmd
[(list 'set-color color)
(printf "Set color to ~a\n" color)]
[(list 'move x y)
(printf "Move to (~a, ~a)\n" x y)]
[(list 'line-to x y)
(printf "Line to (~a, ~a)\n" x y)]
; 新增:把 body 里的命令重复 n 次
[(list 'repeat n body ...)
(for ([_ (in-range n)])
(for-each eval-command body))]
[else
(error 'eval-command "unknown command: ~a" cmd)]))
现在能这么写:
(eval-draw
'(draw
(move 0 0)
(repeat 3
(line-to 10 0)
(move 0 0))))
“造语言”在这里变成了一件很具体的事:想加一个词,就描述它的形状、写它该干什么。变量、条件、循环、函数——只要愿意,都能以同样的方式加进去。这不是 Racket 赏的现成功能,而是“数据加 match”自然长出来的能力。
宏,把数据提升为语法
目前的 MiniDraw 有个不痛快:每次调用都得在前面挂个 ',还得点名 eval-draw:
(eval-draw '(draw (move 10 10) (line-to 100 10)))
那个 ' 和 eval-draw,暴露了实现细节。用这门语言的人不该关心这些。想让它读起来像一门真正的语言,写一个宏就够了:
(define-syntax-rule (draw cmd ...)
(eval-draw '(draw cmd ...)))
define-syntax-rule 定义一个模式:用户写 (draw cmd ...),编译期被改写成 (eval-draw '(draw cmd ...))。改写发生在程序运行之前,是宏的活儿。之后调用就变成:
(draw
(set-color red)
(move 10 10)
(line-to 100 10))
没有 ',没有 eval-draw——一眼就是一门语言。
define-syntax-rule 只能写一种写法。要处理多种写法,就用它的完整形式 define-syntax 配 syntax-rules,在下面排多条 [模式 模板]:
(define-syntax draw
(syntax-rules ()
; 编译期:把 (draw ...) 改写成对解释器的调用
[(_ cmd ...)
(eval-draw '(draw cmd ...))]))
(_ cmd ...) 是模式(_ 代表 draw 自己),下面是模板。这就是宏那篇讲过的机制——宏在编译期改写语法树,而这里改写的结果,是把你这门 DSL 嵌进了 Racket 的语法。
宏是造 DSL 的工具,DSL 是宏最值得的用途。
换一个领域,用 S-表达式写 JSON
MiniDraw 是画图,换个领域再练一遍:JSON。
JSON 够通用了,但它有两个短板:不支持注释,语法锁死在花括号和方括号上。给自己造一个 S-表达式版的 JSON,这些问题一起解决,还能顺手加原版没有的能力。
目标语法:
(json
(object
(name "Alice")
(age 30)
(skills (array "Lisp" "Racket" "C"))
(active #t)))
同样的套路:先定数据形状,再写解释器,把 (object ...) 变成 Racket 的 hash、把 (array ...) 变成 list:
#lang racket
(define (eval-json expr)
(match expr
; 顶层入口
[(list 'json body)
(eval-json body)]
; object → 可变 hash
[(list 'object pairs ...)
(define h (make-hash))
(for ([p pairs])
(match p
[(list key val) (hash-set! h key (eval-json val))]
[else (error 'eval-json "bad pair: ~a" p)]))
h]
; array → list
[(list 'array elems ...)
(for/list ([e elems]) (eval-json e))]
; 数字、字符串、布尔等原子,原样返回
[_ expr]))
跑一下:
(eval-json
'(json
(object
(name "Alice")
(age 30)
(skills (array "Lisp" "Racket" "C"))
(active #t))))
;; '#hash((name . "Alice") (age . 30) (skills . ("Lisp" "Racket" "C")) (active . #t))
因为是 S-表达式,你想往里加什么都容易。留几道练习。加注释,添一条规则把 (comment ...) 直接吞掉:
[(list 'comment _ ...) (void)]
加计算字段,让 DSL 里能算术:
[(list '+ nums ...) (apply + nums)]
; 用法:(total (+ 10 20 30))
加多行字符串、加变量引用、加 include 其他文件,都是再加一条 match 规则的事。等写到这儿,你会发现自己造的 JSON 已经比原版好用了。同样的 recipe——数据、解释器、需要时配宏——可以套到下一个领域:构建规则、UI 布局、测试用例、查询语句。
该不该造 DSL
DSL 不是越多越好。判断标准很简单:同一种形状的代码写了第三遍,就该把它抽成一门小语言了。
写一遍是普通代码,写两遍是巧合,写三遍是 DSL 在敲你的门。反过来,只出现一两次的“领域”硬造一门语言,反而增加阅读负担——DSL 是给反复出现的问题省力气,不是炫技。
还有一点要说清楚。这篇造的都叫“内部 DSL”,住在 Racket 语法里,靠数据和宏工作。Racket 还允许你走得更远:自己写 reader,造一门 #lang my-lang 的“外部 DSL”,连源代码长什么样都由你定。scribble(文档语言)、typed/racket(带类型的 Racket)就是这么来的。那是下一站。
找问题,再改对
这一章造的 JSON DSL,eval-json 的最后一条规则是 [_ expr]——任何没匹配上的东西都原样放行。先跑跑看它会放过什么:
(eval-json
'(json (objekt (name "Alice"))))
;; '(objekt (name "Alice")) —— 拼错的 objekt 静默穿过,没报错
数据错了不吭声,比报错更危险——坏掉的结构会一路流到程序的另一头才炸。把兜底改成白名单:数字、字符串、布尔原样返回,符号和其他未知形式,老老实实报错。
改完想一想:解释器的兜底规则,该选「宽容放行」还是「fail-fast」?放行的代价由谁付?
通用语言关心怎么做,DSL 只说要什么。在 Racket 里造一个,不用写解析器——你设计数据,写一个解释函数,需要时配一个宏,它就是一门语言了。