Racket 编程入门

第 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 是根,里面挂着一串命令,每条命令又是 (命令 参数 …) 的列表。

racket-dsl-fig01

这棵命令树,就是这门语言的全部源代码。

给它写个解释器

光有数据还不算语言,得有东西去解释它。写一个函数,按命令逐条处理:

#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 里造一个,不用写解析器——你设计数据,写一个解释函数,需要时配一个宏,它就是一门语言了。