本页目录
DSL 设计
给特定领域定制精炼的词汇和语法——从内部 DSL(宿主语法延伸,免费类型检查)到外部 DSL(独立 parser + codegen),选择取决于在宿主语言里能不能自然地表达领域概念。
概述
通用语言(GPL)什么都能写,但某些领域的表述在通用语言里噪声巨大。DSL(Domain-Specific Language)给特定领域定制一套精炼的词汇和语法——SQL 操作数据、正则匹配文本、Terraform 描述基础设施。对编译技术,DSL 有两种根本不同的实现路线:内部 DSL(宿主语言的语法延伸,编译器不做新 parser)和外部 DSL(独立语法 + parser + 代码生成)。这篇讲两种路线的机制、选型判据、以及从零设计外部 DSL 时编译前端的工作量。
内部 DSL:宿主语言的语法延伸
内部 DSL 寄生在宿主语言里——利用宿主语言的语法特性(运算符重载、链式调用、宏、trailing closure)让代码读起来像专门设计的语言。没有新 parser,编译由宿主编译器完成。
Fluent API(链式调用)
// 一个构建 SQL 查询的 fluent DSL
let results = select
.from
.where_
.order_by
.execute?;
机制:每个方法(select, from, where_, order_by)返回 QueryBuilder,携带累积的 AST 片段;.execute() 把 AST 翻译成 SQL 字符串并执行。编译期宿主语言做类型检查——如果 where_ 的字段类型和值的类型不匹配,宿主编译器报错。内部 DSL 的一个核心优势:类型检查是免费的。
运算符重载 DSL
利用运算符重载让表达式更像数学:
// C++ Eigen (线性代数 DSL)
MatrixXd A = B * C + D.;
VectorXd x = A..;
底层每个 */+/.transpose() 是 C++ 函数调用,但不产生实际的矩阵乘法——返回的是表达式模板(expression template),一个编译期的 AST。最终 x = ... 赋值时,表达式模板被展开成最优的循环代码(编译期无临时矩阵分配)。零运行时开销——这是运算符重载 DSL 区别于"写一个解释器"的关键。
Macro DSL
Rust 的过程宏允许在编译期把自定义语法翻译成宿主语言的 AST:
← DSL: 声明序列化规则
#[derive(Serialize)] 在编译期展开为 impl Serialize for User,生成 serialize() 方法的逐字段序列化代码。serde 和 diesel 是 Rust macro DSL 的两个代表——serde 的 DSL 是把 struct 定义翻译成序列化逻辑,diesel 的 DSL 是在编译期把模式定义映射到数据库表。
Macro DSL 的优点:语法在编译期被完全展开——错误的 DSL 使用(字段类型不可序列化)在编译期报错,而不是运行时。
外部 DSL:独立语言 + 完整编译管线
当领域的语法无法嵌入宿主语言(如正则、SQL、Terraform HCL),就需要外部 DSL——有自己的词法/语法/语义,编译成目标表示(字节码、机器码、或翻译回宿主语言)。
外部 DSL 的编译管线
源码 (DSL 语法) → 词法分析 → 语法分析 (通常是 Pratt/LR) → 语义分析 → 代码生成
↓
AST (DSL 专用节点)
和通用编译器完全一样,只是:
- 语言更小(文法规则几十条而非几百条)。
- 目标通常不是机器码,而是另一种语言的源码(transpile)或字节码。
- 错误信息是这个 DSL 的用户界面,比通用编译器的错误信息更重要——用户在特定领域里,应该收到领域术语的错误信息。
代码生成目标:transpile vs compile
- Transpile(转译):DSL 源码 → 另一种语言的源码。Terraform HCL → 内部 plan JSON;Protobuf
.proto→.go/.rs/.py。优势:输出可读、可调试;下游编译器做优化。 - Compile(直编):DSL 源码 → IR → 机器码/字节码。Terra(Lua + 低级语言混合)走这条路——DSL 的某些部分被 JIT 编译到机器码。优势:性能最好;代价是实现复杂(需要 IR→后端)。
选择主要看"这个 DSL 的用户要性能还是可调试性"。
外部 DSL 的选型:何时值得新起一门语言
问三个问题:
- 领域词汇能否在宿主语法里自然表达? SQL 的
SELECT...FROM...WHERE在通用语言里用 fluent API 可以接近自然,但正则的^a.*b$无法在通用语法里接近自然。 - 使用频率是否足够? 一个配置文件格式用几次,不值得写 parser;系统内数千处查询,值得。
- 错误信息是否需要领域知识? 正则编译器的"unmatched parenthesis at position 15" 和通用编译器的"SyntaxError: unexpected token" 不是一个质量的体验——DSL 需要理解领域概念才能报好错。
判据:如果一个概念在宿主语言里"总是需要用注释解释意图",就是 DSL 的信号。
渐进式:内部 DSL 进化到外部 DSL
很多成功的 DSL 从内部 DSL 开始,当宿主语法的限制变得不能再被接收时,再额外加一个外部 parser:
阶段 1: fluent API + 字符串模板 (内部 DSL)
阶段 2: macro DSL (编译期 AST 构建 + 宿主类型检查) (内部 DSL,编译期)
阶段 3: 外部 parser + 转译到宿主语言 (外部 DSL, 转译)
Serde 从阶段 1(手动 Serialize impl) 走到阶段 2(过程宏 #[derive(Serialize)]),目前停在阶段 2 完全足够。SQL 在大多数 ORM 里停在阶段 1–2,但数据库引擎内部的 SQL parser 是阶段 3。
参考
- Martin Fowler: "Domain-Specific Languages" — DSL 设计模式与选型指南
- Holden: "Build Your Own Lisp" — 从零写一个 Lisp DSL 解释器,含 parser+codegen
- Serde: https://serde.rs — Rust macro DSL 的典范
- Terra: https://terralang.org — 低级 DSL(JIT 编译)的代表
Keywords: DSL, domain-specific language, internal DSL, external DSL, fluent API, builder pattern, operator overloading DSL, expression template, macro DSL, proc macro, transpile, code generation, embedded DSL, language design, Serde, diesel