“可编程的编程语言”这六个字第一次砸到我时,我是有点想抬杠的。什么编程语言不可编程?Java里我也能写个解释器,Python里也能做元编程,连C都有函数指针。直到我在Racket里真正做完一轮语言扩展,才理解这个说法的分量:Racket不是在语言之上提供“造语言”的库,而是把“替换语言本身”做成了语法内的一等操作。你写的程序可以从第一行开始就和默认的Racket不同,也可以换成一门你自己命名的方言,而它依然共享同一套宏系统、编译器和调试器。本文适合两类人:一是想搞懂“语言为什么能成为库”、DSL到底怎么落地的开发者;二是在做领域建模、厌倦了给通用语言套壳子的工程师。我会用一个能跑的迷你案例贯穿全文,最后聊聊文档里不写但非常现实的坑。
1. “可编程”不是说你什么都能写,而是语言能被替换
1.1 大部分语言的“可扩展”,本质上还是原地打转
在讨论Racket之前,先厘清“可扩展语言”这个概念。C++有模板元编程,理论上可以在编译期做一大票计算;Python有元类、描述符,可以在运行时改类的行为;Ruby的开放类几乎允许你在任意时刻给任何类塞方法;JavaScript的Proxy能拦截对象操作。这些能力都很强大,但它们解决的核心问题是什么?是让用户在同一门语言里写出更灵活、更抽象的代码。
换句话说,Python再能“改”,改完还是Python。你用元类搞出来的东西,依然要遵守Python的语法、求值规则、作用域规则和工具链。这就像你可以往毛坯房里添置家具、改造水电,但承重结构、墙体材料这些根本性的东西是不能动的。想换语言?那你要另起炉灶,写lexer、parser、编译器、IDE插件,跟手头这门语言没半点关系。
Racket的立场完全不同。它认为“扩展语言”不应该是语言之上的苦役,而应该是语言内部的家常便饭。Racket的文档里有一个词叫“语言作为库”,一门语言模块可以被另一门语言模块导入、组合、覆盖。也就是说,你写的代码不一定运行在Racket这一层,它可以运行在你基于Racket构建的某一层语言上。
1.2 Racket“可编程”的真正落点:把语言分层做成一等操作
在Racket里有一个最不起眼但最关键的语法:文件第一行的#lang。大多数初学者觉得这只是“指定方言”的声明,就像C的#include <stdio.h>一样普通。但实际上#lang是一道闸门,它决定了这个文件以后遇到的所有代码,用什么parser去读、用什么方式去展开、跑在什么语义层上。
默认的#lang racket会给你一门标准的Lisp方言:S表达式、尾调用、不可变量、强大的for循环库、模块系统等等。但#lang后面的东西可以换成任何模块名,只要那个模块导出了一组协议,一组名为#%module-begin、#%app、#%datum、#%top的“魔法绑定”。只要你愿意,可以写一门没有默认函数调用语义的语言、一门只能做加法求值的语言、甚至一门语法完全不是Lisp的语言。
这就是“可编程编程语言”的含义:你不只是能写代码,还能重写“代码运行的规则”。Racket把这套机制做得足够通用,以至于它被当作一门“语言工作台”来用。你可以先做出一个小语言,再在这个语言上不断叠加宏,形成语言栈——每一层都是一个独立的语言,上层可以共享下层的宏和运行时。这种分层构造语言的能力才是标题里“可编程”二字真正值钱的地方。
2. 第一板斧:卫生宏把控制结构变成“可插拔模块”
2.1 从repeat-times开始:给你的语言加一个“新语句”
如果不借助宏,Racket和大多数函数式语言一样,循环要靠递归、for系列、或高阶函数完成。现在我想给它加一个像样的语句:(repeat-times n form ...),意思是把后面几个表达式执行n次。这不难,但要用宏来写,因为repeat-times必须能“看见”并展开它后面的代码,而不是把它们当作普通函数参数。
#lang racket/base (define-syntax repeat-times (syntax-rules () [(_ n body ...) (let loop ([i 0]) (when (< i n) body ... (loop (add1 i))))])) (let ([i 99]) (repeat-times 3 (displayln i)))运行之后会打印什么?如果你猜三个99,说明你踩中了变量捕获的坑;正确答案是0、1、2。看到这个结果,宏的“卫生性”已经有了直观体现:模板里那个i和loop虽然是宏作者写的,但它们并不会和调用环境里的i搅在一起。也就是说,Racket自动给宏模板里的局部标识符做了重命名,让它们成为一个“新的、环境外的变量”。这在Lisp社区里叫卫生宏(hygienic macro),是Racket宏系统区别于早期C预处理器和许多老式defmacro方案的关键。
2.2 卫生宏的“卫生”究竟体现在哪
卫生宏最容易被误解的点是:它不只是解决“临时变量重名”的问题。它真正的价值是,宏模板里的每个标识符,都保留着自己定义时的绑定信息。模板里的displayln、when、loop、i在宏定义的那个模块里已经绑定了,不会在用户代码里“重新绑定”。这意味着宏不是文本替换,而是一种带编译环境的代码变换。
拿刚才的例子说,假设调用方自己写了一个(define loop 'oops),repeat-times模板里的loop依然指向上层的let递归循环,而非用户定义的loop。如果拿C预处理器的宏来写,这个程序大概率会崩。在Racket里,你可以在完全不了解调用方上下文的情况下写宏,这让宏具备了一种非常接近“函数纯组合”的可组合性:宏与宏之间不会因为重名互相污染。
对于写DSL的人来说,这一点极其重要。因为DSL宏通常要引入一堆隐藏的辅助变量,比如缓存、计数器、中间类型名。如果这些名字会暴露给用户,DSL根本没法设计;Racket的卫生宏等于帮你把“命名空间隔离”的脏活自动做掉了。
2.3 括号不是束缚,是“不需要写parser”的原料
很多初学者觉得Lisp系语言的括号难读,但从宏开发者的视角看,括号恰恰是福音。因为S表达式本身就是一棵无歧义的语法树,宏可以直接拿到这棵树,而不需要像别的语言那样发明一个AST,再接一套Visitor遍历它。Racket里syntax-case和syntax-parse做的就是对这棵树的模式匹配。
(define-syntax my-unless (syntax-rules () [(_ test body ...) (if (not test) body ...)]))这个my-unless几乎不需要学习成本:把调用源码当作模式,把展开后的代码当作模板。你要在宏里做的“解析”,早就被S表达式的括号完成了。所以如果你想快速验证一门语言的点子,Racket几乎是最快的环境——你不需要先写词法分析器,就能把“语法糖”捏出来,先看看语义合不合理,再决定要不要为它写一个真正的语法。
3. 第一行#lang背后的语言栈:reader、expand、runtime三段分离
3.1 #lang一行的真实含义
很多Racket教程都会告诉你“#lang指定语言”,但很少讲清楚#lang到底做了什么。简单说,当你写#lang my-lang时,Racket会去找名为my-lang的模块,然后把这个模块提供的一组绑定作为当前文件的“语言”。这些绑定里最核心的是一个叫#%module-begin的宏。你可以把#%module-begin理解为“整个文件的外壳”:Racket会把文件里的所有顶层表达式打包,交给这个宏处理,再由它展开成真正的运行时代码。
默认的#%module-begin来自racket/base,它就是简单地按顺序处理各种定义和表达式。但如果我自定义一个#%module-begin,比如“所有顶层表达式执行后打印一句完成”,那么所有使用这门语言的文件,都会自动具备这种行为。这就是语言语义能被替换的最短路径。
另外几个常见的魔法绑定也很重要:#%app定义了函数调用,#%datum定义了字面量,#%top定义了未绑定标识符。你可以把它们都替换掉。比如把#%app重定义成“每次调用前打印一行日志”,就是一门自带追溯功能的方言;把#%top重定义成“未定义的变量返回0”,就是一门容错变量方言。语言设计者真正可以改的不是某个库,而是语言最基本的接线方式。
3.2 reader、expand、runtime三段分离
Racket处理一段代码会经历三个阶段,理解这三个阶段能让你在自定义语言时不掉进坑里:
- Reader(读取):把字符流变成datum(数据),默认是S表达式树。这一步可以整体替换,变成中缀语法、缩进语法、JSON语法都可以。
- Expander(展开):把一个一个datum交给宏系统,展开成核心语言,同时完成绑定解析、模块解析、类型约束拓展。这一步是宏出没的地方。
- Runtime(求值):展开后的核心语言真正执行。默认是函数调用、跳转、赋值等行为,这一步也可以被
#%app等绑定改变。
这个三段分离意味着,你可以只改其中一段,而不用动另外两段。比如只想给Racket加一个unless,只需要改Expand阶段,写几个宏;想彻底不用括号,就要动Reader;想给语言换成“只读不可变数据”的求值语义,就要动Runtime层。
很多人第一次听这个会觉得“这无非是自己写编译器”。但区别在于,你写的不是“一个编译器生成另一个程序”,而是“一个语言模块插入到现有编译器管线中”。你可以复用Racket所有的宏、库、调试器、文档工具,因为线仍然在,变的只是某一节管道的内部。
3.3 phase相位:什么时候运行“运行前代码”
自定义语言不可避免会遇到phase(相位)问题。Racket里默认有两种相位:编译期(宏展开发生的时刻)和运行期(程序真正执行的时刻)。宏作为“运行在编译期的代码”,如果它调用了某个普通函数,那这个函数必须在编译期可用。
(define (helper x) (add1 x)) (define-syntax m (syntax-rules () [(_) (helper 1)]))这段代码往往报错,因为helper是运行期的函数,宏m在编译期展开时却要调用它。正确写法是把helper放到编译期:
(begin-for-syntax (define (helper x) (add1 x)))如果你只是写宏,这个问题可能没那么突出;一旦开始自造#lang,几乎必然遇到。因为语言模块的语法和宏辅助函数都是编译期的,你的普通工具函数必须显式声明为for-syntax。一个很实用的经验是:凡是“只在生成代码时使用的辅助函数”,统一放进begin-for-syntax;凡是“在用户最终运行时需要的函数”,留在模块顶层。
4. 亲手做一门会汇报的“追踪语言”:自定义#lang语言实战
4.1 目标与思路:让每个函数调用都有“回执”
理论说了半天,现在动手做一个真正可以运行的自定义语言。我要做一门叫tr-lang的小语言,它的规则很简单:所有函数调用执行后,都会在终端打印一行结果。可以把它想象成一个自动打log的解释器。这个语言不需要改reader,仍然用S表达式,但通过替换#%app来修改“函数调用”这个最基础的语义。
工程结构长这样:
tr-lang/ lang.rkt demo.rktlang.rkt是语言模块,demo.rkt是使用这门语言写出来的程序。
4.2 实现:替换#%app
先写lang.rkt:
#lang racket/base (provide (except-out (all-from-out racket/base) #%app) (rename-out [trace-app #%app])) (define-syntax (trace-app stx) (syntax-case stx () [(_ f arg ...) #'(let ([f0 f] [args (list arg ...)]) (let ([result (apply f0 args)]) (printf "TRACE args=~s => ~s\n" args result) result))]))核心逻辑不复杂:trace-app把一次函数调用(f arg ...)重写为“先计算函数和参数列表,再apply,最后打印结果”。注意这里打印的是运行后的值,不是源代码。如果你希望打印源代码,可以用syntax->datum把stx转成可读数据,但为了保持代码简洁,这个版本先打印参数和返回值。
一个值得一提的细节:宏模板里的let、list、apply、printf这些名字,来自lang.rkt的定义环境。就算你在demo.rkt里给list绑定了完全不同的变量,宏展开后生成的list仍然指向语言模块里的那个list,不会被用户代码干扰。这就是前面说的卫生性在自定义语言中的价值。
4.3 跑起来:用tr-lang写一个带调用追踪的程序
在demo.rkt里写:
#lang tr-lang (define (double x) (* x 2)) (double 21) (+ 1 2)在终端运行:
raco test demo.rkt打开DrRacket点Run也可以。理论上你会看到类似这样的输出:
TRACE args=(21) => 42 TRACE args=(2) => 42 TRACE args=(1 2) => 3第一行是(double 21)这个最外层调用的结果;第二行是(* x 2)这个内部调用的结果,因为double内部乘法也经过了#%app;第三行是+的结果。
到这里,你已经“造了一门语言”:一门默认会为所有函数调用打印运行日志的语言。这个语言并不是一个包在Racket外面的脚本程序,它就是Racket的一个模块语言,所有函数调用都被理解为“调用后打印”。你甚至可以把这个tr-lang做成包发布,别人写#lang tr-lang就能用。
4.4 这个实验说明了什么
有人会觉得,这不就是一个全局log宏吗?跟AOP/装饰器有什么区别?区别在于:trace-app改变的是语言默认行为,而不是“某些函数的行为”。在tr-lang里,任何函数调用都逃不过打印,不管这个函数是用户写的还是库里的,不管它出现在let里、if里还是模块顶层。这种“全局规则改变”的能力,是AOP或装饰器难以做到的,因为AOP要么需要显式标注,要么只能针对特定对象做代理。
当然,这个语言也有代价。因为标准Racket的#%app支持多返回值、更精细的错误信息、以及对尾调用优化的处理;我重定义后的apply版本会丢掉多返回值,也不保证尾递归特性。这正是语言设计者的取舍:你要一门“纯函数式、高效、精确的Racket”,还是要一门“每个调用都要打log的玩具语言”。Racket给你的自由度,允许你走极端,但也把责任交给你——替换了核心绑定,就要自己维护由此引入的语义偏差。
5. 如果S表达式也碍事:从#lang reader走向自定义语法
5.1 reader层能做什么:真的可以不写括号
上一节的tr-lang仍是S表达式语法,但“可编程编程语言”的魅力在于,连#lang后面S表达式这个外壳都是可以换的。Racket里有一个特殊的模块语言叫reader,你可以在它上面实现自己的read和read-syntax函数,把任何源文本变成datum。简单说,你可以让一门Racket方言长成C、Python、JSON或者你自己设计的领域语言的样子。
一个常见的起点是创建一个reader.rkt,导出read、read-syntax,然后语言模块顶部写:
#lang reader "reader.rkt"Racket会在读取阶段调用你的reader,用它去解析用户文件。注意这里是“读取”和“展开”分离的:你的reader输出什么,宏就在那上面工作。如果你输出的还是S表达式结构,宏系统依然有效;如果你输出的是一些自定义的list结构,宏也能处理,因为你产出的本质还是datum。
5.2 一个中缀语言的parser思路
假设你想造一门语法像“常规算术”的语言,文件里写:
x = 2 + 3 * 4; print x;第一眼会觉得这跟Racket没关系了,但reader层就是干这个的。你需要做的核心工作是一个递归下降parser。伪代码思路大概是这样:
- 词法扫描:识别数字、标识符、运算符、分号。
- 表达式解析:用优先级爬升处理
+ - * /和括号。 - 语句解析:遇到
=就生成一个绑定,遇到print就生成打印。 - 把解析结果包装成Racket能处理的形式,比如产生
(define x (+ 2 (* 3 4)))和(println x)这样的list。
这里的关键是:你不需要自己设计一套完整的运行时。语法解析完成后,把AST映射到Racket已有的define、+、*、println等表达式上就行。你出的是AST,Racket负责后面所有的宏展开、类型检查、求值和错误报告。
Racket官方还提供了parser-tools、megaparsack等库,帮你做词法和语法分析。如果不想从零开始,完全可以用它们把tokenizer与AST搭建出来。但我个人的建议是,第一版尽量手工写递归下降,因为DSL通常很小,手写更灵活,也不容易被生成器的错误信息淹没。
5.3 什么时候不该动reader
替换reader是一件很有诱惑力的事,但我见过不少新手一上来就要“完全自定义语法”,结果三个月后还没跑通,因为他们在DSL设计还没确认时,就把精力耗在了词法分析上。Racket的宏系统之所以强大,就是因为它允许你先用S表达式快速迭代语言语义——等语义稳定了,再考虑“要不要让用户写起来更舒服”。
一个可行的路线是:
- 用宏构造语言语义,先用S表达式写程序验证。
- 语义稳定后,再写reader把外部语法转成宏期望的S表达式。
- 如果语法足够简单,甚至可以不做真正的parser,直接用正则和简单字符串处理输出S表达式。
另外,自定义reader会牺牲一部分Racket工具的便利性。比如DrRacket的某些语法高亮、自动格式化、结构编辑能力,是基于S表达式和默认readtable的。换成自定义reader后,你要自己处理源码位置映射,否则报错信息会指向“你生成的datum”,而不是用户写的源文件。这是进阶玩法,适合对语言栈有清晰把握的人。
6. 学习路线与踩坑经验:从打开DrRacket到写出第一个DSL
6.1 别用面向对象或Python那套脑回路学Racket
很多新手从Python/Java转过来,第一反应是找“class”“for”“if”对应的东西。Racket当然有,但它的设计哲学不是“用对象把所有东西包起来”,而是“用函数和宏把行为组合起来”。你会在Racket里频繁做的事是:写一个小函数,再写一个宏把那批函数组合成一种更像语法的东西。这个思路和面向对象完全不同。
我建议的学习顺序是:先熟悉racket/base的函数式基础,lambda、let、list、apply这些;然后把官方《Racket Guide》里for、struct、contract、macro这四章过一遍;接着读《Beautiful Racket》这本书,它几乎是用“造一门小语言”的方式讲宏和语言工作台,和标题“可编程编程语言”的气质非常契合。
6.2 调试宏依赖工具:Macro Stepper和raco expand
调试宏最大的问题是“看不到展开结果”。Racket的DrRacket里自带一个Macro Stepper,可以一步一步看到宏如何展开,这是我最常用的工具。你在编辑器里选中一段使用宏的代码,点“Step”,就能看到宏一步步把代码变成核心语言的样子,变量被抓到哪里都一目了然。
命令行下则可以用:
raco expand demo.rkt它会打印整个文件宏展开后的结果。当你怀疑某段代码在宏展开后不符合预期时,用这个命令比在脑袋里模拟快得多。我在自定义#lang的早期几乎每改一个绑定就会跑一次raco expand,确认输出里没有残留“未定义标识符”。
6.3 phase错位是最常见的自定义语言bug
自定义语言时最常见的报错是phase level mismatch。这个错误的核心就是:你在编译期(宏展开期)用了一个运行期的函数。前面提过的begin-for-syntax可以解决一半问题,另一半是反过来的:你在运行期引用了一个编译期的绑定,也会报错。
一个有用的排查思路是:把你写的模块分成两堆来理解——“最终用户的代码在运行时需要什么”和“生成用户代码的代码在编译时需要什么”。前者放在模块顶层并提供出去,后者全部放进begin-for-syntax或define-for-syntax。如果某个辅助函数只是宏展开时用来算数字、拼字符串、查表的,记住它必须走编译期。
6.4 实践小贴士:先从宏开始,而不是从语法开始
最后一条经验,也是我给所有想做DSL的人的建议:先别急着造语法,先用宏验证语义。Racket里S表达式就是语法树,你可以用非常小的成本把一门语言的“骨架”搭出来。比如你想做一门配置语言,先用define-syntax提供几个配置指令,跑通了逻辑,再考虑用户是不是更希望写server { port 8080 }这样的文本。很可能你试完发现,S表达式的配置形式已经足够清晰,根本不需要再动reader。
就算最终要做自定义语法,也要记得“reader只是入口,宏才是引擎”。Racket的生态里有一大批特殊#lang,比如写文档的scribble、做演示幻灯片的slideshow,它们的外部语法其实各不相同,但内部都和Racket的宏系统深度咬合。你可以把Racket理解成一套“语言积木”:宏是标准件,reader是可替换外壳,#lang则是这些积木的组装协议。真正能发挥它价值的地方,不是拿它写普通的业务代码,而是用它把领域里的规则变成一门只有你这个领域看得懂、用起来顺手的“专属语言”。我自己的感受是,一旦适应了这种“先考虑语言规则,再写具体代码”的工作方式,再回头用别的语言写DSL,会觉得处处都像在泥地里推车——不是不能写,而是底盘不对。