适用环境:示例在 Apple Bison 2.3、Apple clang 21.0.0 验证;文中现代 Bison 能力以 GNU Bison 3.8.1 手册为准。资料与代码核对日期为 2026-09-11。
同一句规则,谁负责“允许写”,谁负责“读得懂”
设想设备监测系统允许现场人员输入下面的告警规则:
temperature > 80 and vibration >= 6它看起来只是一行条件,但已经包含字段、数值、比较、逻辑连接和优先级。如果再加入 or 与括号,字符串切割很快就无法回答:
temperature < 0 or (temperature > 80 and vibration >= 6)左边成立时是否还要读取 vibration?括号怎样改变组合关系?and 和 or 谁先结合?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 子句 + 参数]Lexer 只识别词,parser 只确认组合,语义检查器才知道当前领域允许哪些字段;两个执行后端只读取通过检查的领域 AST。这里的箭头表示一次规则从文本进入两个后端的数据流,不表示部署关系。
这张图也暴露了一个常见误解:YACC 不负责整门语言。它不会自动定义业务字段、类型、权限、执行成本、重试或副作用;它集中解决的是 Token 流 → 语法结构。
YACC 自己也是一门 DSL
YACC 接受的 grammar 文件并不是普通 C 源码。它有 %token、优先级声明、产生式和 semantic action;这些构件组成了一门“描述另一门语言语法”的语言。POSIX 规范甚至使用 YACC 输入语言描述 YACC 输入语言的一部分。2
所以名字里的 Compiler-Compiler 不表示它能凭空生成完整编译器,而是表示它把一份声明式语言规范转换为 parser 源码:
rule.y --bison--> rule.tab.c --cc--> rule parser本实验的目标 DSL 只有比较、and、or 和括号。下面是正式 grammar 的核心部分:
%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); }
;FIELD、NUMBER 和 AND 是 lexer 写出的 terminal token;expression 与 comparison 是 parser 内部形成的 nonterminal。产生式右侧匹配成功时,$1、$2、$3 分别读取右侧对象的 semantic value,$$ 写出归约后左侧对象的 semantic value。
当前 action 只调用 make_comparison 或 make_logical 建立 AST。它不读取设备、不执行 SQL,也不发送告警。YACC 允许 action 直接计算结果,但将副作用塞进 grammar 会让语法测试触发业务操作,也会迫使每个后端重新解析文本。Bison 对 semantic action 的原始定义是“规则被识别时执行的代码”;让它只构造结构,是本项目基于多后端需求做出的约束,不是 YACC 强制的行为。4
shift 与 reduce 如何把 Token 流变成树
运行 lexer 后,开篇规则先变成:
FIELD(temperature) '>' NUMBER(80)
AND
FIELD(vibration) GE NUMBER(6)
EOFLexer 在这里完成两个容易被忽略的决定。第一,temperature 是一个完整 FIELD,而不是若干字母;第二,>= 必须先被识别成 GE,不能拆成 '>' 和未知的 '='。Parser 从此只消费 token,不再读取原始字符。
Bison 生成的 parser 使用栈执行自底向上的处理:读取 token 并压栈叫作 shift;栈顶对象符合产生式后,用左侧 nonterminal 替换它们叫作 reduce。归约同时执行对应 action,计算新对象的 semantic value。5
开篇输入可以沿着下面的轨迹理解:
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 同时写下下面两条产生式,却没有定义优先级:
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
实验使用:
%left OR
%left ANDYACC/Bison 的优先级按声明顺序递增,所以后声明的 AND 比 OR 优先;%left 还规定同一运算符按左结合归约。现在 a or b and c 只有 a or (b and c) 这一种预期结构。
这里值得迁移到普通业务开发的不是两行特殊语法,而是一种反馈机制:当 parser 报告冲突时,先问语言是否允许多个解释,再决定改 grammar、声明优先级或选择能保留多种解析的算法。 把冲突警告静音,只是隐藏了尚未完成的语言决策。
跑一次完整链路
配套实验位于 examples/dsl-yacc/。它使用系统已有的 Bison 和 C 编译器,在临时目录生成 rule.tab.c 与可执行文件;运行结束后删除生成物。
从仓库根目录执行:
examples/dsl-yacc/user_code/run_demo.sh
examples/dsl-yacc/verify.sh第一条命令会打印 token、AST 和两个后端的结果:
输入: 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
语义拒绝: 未知字段:pressurerun_demo.sh 的连续调用链只有三步:
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 成功,不等于规则可以执行
第二条拒绝路径故意输入:
pressure > 10Lexer 能把它分成 FIELD、'>'、NUMBER,parser 也能按 comparison 产生式建立 Comparison(pressure, >, 10)。如果程序在 yyparse() 返回成功后立刻执行,就把“语法正确”误当成了“业务获准”。
因此,parser 后还有一个显式的 validate_semantics:
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 的输出固定为两种领域节点:
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 应用。
-
把输入语言写成可审查的契约:当括号、嵌套和优先级出现时,用 grammar 集中表达允许的组合,不让规则散落在正则、UI 与后端分支中。
-
让生成器承担机械代码:开发者维护 token、产生式和结构转换,parser generator 维护状态机与解析表;构建时检查 conflict,而不是手工同步大量状态判断。
-
把语法、语义与执行拆开:parser 判断输入能否形成结构;semantic validator 判断结构是否属于当前领域;interpreter/compiler 才决定怎样运行。
-
把冲突和拒绝变成测试:合法输入要固定预期 AST,非法输入要区分 lexical、syntax、semantic 与 runtime error;grammar 变化必须说明哪些历史输入改变了解释。
这种拆分也说明 YACC 不解决哪些工程问题。Parser 不保存任务状态,不处理分布式重试,不保证副作用幂等,也不统一 Python 与 SQL 的空值或时间语义。规则若能停机、发信或修改数据,执行层仍需要独立的任务 ID、权限、事务和审计记录。
Bison、ANTLR、Tree-sitter,还是手写 parser
工具选择应由消费场景决定,而不是由 grammar 看起来是否“像编译器”决定。
| 需求 | 更合适的起点 | 主要代价 |
|---|---|---|
| C/C++ 运行时、批处理、传统 LR grammar | YACC-compatible Bison | 生成代码和宿主语言耦合;错误体验需要额外设计 |
| 多目标语言、需要 parse tree listener/visitor | ANTLR 4 | 引入工具与运行时;版本升级通常要重新生成 parser |
| 编辑器高亮、增量更新、容错 CST | Tree-sitter | CST 面向源码结构,业务通常仍要转换为领域 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 追踪到两个后端;生产代码不应照搬这个文件布局。
-
固定 parser context:避免依赖
source_text、cursor和root等全局变量;让每次解析拥有独立上下文,才能安全并发。 -
保存准确 source span:错误信息要指出 token 的开始与结束位置,而不是只报告 parser 读到哪里;AST 节点也应保留 span,方便 UI 标注。
-
限制结构与执行成本:限制 source 长度、token 数、AST 深度和后端操作数;lexer 与 parser 识别成功不代表输入成本可接受。
-
版本化语言事实:至少保存
source、grammarVersion、astSchemaVersion与policyVersion;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
-
Martin Fowler:Domain Specific Language。该文区分面向特定问题的 DSL、借用宿主语法的内部 DSL 与拥有独立 parser 的外部 DSL。 ↩
-
The Open Group:
yaccutility。规范定义 grammar 输入、生成的 C parser、yylex()接口和 conflict report。 ↩ ↩2 -
Stephen C. Johnson:Yacc — Yet Another Compiler-Compiler。原始论文说明结构化输入、用户提供的 lexer、LALR(1) grammar 与 YACC 的非编译器用途。 ↩
-
GNU Bison 3.8.1:Semantic Actions。Action 在产生式被识别时执行,并从右侧 semantic value 计算左侧 value。 ↩
-
GNU Bison 3.8.1:The Bison Parser Algorithm。Parser stack 通过 shift 与 reduce 将 token 归约到 start symbol。 ↩
-
GNU Bison 3.8.1:Shift/Reduce Conflicts。文档解释默认 shift、优先级消歧与 conflict counterexample。 ↩
-
GNU Bison 3.8.1 Manual。Bison 可以从带注解的 context-free grammar 生成 LALR、IELR、canonical LR 或 GLR parser;具体能力需绑定工具版本。 ↩
-
ANTLR 4 官方仓库。ANTLR 从 grammar 生成 parse tree、listener/visitor,并维护多个目标语言运行时。 ↩
-
Tree-sitter:Writing the Grammar 与 Writing Tests。Tree-sitter 面向增量 CST,并用 corpus 固定输入与预期树。 ↩