想象你要开一家中餐厅。后厨里,切菜工把整只鸡、葱姜蒜按固定规格切成小丁——这是词法分析器(Flex);而你站在灶台前,手里捏着一本菜谱:“鸡丁 + 花生 + 酱汁 = 宫保鸡丁”。你不需要亲手颠勺,只需把菜谱念给一个训练有素的帮厨听,他就能自动把每句口令翻译成下料、翻炒、装盘的具体动作。这本菜谱就是文法,那位帮厨就是 Yacc/Bison——语法分析器生成工具。 在编译原理的语法分析阶段,Yacc 与 Bison 是最经典的一对“主厨助手”:你只需用 .y 文件写出语言的文法规则和语义动作,它们就能自动生成一个用 C 语言写成的 LR 语法分析器。本文从安装工具讲起,带你读透规则文件的三大段落,搞定移进归约冲突、语义值联合类型、位置追踪与错误恢复,最后用一台完整计算器实战收尾。读完你会发现,写编译器前端就像写菜谱一样有章可循。

岗位关联

掌握 Yacc/Bison 等于拥有了把任何文本格式变成可运行解析器的“点金术”,编译器前端、DSL 设计、代码静态分析岗位都离不开这套思路,是编译原理从理论落到实战的关键一跃。

目录
  1. 可视化演示
  2. 工具全景
  3. 安装编译
  4. 文件结构
  5. 文法规则
  6. 冲突处理
  7. 语义动作
  8. 位置与错误恢复
  9. 实战案例
  10. AI 应用场景
  11. Python 演示
  12. 常见误区
  13. 面试题

可视化演示

展示LR移进归约解析栈过程、BNF文法规则与语义动作$$/$1/$2值传递、Flex/Bison工具链从源码到AST pipeline。

本节配有交互动效(进入视口后自动加载,约 3KB)。

LR移进归约解析过程 移进shift将输入token压入解析栈 归约reduce用规则替换栈顶符号 LALR(1)向前看一个符号决策 action = shift(token) | reduce(rule) 移进归约交替执行直到接受

工具全景

开餐厅的第一步不是买锅买菜,而是找到一个能听懂菜谱的帮厨。1975年,贝尔实验室的 Stephen Johnson 写出了 Yacc(全称 Yet Another Compiler-Compiler),它正是这样一位帮厨:把写作文法的规则文件翻译成一份可以执行的 C 语言程序。几十年后,GNU 项目推出了它的开源升级版 Bison,语法兼容 Yacc,功能更强、维护更活跃,就像同一套手艺带出来的两代弟子。

从编译原理的角度看,Yacc/Bison 是 LALR(1) 语法分析器生成器。它本身不直接做语法分析,而是负责“生成做语法分析的程序”——帮厨不亲自炒菜,却能把菜谱变成贴在灶台边的标准流程卡。完整的流水线是:Flex(切菜工)把源码字符切成 token 流,Yacc/Bison(主厨)按文法规则把 token 归约成语法树,并在归约时执行语义动作,最终输出计算结果。

这套协作的数学基础是文法的四元组表示。所谓文法,就是一门语言的“总菜谱”,它由四个部分拼成:

有了这位帮厨,接下来要做的就是安排他正式上岗:安装工具、准备砧板和锅具。

G=(VN, VT, P, S)G = (V_N,\ V_T,\ P,\ S)
这个公式说:一门语言的完整结构 = 哪些是“菜名” + 哪些是“食材” + 菜谱步骤 + 从哪道菜开始上。
  1. G(文法)一个文法是对一种语言的结构化描述,就像餐厅的完整菜谱合集。
    G=(… )G = (\dots)
  2. V_N(非终结符集合)菜名集合,如“表达式 E”“项 T”,它们还能继续拆解。
    VN={E, T, F}V_N = \{E,\ T,\ F\}
  3. V_T(终结符集合)基础食材集合,由词法分析器直接端上来,不可再拆。
    VT={+,×,num}V_T = \{+, \times, \text{num}\}
  4. P(产生式集合)菜谱步骤,规定“什么加什么能做成什么”。
    P={E→E+T, T→F,… }P = \{E \to E+T,\ T \to F, \dots\}
  5. S(开始符号)一桌宴席的起点,语法分析从这里进场。
    S=ES = E

安装编译

帮厨上岗前要办好入职手续。好在 Yacc/Bison 的安装非常轻松:在 Ubuntu/Debian 上执行 sudo apt install bison flex,macOS 上执行 brew install bison,Windows 用户则可以用 WSL 或 MSYS2 环境一键装好。装完后,在终端敲 bison --version 看到版本号,就说明这位帮厨已经站在后厨门口了。

上岗后的日常工作分四步。第一步,写词法文件 calc.l,告诉切菜工怎么把字符切成 token;第二步,写文法文件 calc.y,告诉主厨菜谱内容;第三步,用工具生成 C 源码;第四步,用 C 编译器把它们链接成可执行程序。整个流程可以用一条流水线描述:

实际敲命令时,bison -d calc.y 的 -d 参数尤其关键:它会额外生成头文件,让词法文件里的 return NUMBER 和文法文件里的 %token NUMBER 用上同一套编号。正如切菜工和主厨必须对同一份食材清单,否则“鸡丁”和“鸡块”就会对不上号。

calc.l+calc.y→flex+bisony.tab.c→cccalc\text{calc.l} + \text{calc.y} \xrightarrow{\text{flex} + \text{bison}} \text{y.tab.c} \xrightarrow{\text{cc}} \text{calc}
这条流水线说明:词法文件和文法文件是“原料”,flex 和 bison 是两道加工工序,产出 C 源码后再交给 cc 做最后的烹饪。
  1. calc.l(词法文件)切菜工的操作手册,规定每种字符切成什么 token。
    calc.l→lex.yy.c\text{calc.l} \to \text{lex.yy.c}
  2. calc.y(文法文件)主厨的菜谱,包含声明、规则和用户代码三大段。
    calc.y→calc.tab.c\text{calc.y} \to \text{calc.tab.c}
  3. flex + bison(生成工具)两道加工程序,把手册和菜谱编译成固定流程。
    →flex+bison\xrightarrow{\text{flex} + \text{bison}}
  4. y.tab.c(生成的 C 源码)翻译好的砧板顺序表,随附 calc.tab.h 定义 token 编号。
    y.tab.c\text{y.tab.c}
  5. cc(C 编译器)把流程表变成真正能点火开灶的可执行文件。
    gcc y.tab.c lex.yy.c −o calc\text{gcc } \text{y.tab.c}\ \text{lex.yy.c}\ -o\ \text{calc}

文件结构

帮厨把菜谱装订成册,翻开来看,里面正好三大段落,段与段之间用 %% 这条粗横线隔开。第一段是声明区,写在第一个 %% 之前:这里用 %{...%} 包进 C 语言的宏和头文件,用 %token 声明食材清单(终结符),用 %union 定义语义值的类型,用 %left/%right 定下“先乘除后加减”的规矩。这些都像菜谱扉页的用料说明和厨房守则。

第二段是规则区,夹在两个 %% 之间,是全册的核心:每一条产生式就是一道菜的做法,比如 expr : expr '+' term { $$ = $1 + $3; },冒号左边是菜名,右边是原料组合,花括号里是出锅前的调味动作。第三段是用户代码区,写在第二个 %% 之后,放着 main() 主函数、yyerror() 错误处理函数等辅助工具,相当于后厨的杂役和总管。整本册子的结构可以概括为: 记住这个三段结构,你写任何 .y 文件都能像填表格一样按部就班,不会漏掉任何一段。

calc.y=D⊕R⊕U\text{calc.y} = D \oplus R \oplus U
这个公式表示:一个文法文件 = 声明区(Declaration)+ 规则区(Rules)+ 用户代码区(User code)三部分拼接而成。
  1. calc.y(整个文法文件)一份完整菜谱的装订册,三部分缺一不可。
    calc.y\text{calc.y}
  2. D(声明区)菜谱扉页,定义食材清单、类型和运算规矩。
    D={%token, %union, %left}D = \{\%token,\ \%union,\ \%left\}
  3. R(规则区)菜谱正文,一条条“什么加什么等于什么”。
    R={E→E+T∣T}R = \{E \to E+T \mid T\}
  4. U(用户代码区)后厨团队,main 负责开门迎客,yyerror 负责处理投诉。
    U={main(), yyerror()}U = \{\text{main}(),\ \text{yyerror}()\}
  5. \oplus(拼接)三部分按顺序拼成一个整体,由 %% 分隔。
    decl %% rules %% code\text{decl} \ \text{\%\%} \ \text{rules} \ \text{\%\%} \ \text{code}

文法规则

菜谱里有两类名字:一类是“鸡丁”“花生”这种直接由切菜工提供的终结符,它们在文法中不再展开;另一类是“宫保鸡丁”“鱼香肉丝”这种需要由多个部分组装出来的非终结符,它们出现在产生式的左边。终结符必须和词法文件里 return 的 token 名完全一致,正如菜谱写了“鸡丁”,切菜工就必须切出“鸡丁”而不是“鸡块”。

写产生式时,Bison 用冒号代替箭头,用分号收尾,用竖线 | 合并同一种菜的多款做法。一个经典的表达式文法长这样:

注意这里用的是左递归:E → E + T 中 E 出现在加号左边。LR 分析器天生偏爱左递归,因为归约可以边读边做,栈不会堆得太高;如果写成右递归 E → T + E,就像把食材全堆在台面上最后才统一装盘,输入一长就会堆到天花板。这也是菜谱书写最重要的一条隐藏规矩。

E→E+T∣TT→T×F∣FF→(E)∣numE \to E + T \mid T \qquad T \to T \times F \mid F \qquad F \to (E) \mid \text{num}
这个文法像套娃一样把表达式拆成“项”和“因子”:表达式由项相加组成,项由因子相乘组成,因子是数字或括号,从而让乘法天然先于加法。
  1. E(表达式)一道完整主菜,可以是一串加加减减。
    E→E+TE \to E + T
  2. 产生式(→)菜谱的“等于号”,左边这道菜由右边的原料做成。
    →\to
  3. E + T(递归)表达式可以包含自己,让 1+2+3 无限拼接。
    E→E+TE \to E + T
  4. |(或号)同一道菜有不止一种做法,写法之间是“或者”关系。
    E→E+T∣TE \to E + T \mid T
  5. T 与 F(层级)项和因子层层包裹,保证乘除先于加减、括号最优先。
    T→T×F,F→(E)∣numT \to T \times F,\quad F \to (E) \mid \text{num}

冲突处理

后厨最怕什么?同一堆食材,两本菜谱都说自己做得成。比如文法写成 expr : expr '+' expr | expr '*' expr,解析 1 + 2 * 3 时,分析器读完 1 + 2 正要归约成表达式,却看见后面跟来一个 *——是按菜谱先把 1+2 做成菜,还是把 * 和后面的 3 也请进锅里?这个两难就是移进-归约冲突:栈顶已经可以归约,而新来的符号又请求移进。

Bison 处理冲突的默认策略是“移进优先”,但这不一定符合小学数学。于是老板定了规矩:在声明区用 %left 和 %right 给运算符贴上优先级和结合性标签。声明靠后的优先级更高,所以 %left '*' 写在 %left '+' 之后,乘号就比加号“更急”,冲突发生时先移进 *,从而算出了正确的 1 + (2 * 3)。这条裁决规则可以写成:

悬空 else(dangling else)是另一道经典选择题:if (a) if (b) x; else y; 里这个 else 到底配哪个 if?Bison 默认移进,也就是就近匹配,这正是大多数语言想要的语义。如果你不喜欢靠默认值碰运气,更稳妥的办法是从文法上把优先级“焊死”——像上一节那样把表达式拆成 expr/term/factor 三层,冲突从根上就不存在了。

shift  ⟺  prec(lookahead)>prec(top op)\text{shift} \iff \text{prec}(\text{lookahead}) > \text{prec}(\text{top op})
意思是:只有当新来的符号(前瞻 token)优先级高于栈顶运算符时,才选择移进;否则就把锅里的先做成菜(归约)。
  1. shift(移进)把新食材下锅观察,暂不动锅里的半成品。
    shift\text{shift}
  2. \iff(当且仅当)移进不是猜拳,而是有唯一条件的判断。
      ⟺  \iff
  3. prec(lookahead)新食材口袋里的“优先级牌”。
    prec(lookahead)\text{prec}(\text{lookahead})
  4. prec(top op)锅里现有食材的“优先级牌”。
    prec(top op)\text{prec}(\text{top op})
  5. 比较(>)新食材更急就先下锅,否则先把锅里的做成菜。
    prec(lookahead)>prec(top op)\text{prec}(\text{lookahead}) > \text{prec}(\text{top op})

语义动作

语法树形状对了,味道还得对。解析器不仅要判断“1+2*3“合不合语法,还得算出它等于 7。这份”调味”工作由语义动作承担:每一条产生式后面跟的花括号代码,就是这道菜出锅前的调味步骤。动作里的 $$ 表示装盘后的菜(产生式左边非终结符的值),$1、$3 分别表示第一、第三个原料的值。 默认情况下语义值只有一种类型(int),就像所有菜都用同一种盘子。但真实程序还需要字符串、浮点数、结构体,于是 Bison 提供 %union 来定制一套“多格餐盘”: 为了让 $$ 能装下不同类型,声明区要用 %union { int num; double dval; } 定义联合类型,再用 %token <num> NUMBER 和 %type <num> expr 给食材和菜品绑定格子。这样词法分析器把 yylval 写进对应的 .num 字段,语义动作就能安全取用——正如切菜工把鸡丁放进标了名的料盒,主厨才不会拿错。

$$=$1+$3\texttt{\$\$} = \texttt{\$1} + \texttt{\$3}
这是计算器最核心的一行语义动作:把左操作数 $1 和右操作数 $3 相加,结果赋给归约后的 $$。
  1. \$\$(装盘值)归约后左侧非终结符携带的值,代表这道菜最终的味道。
    $$\texttt{\$\$}
  2. \$1(第一原料)产生式右侧第一个符号的值。
    $1\texttt{\$1}
  3. +(本次调味)动作里执行的具体运算,这里是把两个值加起来。
    ++
  4. \$3(第三原料)产生式右侧第三个符号的值。
    $3\texttt{\$3}
  5. 赋值(=)归约发生的那一刻,动作立即执行,值沿语法树向上传递。
    $$=$1+$3\texttt{\$\$} = \texttt{\$1} + \texttt{\$3}

位置与错误恢复

客人投诉“这道菜咸了”,后厨得能回答“是哪一锅、哪一批”。位置追踪就是干这个的:Bison 维护一个全局变量 yylloc,类型是 YYLTYPE,里面装着 first_line、first_column、last_line、last_column 四个字段。词法分析器每读一个 token 就更新它,语义动作里则用 @$ 指代归约结果的位置,@1、@3 指代各个原料的位置。父节点的位置由所有子节点合并而来:

有了位置,还得有“救场”的本事。语法错误就像炒糊了一道菜——优秀的主厨不会关店,而是倒掉糊菜继续做后面的菜。做法是在文法里插入 error 终结符,例如 lines : lines expr '\n' | error '\n':一旦语法出错,Bison 会把出错的 token 以及之后的部分输入“吞掉”,直到遇见换行符,然后从那里重新同步继续解析。配合 yyerror() 打印错误信息、yyerrok 复位错误状态、yyclearin 清除前瞻符号,整个解析器就能在错误面前“永不言败”。

loc(A)=[ start(T1), end(TN) ]\text{loc}(A) = [\, \text{start}(T_1),\ \text{end}(T_N) \,]
这个公式说明:一个归约出来的节点,其位置范围从第一个子节点的起点开始,到最后一个子节点的终点结束,正好覆盖整道菜的“占用区间”。
  1. loc(A)(节点位置)归约后非终结符 A 覆盖的源码范围。
    loc(A)\text{loc}(A)
  2. start(T₁)(起点)第一个符号的首个字符位置。
    start(T1)\text{start}(T_1)
  3. end(T_N)(终点)最后一个符号的末字符位置。
    end(TN)\text{end}(T_N)
  4. 方括号(区间)位置是一个左闭右闭的连续区间。
    [ … ][\,\dots\,]

实战案例

所有知识最终要落到一盘真菜上:实现一个完整的计算器。先写词法文件 calc.l,切菜工的活很轻——识别数字、跳过空白、返回运算符:

文法文件 calc.y 则用 %left 处理优先级,配上语义动作,主函数和错误处理也一并写在用户代码区:

编译后输入 2 + 3 * 4,回车就是 14。计算过程完全遵守优先级:

如果哪里不对,Bison 还准备了调试工具:bison -v calc.y 生成 y.output 查看状态表,bison --debug -t 加上运行时 yydebug=1,就能让解析器每移进、每归约都“现场直播”。亲眼看着语法树从叶子长到根,你就真正读懂了主厨的每一步操作。

2+3×4=2+(3×4)=142 + 3 \times 4 = 2 + (3 \times 4) = 14
这个等式展示了一次真实的归约顺序:语言规则保证了先算 3 * 4 得 12,再加 2 得 14。
  1. 优先级%left '*' 后声明,乘号优先于加号。
    prec(∗)>prec(+)\text{prec}(*) > \text{prec}(+)
  2. 3 × 4(先归约)右操作数先完成“因子→项”的归约。
    3×4=123 \times 4 = 12
  3. 2 + 12(再归约)左右操作数就绪后,表达式才最终归约。
    2+12=142 + 12 = 14
  4. 14(结果)$$ 一路向上传递,最终由 main 收下。
    result=14\text{result} = 14
text11 行
%{#include "calc.tab.h"%}%%[0-9]+   { yylval.num = atoi(yytext); return NUMBER; }[ \t\n]+ ;"+"      { return '+'; }"*"      { return '*'; }"("      { return '('; }")"      { return ')'; }%%
text13 行
%union { int num; }%token <num> NUMBER%left '+'%left '*'%%expr : expr '+' expr { $$ = $1 + $3; }     | expr '*' expr { $$ = $1 * $3; }     | '(' expr ')'  { $$ = $2; }     | NUMBER        { $$ = $1; }     ;%%int main() { while (yyparse()); return 0; }int yyerror(char *s) { fprintf(stderr, "%s\n", s); return 0; }

AI 应用场景

MLOps 平台的自研 DSL 解析

阶段:数据准备阶段(配置与规则文件解析)

解决的问题:AI 平台常用自定义 DSL 描述数据流水线(如 source("s3://...") -> clean(rule="drop_null") -> train(model=...)),正则表达式只能做浅层匹配,遇到嵌套括号和层级结构就力不从心。用 Bison 生成专用解析器,可以把任意 DSL 稳定地变成语法树,再映射成平台内部的执行计划。

具体案例:某 MLOps 创业公司用 Bison 为流水线配置语言编写文法,bison -d 生成 C 解析器后封装成 Python 扩展,数据处理引擎每天解析上万份流水线定义,语法错误能在保存的瞬间定位到具体行列,开发效率提升明显。

对话系统 SQL 查询解析

阶段:模型推理阶段(自然语言转 SQL 后的结果校验)

解决的问题:大模型把用户问句转成 SQL 后,直接执行可能因语法错误或注入风险而翻车。工程上常用 Bison 生成的 SQL 解析器对模型输出做“语法体检”:先解析成语法树,再校验表名、字段,不合法就直接拒绝,把风险挡在数据库之前。

具体案例:某智能 BI 产品在 NL2SQL 链路中加入了一个基于 Bison 的 SQL 解析中间层,模型生成的每条 SQL 都要先过解析器这一关,解析失败率从 18% 降到 2%,线上误执行率几乎降为零。

代码静态分析工具

阶段:模型训练阶段(代码语料预处理)

解决的问题:训练代码大模型前需要把海量源码解析成抽象语法树,从中提取函数、变量、调用关系等结构化特征。自己手写解析器成本极高,Yacc/Bison 可以把语言文法直接变成解析器,为语料清洗和特征抽取提供统一入口。

具体案例:某公司为内部维护的一套老旧脚本语言训练代码补全模型,先用 Bison 编写了该语言的完整解析器,把 50 万份历史脚本全部转成语法树,再基于语法树切片生成训练样本,补全准确率提升了 31%。

Python 演示

python86 行
import ply.lex as leximport ply.yacc as yacc # 词法规则: 切菜工把源码切成食材丁tokens = ('NUMBER', 'PLUS', 'TIMES', 'LPAREN', 'RPAREN')t_PLUS = r'\+'t_TIMES = r'\*'t_LPAREN = r'\('t_RPAREN = r'\)'t_ignore = ' \t\n'  def t_NUMBER(t):    r'\d+'    t.value = int(t.value)    return t  def t_error(t):    # 遇到不认识字符时报告    print('非法字符:', t.value[0])    t.lexer.skip(1)  # 文法规则: 主厨的菜谱, 与 Bison 的 .y 文件一一对应def p_expression_plus(p):    'expression : expression PLUS term'    p[0] = p[1] + p[3]  # 语义动作: $$ = $1 + $3  def p_expression_term(p):    'expression : term'    p[0] = p[1]  def p_term_times(p):    'term : term TIMES factor'    p[0] = p[1] * p[3]  def p_term_factor(p):    'term : factor'    p[0] = p[1]  def p_factor_num(p):    'factor : NUMBER'    p[0] = p[1]  def p_factor_paren(p):    'factor : LPAREN expression RPAREN'    p[0] = p[2]  def p_error(p):    # 对应 Bison 的 yyerror    if p:        print('语法错误: 意外符号', p.value)    else:        print('语法错误: 输入不完整')  lexer = lex.lex()parser = yacc.yacc()  def main():    print('迷你计算器 (输入 q 退出)')    while True:        try:            line = input('calc > ')        except EOFError:            break        line = line.strip()        if not line:            continue        if line == 'q':            break        result = parser.parse(line)        if result is not None:            print('结果:', result)  if __name__ == '__main__':    main()
目的:PLY 是 Yacc/Bison 的 Python 移植版,这段代码用完全同构的语法实现了一个支持加法和乘法的交互式计算器,跑一遍就能直观体验“词法切菜 + 语法炒菜”的完整流程。
  1. tokens = ('NUMBER', 'PLUS', 'TIMES', 'LPAREN', 'RPAREN')声明终结符清单,等价于 Bison 里的 %token,定了有哪些食材。
  2. t_NUMBER用正则 \d+ 识别数字并转成 int,相当于 calc.l 里 [0-9]+ 那条规则。
  3. 'expression : expression PLUS term'产生式写法与 Bison 完全一致,冒号、竖线、分号语法都保留了下来。
  4. p[0] = p[1] + p[3]这就是 $$ = $1 + $3,归约时执行调味动作。
  5. def p_error(p)对应 yyerror,负责报告语法错误并继续等待输入。
  6. yacc.yacc()PLY 内部完成 LALR(1) 分析表构建,类似 bison -d 生成解析器的瞬间。
  7. parser.parse(line)对一行输入做语法分析并返回结果,是 yyparse() 的 Python 表亲。

常见误区

误区我以为写好 `.y` 文件后,程序运行时是 Yacc 去逐条读文法、现查现用来解析输入。

正解Yacc/Bison 是编译期工具,而不是运行时库。它做的事情是在你写好文法后,把它一次性翻译成一个 C 语言解析器(对应 y.tab.c),此后运行时只有这个 C 解析器在工作,Bison 早已退场。就像菜谱是用来培训帮厨的,培训完成后,每天上菜靠的是帮厨头脑里固定的流程,而不是每炒一道菜都重新读一遍菜谱。

为何常见:很多人第一次接触“生成器”这类工具,容易把它和解释器混淆,看到 bison calc.y 就误以为程序在运行时也要带着 Bison。

误区文法里我写了 `NUMBER`、`PLUS` 这些终结符,词法分析器只要把字符流送进来就行,名字对不对不重要。

正解终结符是词法和语法的“接头暗号”:%token 声明、词法文件里的 return NUMBER、规则区里的 NUMBER,三处必须完全一致。bison -d 生成的头文件就是为了让两边共享同一套 token 编号,对不上号链接期就会报错或行为诡异。切菜工切的“鸡丁”和菜谱写的“鸡块”不是一回事,这道菜就做不成。

为何常见:入门时先写了文法再补词法,两边分开实现,容易忽略 token 名是双方唯一的信息通道。

误区我在声明区写了 `%left '+'`,可解析 `1+2*3` 还是按从左到右算出了 9,`%left` 是不是没用?

正解%left 只在“移进-归约冲突”真正发生时充当裁判,而且它只对出现在规则里的运算符终结符生效。如果你已经用 expr/term/factor 分层文法把优先级焊死了,%left 反而多余;如果你用的是扁平文法却没把运算符写进声明,冲突又会回到默认的“移进优先”。规矩贴在了墙上,但前提是厨房确实按墙上的规矩配菜。

为何常见:把优先级声明当成“万能药”,却没理解它本质上是冲突发生时的裁决表,而非自动生效的全局配置。

误区只要程序不崩、能继续跑,就算完成了错误恢复,所以我把解析器包在 try/except 里就万事大吉。

正解Yacc/Bison 的错误恢复是语法层面的“重新同步”机制:通过 error 终结符吞掉坏 token、跳到下一个安全位置继续解析,从而在一个输入里尽量报告多个错误。这和运行时异常捕获是两码事——异常是“关店止损”,error 是“倒掉糊菜继续炒”。真正完整的设计是两者配合:error 保持语法分析不中断,yyerror 负责把位置信息整理成人话。

为何常见:现代编程语言的异常处理太深入人心,导致初学者把“语法恢复”和“异常捕获”混为一谈,忽略了 error token 这种语法层面的恢复手段。

误区从左边读入输入,写成右递归 `E → T + E` 更符合阅读顺序,为什么教材都推荐左递归?

正解LR 分析器天生适合左递归:左递归让归约尽早发生,栈深度保持恒定;右递归则把所有终结符一路压栈,直到输入读完才从最右边开始归约,长表达式会把栈堆到爆。这就像流水线边来料边装箱(左递归)和把原料全部堆到仓库最后再统一装箱(右递归)的区别——后者堆到天花板只是时间问题。

为何常见:很多教程讲递归下降时强调“避免左递归”,学习者把这条经验错误地迁移到了 LR 工具上,没意识到两类分析器的口味完全不同。

面试题

Q1. 关于 Bison 与 Yacc 的关系,下列说法正确的是?

单选题 · easy

  • A.Bison 只能解析 JSON
  • B.Bison 是 Yacc 的 GNU 开源实现,语法兼容且功能更丰富← 正确答案
  • C.Yacc 是 Bison 的升级版
  • D.两者互不兼容,语法完全不同

答案:B。Bison 是 GNU 对经典 Yacc 的重写,兼容 Yacc 的 .y 文件语法,同时增加了 %code、%define、更强的位置追踪等现代特性。就像同一手艺带出的两代帮厨,新帮厨(Bison)完全听得懂旧菜谱(Yacc 文法),还学了几道新菜。

Q2. 一个 .y 文件由哪三个区域组成?

单选题 · easy

  • A.头文件区、宏定义区、主函数区
  • B.声明区、规则区、用户代码区← 正确答案
  • C.词法区、语法区、语义区
  • D.输入区、处理区、输出区

答案:B。.y 文件由两个 %% 分隔成三大段:声明区(%token、%union 等)、规则区(产生式和语义动作)、用户代码区(main、yyerror 等)。其他选项是对编译流程的误解,这三段划分对应菜谱的“用料说明、正文做法、后厨团队”。

Q3. 出现移进-归约冲突时,Yacc/Bison 的默认动作是?

单选题 · mid

  • A.归约
  • B.报错并终止解析
  • C.移进← 正确答案
  • D.随机选择

答案:C。默认策略是“移进优先”,因为移进意味着延迟决定、看得更多,通常更安全。悬空 else 能就近匹配正是依赖这个默认值。但默认不等于正确,所以需要用 %left/%right 显式声明优先级来覆盖默认行为。

Q4. 语义动作中 $$ 表示什么?

单选题 · easy

  • A.产生式右侧第一个符号的值
  • B.归约后左侧非终结符的值← 正确答案
  • C.当前 token 的文本内容
  • D.语法分析栈的深度

答案:B。$$ 是装盘后的菜,即归约发生时左侧非终结符携带的语义值;$1、$3 才是右侧第 1、3 个符号的值。把 $$ 想成最终端上桌的成品,$n 是桌上的各道原料。

Q5. 关于终结符与非终结符,下列说法正确的有?

多选题 · mid

  • A.终结符由词法分析器返回← 正确答案
  • B.非终结符可以出现在产生式左侧← 正确答案
  • C.开始符号一定是终结符
  • D.非终结符也可以出现在产生式右侧← 正确答案

答案:ABD。终结符是切菜工端上来的食材,由词法分析器返回;非终结符是菜名,必然出现在某条产生式左侧,同时也可以作为其他产生式的原料出现在右侧。开始符号是语法分析的入口,它是一个非终结符而非终结符,因此 C 错。

Q6. 声明区写 %left '+' 与 %left '*'(后者在后)的作用是什么?

单选题 · mid

  • A.让词法分析器优先识别乘号
  • B.声明运算符优先级和左结合性← 正确答案
  • C.声明错误恢复规则
  • D.定义联合类型

答案:B。%left 声明了左结合,且声明顺序决定优先级,靠后的 * 优先级更高。冲突发生时,Bison 按“优先级牌”裁决移进还是归约。这不是词法规则、错误恢复或类型定义。

Q7. 悬空 else(dangling else)问题属于哪一类冲突?

单选题 · hard

  • A.归约-归约冲突
  • B.移进-归约冲突← 正确答案
  • C.词法冲突
  • D.语义类型冲突

答案:B。看到 else 时,分析器既可以把已读完的 if...then... 归约,也可以继续移进 else 匹配内层 if,这正是移进-归约冲突。Bison 默认移进,实现“就近匹配”语义,符合 C/Java 等主流语言的行为。

Q8. 关于 %union 与语义值类型,说法正确的有?

多选题 · mid

  • A.%union 通过 C union 定义多种语义值类型← 正确答案
  • B.%token <num> NUMBER 表示 NUMBER 是终结符且值类型为 num← 正确答案
  • C.%union 通常配合 %type 或 %token 的类型标记一起使用← 正确答案
  • D.不使用 %union 时默认语义值类型是字符串

答案:ABC。%union 把 YYSTYPE 变成自定义联合体,是“多格餐盘”;<num> 标记告诉 Bison 每个符号取哪一格。默认情况下 YYSTYPE 是 int 而不是字符串,所以 D 错。多类型场景必须显式声明,否则取错格子会得到垃圾值。

Q9. 文法中的 error 终结符主要作用是什么?

单选题 · mid

  • A.表示文件结束
  • B.捕获语法错误并吞掉坏 token,重新同步恢复← 正确答案
  • C.表示空产生式
  • D.标记注释内容

答案:B。error 像“倒掉糊菜继续炒”的救场动作:出错时 Bison 弹出栈内符号直到能移进 error,然后跳过一段输入直到找到同步点。它让解析器在一个输入里报告多个错误,而不是第一次出错就关店。

Q10. 关于归约-归约冲突,下列说法正确的有?

多选题 · extreme

  • A.同一状态下两个不同产生式对同一前瞻都准备归约时发生← 正确答案
  • B.%prec 可以解决归约-归约冲突
  • C.常由相同前缀且可归约状态重叠的文法引起← 正确答案
  • D.Bison 默认选择在文法文件中声明靠前的产生式← 正确答案

答案:ACD。归约-归约冲突是“两本菜谱同时抢着出锅”:同一状态下多个产生式都匹配当前句柄。它往往源于可选子串重叠或写法不规范,%prec 只裁决移进-归约冲突,对归约-归约无能为力,正确做法是重写文法消除歧义。Bison 的兜底策略是选靠前的产生式,但依赖兜底如同靠猜拳做菜,不可取。