程云来 / 杭州
Back to Blog·DSL
·About 21 min

DSL 决定表达什么,YACC 决定怎样读懂

从一条设备告警规则出发,区分 DSL、Lexer、YACC、AST 与执行后端,用可运行的 Bison 实验观察 token、shift/reduce、语法冲突和语义边界。


适用环境:示例在 Apple Bison 2.3、Apple clang 21.0.0 验证;文中现代 Bison 能力以 GNU Bison 3.8.1 手册为准。资料与代码核对日期为 2026-09-11。

同一句规则,谁负责“允许写”,谁负责“读得懂”

设想设备监测系统允许现场人员输入下面的告警规则:

text
temperature > 80 and vibration >= 6

它看起来只是一行条件,但已经包含字段、数值、比较、逻辑连接和优先级。如果再加入 or 与括号,字符串切割很快就无法回答:

text
temperature < 0 or (temperature > 80 and vibration >= 6)

左边成立时是否还要读取 vibration?括号怎样改变组合关系?andor 谁先结合?pressure > 10 在语法上像一条规则,但当前设备根本没有 pressure,谁来拒绝它?

这些问题容易把两个不同层次的概念混在一起:领域特定语言(Domain-Specific Language,DSL)决定领域用户可以表达什么;YACC 决定程序怎样把一部分文本表达可靠地还原为结构。

因此,DSL 是要设计和长期维护的语言产品;YACC 是实现外部文本 DSL 的一种 parser generator。没有 YACC 也可以实现 DSL,使用 YACC 也不等于已经设计好了 DSL。

DSL 与 YACC 不在同一层

DSL 面向一个有限问题域,而不是尝试处理所有软件问题。SQL 面向数据查询,正则表达式面向文本模式;设备规则则面向指标、阈值和逻辑组合。Martin Fowler 进一步区分了内部 DSL 与外部 DSL:内部 DSL 借用宿主语言的语法,外部 DSL 拥有自己的文本语法和 parser。1

YACC 主要出现在外部 DSL 的语法前端。POSIX 对 yacc 的定义是:读取一份上下文无关文法描述,生成 C parser;应用提供 yylex(),parser 再从它读取 token。2 原始 YACC 论文把范围说得更宽:凡是处理结构化输入的程序,都可以把自己接受的内容看作一种输入语言;YACC 用 LALR(1) grammar 和消歧规则生成处理这种输入的子程序。3

一条规则进入系统时,各对象沿着下面的数据流协作:

正在绘制图表…
查看 Mermaid 源码
flowchart LR
    source[DSL 源文本] -->|读取字符| lexer[Lexer]
    lexer -->|写 Token 流| parser[YACC 生成的 Parser]
    parser -->|归约并构造| syntaxAst[语法 AST]
    syntaxAst -->|字段、类型、权限检查| domainAst[领域 AST]
    domainAst -->|evaluate| decision[告警 / 正常]
    domainAst -->|compile_sql| sql[WHERE 子句 + 参数]

Mermaid 大图

可滚动查看图表,点击缩放比例可恢复 100%。按 Esc 关闭。

Lexer 只识别词,parser 只确认组合,语义检查器才知道当前领域允许哪些字段;两个执行后端只读取通过检查的领域 AST。这里的箭头表示一次规则从文本进入两个后端的数据流,不表示部署关系。

这张图也暴露了一个常见误解:YACC 不负责整门语言。它不会自动定义业务字段、类型、权限、执行成本、重试或副作用;它集中解决的是 Token 流 → 语法结构

YACC 自己也是一门 DSL

YACC 接受的 grammar 文件并不是普通 C 源码。它有 %token、优先级声明、产生式和 semantic action;这些构件组成了一门“描述另一门语言语法”的语言。POSIX 规范甚至使用 YACC 输入语言描述 YACC 输入语言的一部分。2

所以名字里的 Compiler-Compiler 不表示它能凭空生成完整编译器,而是表示它把一份声明式语言规范转换为 parser 源码:

text
rule.y --bison--> rule.tab.c --cc--> rule parser

本实验的目标 DSL 只有比较、andor 和括号。下面是正式 grammar 的核心部分:

text
%token <text> FIELD
%token <number> NUMBER
%token AND OR GE LE EQ NE

%left OR
%left AND

%%

expression:
      comparison
    | expression AND expression
        { $$ = make_logical("and", $1, $3); }
    | expression OR expression
        { $$ = make_logical("or", $1, $3); }
    | '(' expression ')'
        { $$ = $2; }
;

comparison:
    FIELD comparison_operator NUMBER
        { $$ = make_comparison($1, $2, $3); }
;

FIELDNUMBERAND 是 lexer 写出的 terminal token;expressioncomparison 是 parser 内部形成的 nonterminal。产生式右侧匹配成功时,$1$2$3 分别读取右侧对象的 semantic value,$$ 写出归约后左侧对象的 semantic value。

当前 action 只调用 make_comparisonmake_logical 建立 AST。它不读取设备、不执行 SQL,也不发送告警。YACC 允许 action 直接计算结果,但将副作用塞进 grammar 会让语法测试触发业务操作,也会迫使每个后端重新解析文本。Bison 对 semantic action 的原始定义是“规则被识别时执行的代码”;让它只构造结构,是本项目基于多后端需求做出的约束,不是 YACC 强制的行为。4

shift 与 reduce 如何把 Token 流变成树

运行 lexer 后,开篇规则先变成:

text
FIELD(temperature) '>' NUMBER(80)
AND
FIELD(vibration) GE NUMBER(6)
EOF

Lexer 在这里完成两个容易被忽略的决定。第一,temperature 是一个完整 FIELD,而不是若干字母;第二,>= 必须先被识别成 GE,不能拆成 '>' 和未知的 '='。Parser 从此只消费 token,不再读取原始字符。

Bison 生成的 parser 使用栈执行自底向上的处理:读取 token 并压栈叫作 shift;栈顶对象符合产生式后,用左侧 nonterminal 替换它们叫作 reduce。归约同时执行对应 action,计算新对象的 semantic value。5

开篇输入可以沿着下面的轨迹理解:

text
FIELD > NUMBER
      ↓ reduce comparison
Comparison(temperature, >, 80)

Comparison AND FIELD GE NUMBER
               ↓ reduce comparison
Comparison AND Comparison
          ↓ reduce expression AND expression
Logical(and)

最后的 Logical(and) 拥有两个 Comparison 子节点。空格、关键字原文和括号没有进入领域节点;后端只看到比较与逻辑组合。

这正是 YACC 与 AST 的连接点:YACC 的 parser 在 reduce 时得到局部结构,semantic action 把局部结构逐步组合为 AST。YACC 并不要求输出一定是树;action 也可以计算数值或生成代码,但对于需要验证、解释和编译多个后端的 DSL,AST 是更稳定的交接对象。

优先级冲突是语言设计反馈

如果 grammar 同时写下下面两条产生式,却没有定义优先级:

text
expression: expression AND expression
          | expression OR expression

那么 a or b and c 读到 and 时,parser 可能面临两个选择:先把 a or b reduce,或者继续 shift and,稍后形成 b and c。前者得到 (a or b) and c,后者得到 a or (b and c)

这类“既能 shift,又能 reduce”的状态叫 shift/reduce conflict。Bison 默认倾向 shift,但默认决策不应替代语言定义;官方手册建议理解并显式处理冲突,还可以生成反例证明 grammar 的歧义。6

实验使用:

text
%left OR
%left AND

YACC/Bison 的优先级按声明顺序递增,所以后声明的 ANDOR 优先;%left 还规定同一运算符按左结合归约。现在 a or b and c 只有 a or (b and c) 这一种预期结构。

这里值得迁移到普通业务开发的不是两行特殊语法,而是一种反馈机制:当 parser 报告冲突时,先问语言是否允许多个解释,再决定改 grammar、声明优先级或选择能保留多种解析的算法。 把冲突警告静音,只是隐藏了尚未完成的语言决策。

跑一次完整链路

配套实验位于 examples/dsl-yacc/。它使用系统已有的 Bison 和 C 编译器,在临时目录生成 rule.tab.c 与可执行文件;运行结束后删除生成物。

从仓库根目录执行:

bash
examples/dsl-yacc/user_code/run_demo.sh
examples/dsl-yacc/verify.sh

第一条命令会打印 token、AST 和两个后端的结果:

text
输入: temperature > 80 and vibration >= 6
Token: FIELD(temperature) '>' NUMBER(80) AND FIELD(vibration) GE NUMBER(6) EOF
领域 AST:
Logical(and)
  Comparison(temperature, >, 80)
  Comparison(vibration, >=, 6)
内存判断: 告警
SQL: ("temperature" > ? AND "vibration" >= ?)
参数: [80, 6]
输入: temperature > > 80
语法拒绝: 第 1 行,第 16 列附近:syntax error
输入: pressure > 10
语义拒绝: 未知字段:pressure

run_demo.sh 的连续调用链只有三步:

bash
bison -o "$build_dir/rule.tab.c" "$example_dir/core/rule.y"
cc -std=c11 "$build_dir/rule.tab.c" -o "$build_dir/rule-demo"
"$build_dir/rule-demo"

第一步读取 grammar 并写出 parser C 源码;第二步编译 parser、lexer、AST 与后端;第三步才读取规则并执行。verify.sh 还生成 parser report,发现任何意外 conflict 就失败,并断言成功、语法拒绝与语义拒绝三条路径都出现。

当前 macOS 自带 Apple Bison 2.3,因此实验只使用传统兼容语法。它足以证明 lexer、shift/reduce、semantic action 和 AST 边界;若要使用 GNU Bison 3.8.1 手册中的 conflict counterexample、IELR 或更完整诊断,应在 CI 和本地固定现代 Bison 版本,不能假设系统 bison 与文档一致。7

Parser 成功,不等于规则可以执行

第二条拒绝路径故意输入:

text
pressure > 10

Lexer 能把它分成 FIELD'>'NUMBER,parser 也能按 comparison 产生式建立 Comparison(pressure, >, 10)。如果程序在 yyparse() 返回成功后立刻执行,就把“语法正确”误当成了“业务获准”。

因此,parser 后还有一个显式的 validate_semantics

c
static int is_allowed_field(const char *field) {
    return strcmp(field, "temperature") == 0
        || strcmp(field, "vibration") == 0;
}

static int validate_semantics(const Node *node, char *message, size_t size) {
    if (node->kind == NODE_COMPARISON) {
        if (!is_allowed_field(node->value.comparison.field)) {
            snprintf(message, size, "未知字段:%s", node->value.comparison.field);
            return 0;
        }
        return 1;
    }

    return validate_semantics(node->value.logical.left, message, size)
        && validate_semantics(node->value.logical.right, message, size);
}

Grammar 读取“形状”,semantic validator 读取“含义”。字段是否存在、数值单位是否兼容、调用者是否有权限、目标后端是否支持某节点,都属于后者。即使 parser 没有任何 conflict,这些问题仍然可能失败。

对于 AI 生成的 DSL,这条边界尤其重要。模型可以输出语法合法的规则,但 parser 不能判断它是否引用敏感字段,也不能限制一次动作造成多少外部调用。建议让模型输出经过 grammar 约束的文本或结构,再依次执行语义检查、成本检查和授权;不要把“成功 parse”当作安全沙箱。

同一棵 AST 为什么比直接执行 action 更耐用

原始 YACC 示例常在 action 中直接计算表达式值,例如识别 expr + expr 时立即相加。对于计算器,这种写法足够;设备规则还要同时解释实时读数、编译 SQL、展示人类说明和记录审计证据,直接计算会过早丢失结构。

本实验把 action 的输出固定为两种领域节点:

c
typedef enum {
    NODE_COMPARISON,
    NODE_LOGICAL
} NodeKind;

struct Node {
    NodeKind kind;
    /* Comparison 或 Logical 的数据 */
};

evaluate 读取 Node + Metrics,写出真假;compile_sql 只读取 Node,写出 WHERE 子句和绑定参数。两个后端不依赖 YACC 的 parser stack,也不重新读取 source。以后前端换成表单、ANTLR 或手写 parser,只要仍生成相同领域 AST,后端不必跟着改变。

反过来也成立:如果系统只有一次即时计算,不需要保存、分析或转换结构,action 直接计算或者普通函数可能更简单。AST 的价值来自结构被多个阶段复用,不来自“树”这个名字。

现代开发应该继承什么

YACC 诞生于早期 Unix,但下面四个设计动作仍然直接影响今天的规则引擎、查询语言、策略系统和 AI 应用。

  1. 把输入语言写成可审查的契约:当括号、嵌套和优先级出现时,用 grammar 集中表达允许的组合,不让规则散落在正则、UI 与后端分支中。

  2. 让生成器承担机械代码:开发者维护 token、产生式和结构转换,parser generator 维护状态机与解析表;构建时检查 conflict,而不是手工同步大量状态判断。

  3. 把语法、语义与执行拆开:parser 判断输入能否形成结构;semantic validator 判断结构是否属于当前领域;interpreter/compiler 才决定怎样运行。

  4. 把冲突和拒绝变成测试:合法输入要固定预期 AST,非法输入要区分 lexical、syntax、semantic 与 runtime error;grammar 变化必须说明哪些历史输入改变了解释。

这种拆分也说明 YACC 不解决哪些工程问题。Parser 不保存任务状态,不处理分布式重试,不保证副作用幂等,也不统一 Python 与 SQL 的空值或时间语义。规则若能停机、发信或修改数据,执行层仍需要独立的任务 ID、权限、事务和审计记录。

Bison、ANTLR、Tree-sitter,还是手写 parser

工具选择应由消费场景决定,而不是由 grammar 看起来是否“像编译器”决定。

需求更合适的起点主要代价
C/C++ 运行时、批处理、传统 LR grammarYACC-compatible Bison生成代码和宿主语言耦合;错误体验需要额外设计
多目标语言、需要 parse tree listener/visitorANTLR 4引入工具与运行时;版本升级通常要重新生成 parser
编辑器高亮、增量更新、容错 CSTTree-sitterCST 面向源码结构,业务通常仍要转换为领域 AST
小型表达式 DSL、规则数量很少手写 recursive descent / Pratt parser状态、错误恢复和 grammar 演进由项目自己维护
固定字段、没有括号和嵌套JSON Schema / 类型化配置表达力受限,但通常更容易验证和编辑
表格、图形或投影式编辑直接编辑结构化模型需要专门编辑器,不再以普通文本为事实来源

ANTLR 4 可以从 grammar 生成 parse tree、listener 和 visitor,并支持多个目标语言。8 Tree-sitter 则生成面向编辑器的 CST,采用增量解析,并建议用输入与预期树组成 corpus tests。9

在当前设备规则里,如果目标只是交付 Python 功能,现有 ast.parse 加领域白名单已经足够;如果目标是学习 YACC、拥有完全独立于 Python 的语法,或将 parser 嵌入 C/C++ 运行时,Bison 实验才提供了真实增量。选择工具之前,先确认项目究竟需要独立语法、跨语言 parser,还是编辑器级 CST。

从实验走到生产,需要再补四道边界

当前实验把完整链路放在一个 rule.y 中,便于从 grammar 追踪到两个后端;生产代码不应照搬这个文件布局。

  1. 固定 parser context:避免依赖 source_textcursorroot 等全局变量;让每次解析拥有独立上下文,才能安全并发。

  2. 保存准确 source span:错误信息要指出 token 的开始与结束位置,而不是只报告 parser 读到哪里;AST 节点也应保留 span,方便 UI 标注。

  3. 限制结构与执行成本:限制 source 长度、token 数、AST 深度和后端操作数;lexer 与 parser 识别成功不代表输入成本可接受。

  4. 版本化语言事实:至少保存 sourcegrammarVersionastSchemaVersionpolicyVersion;grammar 升级时用历史 corpus 做兼容性比较。

上线前可以用下面的检查项收束:

  • grammar 是否对每个运算符定义了优先级与结合性;
  • CI 是否拒绝未经解释的 shift/reduce 和 reduce/reduce conflict;
  • lexer 是否覆盖最长匹配、Unicode、数字范围和错误字符;
  • parser action 是否只构造结构,没有业务副作用;
  • semantic validator 是否检查字段、类型、权限和后端支持;
  • 合法与非法 corpus 是否固定了输入、预期 AST 和错误类别;
  • 每个执行后端是否通过相同 AST 的一致性测试;
  • grammar、AST 与 policy 是否能独立升级和审计。

回到开篇规则,DSL 负责人决定 temperature、比较和逻辑组合是否属于语言;lexer 把字符切成 token;YACC 生成的 parser 通过 shift/reduce 建立 AST;semantic validator 拒绝 pressure;解释器与 SQL 编译器才消费通过检查的结构。

YACC 最值得保留的遗产不是某个 .y 文件格式,而是这条工程边界:先声明输入结构,让工具生成可靠的识别过程,再把语法结构、领域含义和执行效果分阶段处理。 当一段配置开始拥有括号、优先级和多个后端时,它已经不再只是字符串;承认它是一门小语言,通常比继续增加字符串技巧更便宜。

Footnotes

  1. Martin Fowler:Domain Specific Language。该文区分面向特定问题的 DSL、借用宿主语法的内部 DSL 与拥有独立 parser 的外部 DSL。

  2. The Open Group:yacc utility。规范定义 grammar 输入、生成的 C parser、yylex() 接口和 conflict report。 2

  3. Stephen C. Johnson:Yacc — Yet Another Compiler-Compiler。原始论文说明结构化输入、用户提供的 lexer、LALR(1) grammar 与 YACC 的非编译器用途。

  4. GNU Bison 3.8.1:Semantic Actions。Action 在产生式被识别时执行,并从右侧 semantic value 计算左侧 value。

  5. GNU Bison 3.8.1:The Bison Parser Algorithm。Parser stack 通过 shift 与 reduce 将 token 归约到 start symbol。

  6. GNU Bison 3.8.1:Shift/Reduce Conflicts。文档解释默认 shift、优先级消歧与 conflict counterexample。

  7. GNU Bison 3.8.1 Manual。Bison 可以从带注解的 context-free grammar 生成 LALR、IELR、canonical LR 或 GLR parser;具体能力需绑定工具版本。

  8. ANTLR 4 官方仓库。ANTLR 从 grammar 生成 parse tree、listener/visitor,并维护多个目标语言运行时。

  9. Tree-sitter:Writing the GrammarWriting Tests。Tree-sitter 面向增量 CST,并用 corpus 固定输入与预期树。