如何设计编译器
从 LedgerRule 规则语言出发,把源码、Token、AST、类型检查、IR、优化、代码生成和错误诊断逐步设计出来
阅读定位: 这页不追求立刻写出 LLVM 或 JVM 级别的工业编译器,而是用一个小型规则语言做训练。我们先让 LedgerRule 能被机器读懂、检查、优化并执行,再把这条链路回收到通用编译器设计。
一句话原则: 编译器不是“把代码翻译成机器码”这么窄,它的核心是把人写的文本逐步变成可信的结构、可验证的语义、可执行的计划和可理解的错误提示。
| 问题 | 如果没有编译器会怎样 | 编译器要承担什么 |
| 财务人员写了一条规则 | 系统只能把它当普通字符串保存 | 把文本解析成结构化规则 |
| 规则里写错字段名 | 上线后执行到那条规则才发现 | 编译期发现 unknown field,并给出建议 |
| 金额字段被当字符串比较 | 结果可能错得很隐蔽 | 类型检查阻止不合法操作 |
| 规则要在后端、批处理、审计里复用 | 每个系统各写一套解释逻辑 | 输出统一 IR 或字节码,让不同运行端共享语义 |
| 规则越来越多 | 执行慢、错误难定位、版本难审计 | 优化、诊断、源位置映射和编译产物版本管理 |
源文本
Token
AST
符号表
类型检查
IR
优化
代码生成
诊断
rule meal_limit:
when category == "meal" and amount > 200
then require_approval("manager")
rule taxi_auto_tag:
when merchant contains "Taxi" or category == "transport"
then tag("transport")
| 输入 / 输出 | 具体内容 | 验收问题 |
| 输入 | 规则源文件、账目字段 schema、动作白名单 | 编译器是否知道 amount 是金额、category 是字符串 |
| 输出 | 可执行规则计划、错误列表、源位置映射、规则元数据 | 运行时能否执行,用户能否看懂错误 |
| 边界 | 第一版只支持条件表达式和白名单动作 | 是否避免把规则语言做成任意脚本 |
| 源码片段 | Token | 为什么需要 |
rule meal_limit: | RULE IDENT COLON | 后续 parser 不再逐字符猜意思 |
amount > 200 | IDENT GT NUMBER | 操作符、字段名和字面量要分清 |
"meal" | STRING | 字符串里的内容不能被继续当关键字解析 |
and / or | AND / OR | 逻辑操作符会影响表达式结构和优先级 |
常见坑: 词法分析不只是 split 空格。字符串转义、行列号、注释、中文字符、错误字符和源位置都要保留,否则后面的错误提示会很粗糙。
Rule(
name = "meal_limit",
condition = And(
Eq(Field("category"), String("meal")),
Gt(Field("amount"), Number(200))
),
action = Call("require_approval", [String("manager")])
)
| 设计问题 | LedgerRule 的选择 | 背后的编译器概念 |
and 和 or 谁优先 | and 高于 or | 表达式优先级和结合性 |
| 规则边界怎么识别 | rule name: 开始,下一条 rule 前结束 | 语法结构和错误恢复 |
| 括号怎么处理 | 括号显式改变表达式结构 | 递归下降或 Pratt parser 的典型场景 |
| 语法错了怎么办 | 尽量恢复到下一条规则继续解析 | 错误恢复,而不是遇错全文件失败 |
| 名字 | 来自哪里 | 编译器记录什么 | 能发现什么错误 |
amount | 账目字段 schema | 类型 Money / Number,可比较大小 | 字段拼写错误、类型不匹配 |
category | 账目字段 schema | 类型 String,可相等比较 | 把字符串字段当数字比较 |
require_approval | 动作白名单 | 参数签名 (role: String) | 参数数量或类型错误 |
meal_limit | 规则声明 | 规则名、位置、版本 | 重复规则名、审计定位困难 |
| 错误规则 | 检查逻辑 | 好的错误提示 |
amount contains "20" | contains 只接受 String 字段 | amount 是金额,不能使用 contains,可改用 amount > 20 |
category > 100 | > 只接受 Number / Money / Date | category 是字符串,不能和数字比较大小 |
tag() | 动作签名要求 1 个 String 参数 | tag 缺少标签参数,例如 tag("transport") |
merchant == 10 | 左右类型不兼容 | merchant 是字符串,不能和数字 10 比较 |
rule meal_limit
load_field category
push_string "meal"
eq
load_field amount
push_number 200
gt
and
if_true call require_approval "manager"
| 为什么不直接执行 AST | IR 带来的好处 | 代价 |
| AST 更接近语法,不一定适合执行 | IR 更接近机器或解释器的执行步骤 | 需要维护从 AST 到 IR 的转换 |
| 不同语法可以表达同一语义 | 统一成 IR 后,优化和运行时更简单 | 错误映射必须保留源位置 |
| 未来可能输出 SQL、Java 或字节码 | IR 可以作为多个后端的共同输入 | IR 设计太早复杂化会拖慢第一版 |
| 优化 | 例子 | 价值 | 风险 |
| 常量折叠 | amount > 100 + 100 变成 amount > 200 | 减少运行时计算 | 金额、日期、时区等语义必须清楚 |
| 短路执行 | A and B 中 A 为 false 时不执行 B | 少访问字段,少做动作前判断 | 条件表达式不能有副作用 |
| 重复子表达式消除 | 同一字段读取只做一次 | 批量执行时减少开销 | 字段值必须在一次规则执行中稳定 |
| 规则预筛选 | 先按 category 粗分命中候选规则 | 规则很多时减少扫描 | 索引条件和完整条件不能不一致 |
| 后端选择 | 适合什么 | 代价 | LedgerRule 第一版选择 |
| 解释执行 IR | 规则经常变化,需要快速上线和调试 | 性能上限较低 | 第一版采用 |
| 生成字节码 | 需要更高性能,但仍想保留 VM 控制 | 需要设计 VM、指令集和调试映射 | 第二版考虑 |
| 生成 Java 代码 | 希望接入现有 Java 服务和类型系统 | 编译、加载、版本隔离和安全边界复杂 | 只作为生产化方向 |
| 生成 SQL 条件 | 批量筛选大量历史账目 | 动作语义难映射,数据库差异明显 | 只允许条件部分下推 |
| 差的错误 | 好的错误 | 需要编译器保存什么 |
| Parse error | 第 2 行 amount > 后缺少数字,例如 amount > 200 | Token 的行列号和上下文 |
| Unknown identifier | 未找到字段 merchent,你是否想写 merchant | 符号表和相似度建议 |
| Type mismatch | category 是字符串,不能和数字比较大小 | 表达式类型和源位置 |
| Compilation failed | 规则 meal_limit 的动作 require_approval 参数必须是角色名字符串 | 规则名、动作签名、参数位置 |
增量编译
只重新编译变更规则,避免规则文件一大就每次全量处理。
源映射
运行时报错能回到源文件、规则名、行列号,而不是只看到 IR 指令。
编译缓存
同一版本规则可以复用编译产物,减少启动和发布成本。
版本治理
规则语法、字段 schema、动作白名单和编译器版本都要可追踪。
测试夹具
每条规则能用样例账目测试命中结果、动作和错误提示。
安全边界
生成代码或执行 IR 时必须限制动作能力,不能让规则绕过权限。
| 通用问题 | LedgerRule 里的对应物 | 扩展到通用语言会变成什么 |
| 源码怎样变成结构 | Token 和 Rule AST | 词法分析、语法分析、AST、错误恢复 |
| 名字怎样解析 | 字段 schema、动作白名单、规则名 | 作用域、符号表、模块、命名空间 |
| 类型怎样约束错误 | Money、String、Bool、Action 签名 | 静态类型、泛型、类型推导、借用 / 所有权 |
| 怎样生成可执行形式 | IR、解释器、SQL 下推 | 字节码、机器码、JIT、AOT、链接和加载 |
| 怎样优化 | 常量折叠、短路、规则预筛选 | SSA、内联、逃逸分析、向量化、寄存器分配 |
| 怎样诊断 | 规则名、行列号、字段建议 | 编译器诊断、LSP、调试信息、source map |
误区 1: 写 parser 就等于写编译器。
Parser 只是把文本变成结构,真正让语言可靠的是语义检查、类型检查、IR、执行后端和诊断。
误区 2: 编译器一定要生成机器码。
很多编译器输出 AST、IR、字节码、SQL、JavaScript、配置计划或解释器指令,关键是输出可执行语义。
误区 3: 优化越多越好。
优化会增加复杂度和错误风险。第一版应该优先保证语义正确、错误清楚、执行稳定。
误区 4: 错误提示是最后再做的 UI 细节。
错误提示依赖源位置、符号表、类型信息和恢复策略,必须从 pipeline 一开始就设计。
误区 5: 生成代码就天然安全。
代码生成会放大权限和注入风险,必须限制动作、隔离执行、记录版本和审计。
| 练习 | 要输出什么 | 验收问题 |
| 写 LedgerRule 的 Token 表 | 关键字、标识符、字符串、数字、操作符、行列号 | 能否给出准确错误位置 |
| 画 AST 节点 | Rule、And、Or、Compare、Field、Literal、Call | 能否表示括号、优先级和动作调用 |
| 设计符号表 | 字段类型、动作签名、规则名和源位置 | 能否发现未知字段和重复规则 |
| 写 5 条类型错误 | 字段、操作符、动作参数、左右类型、缺失值 | 错误提示是否能让用户自己修正 |
| 设计 IR | load_field、push、eq、gt、and、or、call 等指令 | 解释器是否能按 IR 执行同样语义 |
| 选择一个后端 | 解释执行、字节码、Java 代码或 SQL 下推 | 选择是否匹配性能、调试、安全和规则变化频率 |
如何设计一门编程语言
如果你还没确定语言要表达什么、语法和语义怎么定,先回到
如何设计一门编程语言。
编译器与运行时底座
如果你想继续理解 IR、优化、字节码、JIT、GC、对象布局和运行时差异,继续看
编译器与运行时底座。
数据结构与算法底座
如果你要补编译器里常见的树、图、栈、哈希表、自动机和复杂度基础,继续看
数据结构与算法底座。
Java 多语言对比实验
如果你正在用多语言理解 Java 的编译、运行时和类型边界,可以回到
Java 多语言对比实验。
总结: 设计编译器的吸收顺序是:先明确输入和输出,再把源码切成 Token、组织成 AST、建立符号表、做类型检查、降成 IR、选择后端,并从第一天就保留源位置和错误诊断。编译器真正训练的是“把模糊文本变成可信执行计划”的能力。