---
title: DSL 设计
url: https://doc.liz6.com/compilers/08-compilation-technology-applications/02-dsl-design
locale: zh
area: compilers
tags:
- compilers
- 编译技术应用
date: 2026-06-30
modified: 2026-06-30
description: 给特定领域定制精炼的词汇和语法——从内部 DSL(宿主语法延伸,免费类型检查)到外部 DSL(独立 parser + codegen),选择取决于在宿主语言里能不能自然地表达领域概念。
---

# 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(链式调用)

```rust
// 一个构建 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

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

```cpp
// 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:

```rust
#[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*
