本页目录

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 = query::select(&[name, age])
    .from(users)
    .where_(
        condition::eq(department, "Engineering")
            .and(condition::gt(salary, 50000)))
    .order_by(name, Desc)
    .execute(db)?;

机制:每个方法(select, from, where_, order_by)返回 QueryBuilder,携带累积的 AST 片段;.execute() 把 AST 翻译成 SQL 字符串并执行。编译期宿主语言做类型检查——如果 where_ 的字段类型和值的类型不匹配,宿主编译器报错。⁠内部 DSL 的一个核心优势:类型检查是免费的。

运算符重载 DSL

利用运算符重载让表达式更像数学:

// C++ Eigen (线性代数 DSL)
MatrixXd A = B * C + D.transpose();
VectorXd x = A.lu().solve(b);

底层每个 */+/.transpose() 是 C++ 函数调用,但不产生实际的矩阵乘法——返回的是表达式模板(expression template),一个编译期的 AST。最终 x = ... 赋值时,表达式模板被展开成最优的循环代码(编译期无临时矩阵分配)。⁠零运行时开销——这是运算符重载 DSL 区别于"写一个解释器"的关键⁠。

Macro DSL

Rust 的过程宏允许在编译期把自定义语法翻译成宿主语言的 AST:

#[derive(Serialize)]DSL: 声明序列化规则
struct User {
    name: String,
    age: u32,
}

#[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 的选型:何时值得新起一门语言

问三个问题:

  1. 领域词汇能否在宿主语法里自然表达? SQL 的 SELECT...FROM...WHERE 在通用语言里用 fluent API 可以接近自然,但正则的 ^a.*b$ 无法在通用语法里接近自然。
  2. 使用频率是否足够? 一个配置文件格式用几次,不值得写 parser;系统内数千处查询,值得。
  3. 错误信息是否需要领域知识? 正则编译器的"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