可编程的编程语言:Racket宏与#lang实战 “可编程的编程语言”这六个字第一次砸到我时我是有点想抬杠的。什么编程语言不可编程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.rktRacket会在读取阶段调用你的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等表达式上就行。你出的是ASTRacket负责后面所有的宏展开、类型检查、求值和错误报告。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到写出第一个DSL6.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会觉得处处都像在泥地里推车——不是不能写而是底盘不对。