说实话,编译原理这门课,很多人学到“语法制导翻译”这一章就开始掉队了。前面词法分析、语法分析好歹还有个直观的“识别字符串”的感觉,到了语法制导翻译,突然冒出来一堆“综合属性”“继承属性”“语义规则”“翻译方案”,很多人直接懵在原地:这到底是在分析句子,还是在算什么东西?答案是:从这一章开始,编译器的“前端分析”正式转向“后端生成”,你不再只是判断“输入对不对”,而是要回答“输入是什么意思、该生成什么中间代码”。
我最近在刷哈工大陈鄞老师的MOOC《编译原理》配套习题,正好做到第8到10讲“语法制导翻译”这个合集。这套题包含答案,题目质量很高,覆盖了从基础概念到中间代码生成的完整链路。今天就把我做题过程中的思考过程、踩坑教训、以及每类题目的解题套路整理出来,希望能帮到正在啃这一章的同学们。
1. 语法制导翻译的核心逻辑:从“句子合法”到“句子有含义”
语法制导翻译这个名词听起来很唬人,拆开看就两层意思:“语法制导”,就是整个翻译过程由文法产生式来驱动;“翻译”,就是把输入的源语言结构,转换成另一种表达形式,最常见的就是中间代码、后缀式、或者直接计算出某种值。也就是说,语法分析器原本只负责回答“这个句子合不合法”,而语法制导翻译在此基础上额外回答“这个句子该怎么处理”。
1.1 语法制导定义(SDD)和翻译方案(SDT)怎么区分
这是最容易混的一对概念。做习题时我一开始也老搞反,直到用一句话记住了它们:
- 语法制导定义(SDD):文法产生式 + 属性 + 语义规则。它是“声明式”的,只说明每个产生式对应的属性之间怎么计算,不规定具体什么时候执行。你可以把SDD理解成一张“计算说明书”。
- 语法制导翻译方案(SDT):在产生式右部嵌入“语义动作”的程序片段,用花括号括起来。它是“命令式”的,明确告诉你某个动作在什么时候、什么位置执行。SDT是SDD的实现版本。
有一个很经典的例子:把中缀表达式翻译成后缀表达式。
- 如果写成SDD,你需要为每个产生式定义属性
t表示子树翻译结果,然后用拼接的方式写出规则,比如E -> E1 + T,E.t = E1.t || T.t || '+'。 - 如果写成SDT,就直接在产生式里嵌动作:
E -> E1 + T { print('+') }。从考试角度看,SDT更常考,因为题目通常要求“写出带语义动作的翻译方案”,你得知道动作插在哪个位置。
1.2 综合属性与继承属性:分清方向就能做对一半题
属性分两类,判断标准是“信息的流向”:
- 综合属性:信息从子节点流向父节点。比如
E -> E1 + T,E.val = E1.val + T.val,这就是典型的综合属性,因为E的值依赖它的子节点。自底向上分析时,归约到E时就能算出E.val。 - 继承属性:信息从父节点流向子节点或兄弟节点。比如声明语句
D -> T L,需要把类型T.type传递给L,让L知道它声明的每个变量是什么类型,这就是继承属性。
我在习题里见到最典型的综合属性考察,是给出一棵带注释的分析树,让你根据已知节点值推断根节点的值。这类题只要记住“综合属性向上传”,基本不会错。
继承属性的经典考法,是判断一个SDD是否是L属性定义。L属性的核心要求是:每个产生式A -> X1 X2 ... Xn,Xi的继承属性只能依赖A的继承属性或Xi左边符号的属性。这句话有点绕,我用人话翻译:继承属性在深度优先遍历中只能“从左向右传”,不能回头引用右边的信息。
1.3 为什么S属性定义能在自底向上分析中直接实现
陈鄞老师的课里把S属性定义和L属性定义讲得很清楚:S属性定义是只用综合属性的SDD,它可以在自底向上分析过程中,随着归约动作同步计算属性值。
这背后最妙的地方在于,自底向上分析时,每次归约都会把右部符号“折叠”成左部非终结符,而这种折叠天然就符合综合属性的计算方向:子节点先分析完,信息先有,父节点后归约,能拿到所有子节点的信息。因此你只需要在栈里为每个符号额外存一份属性值,归约时取出右部所有符号的属性,套用规则算出新符号的属性,就可以做到一遍扫描完成翻译,不用额外构造分析树。
这部分对应习题合集里“匹配题/选择判断”的常客。比如题目问你“以下哪个SDD不是S属性定义”或者“在自底向上分析中,以下哪种属性最容易实现”,答案基本都是围绕综合属性。
2. 习题中最常见的四类题型与解题模板
我刷完第8-10讲的习题合集后,把语法制导翻译的题归纳成四类:概念辨析题、构造SDD/SDT题、注释分析树求值题、中间代码生成题。每一类都有相对固定的套路。
2.1 概念辨析题:别死记硬背,要找对比例子
这类题数量不多,但经常考得刁钻。比如题目给几个描述,让你判断“哪个说法关于SDD和SDT是错误的”。典型的错误选项包括:“SDT中语义动作只能出现在产生式末尾”——这明显不对,语义动作可以出现在右部任意位置,放在不同位置表示不同执行时机;再比如“一个SDD的所有属性都必须是综合属性”也错,S属性只是特例,完整SDD允许继承属性存在。
我的建议是不要孤立背概念,而是把每对儿概念都对比着记:SDD对SDT、综合属性对继承属性、S属性定义对L属性定义。做题时万一拿不准,就回忆一道经典例子,比如表达式求值的SDD是S属性定义,声明语句传类型的SDD是L属性定义但非S属性。拿例子去套题干的描述,比硬记定义靠得住。
2.2 构造SDD/SDT题:三步法解决80%的问题
这种题是重头戏,题目通常给你一个文法或一种语言,要求你写出语法制导定义或翻译方案。遇到这种题不要直接上手硬写,我总结了一个“三步法”,按部就班走基本不会漏:
第一步,确定属性集合:分析题目要求你算什么或翻译成什么。如果要求计算表达式的值,需要val属性;如果要求翻译成后缀式,可能需要t属性或直接用语义动作打印;如果涉及类型检查,需要type属性。
第二步,确定属性类型:看信息流向,自下而上用综合属性,自上而下或左右传递用继承属性。拿不准的时候优先考虑综合属性,因为自底向上实现容易;只有当信息确实需要从父节点或左侧传给右侧子节点时,才引入继承属性。
第三步,逐条产生式写规则:一个产生式对应一条语义规则或一组语义动作,一个属性没写或少写都可能丢分。写完再自查一遍:每个属性的计算是否都有规则覆盖,终止符的固有属性是否标注。
这个方法论放在C语言风格声明翻译的题目上会特别实用。比如题目要求将int a, b, c这样的声明翻译成“依次把每个变量填入符号表并标记类型为int”。写法大致是:D -> T L,L.in = T.type,然后在L -> L1, id中把id的名字和L1.in对应的类型填入符号表。
如果题目要求你为L -> id, L1写语义动作,很多人容易犯一个错误,把类型继承属性写成了综合属性,结果符号表填表时拿不到类型信息。
2.3 注释分析树求值题:画图往上推,别跳步
这类题会给你一个表达式的分析树,要求给每个结点标注属性值。我一开始做题时喜欢“心算”然后直接填答案,结果经常出错,因为中间任何一步错了,后面全跟着错。
正确做法是老老实实画出分析树,然后从叶子结点开始,自底向上逐层计算综合属性。如果是带继承属性的题,还要先做一遍“自顶向下”的继承属性传递,再做“自底向上”的综合属性计算。注意顺序不能乱:继承属性没算出来,后面综合属性可能根本没法算。
这让我想起一道印象深刻的题:文法给的是D -> T { L.in = T.type } L,然后L -> L1, id { addType(id.entry, L.in); L1.in = L.in },最后把类型填进符号表。这类题就算前面所有步骤都对,最后忘了给L1.in = L.in这条规则,也会导致没法把类型传给更右侧的变量。
2.4 中间代码生成题:核心是搞清“出口”和“回填”
习题第10讲很大篇幅在讲中间代码生成,因为这是语法制导翻译的终极应用。中间代码最常见的形式是三地址码,而题目最喜欢考察布尔表达式和控制流语句的翻译。布尔表达式需要真出口、假出口;控制流语句的跳转目标经常还不知道,需要回填技术。这就是陈鄞老师课上反复强调的“拉链-回填”方法。
我当时做题时的最大障碍,是搞不清nextquad是怎么变化的。说白了,nextquad就是“下一条将要生成的指令编号”,每次emit生成一条三地址指令,nextquad就加1。很多题目的易错点在于:你的代码里如果先递归翻译子表达式,再生成跳转指令,那子表达式生成的指令就会占用前面的编号,跳转目标的编号必须对应上。
我在习题中写过一道非常有代表性的题:把if x > 0 then y = x else y = -x翻译成三地址码,其中用回填来处理else分支。拿到题先不要着急写指令,而是先在草稿纸上把控制流结构画出来,标出每个分支的“入口”和“出口”位置,再往里面填代码,这样跳转编号基本不会乱。
3. 重点习题精讲:从题目到答案的完整推演
下面我挑几道有代表性的习题,把完整推演过程写出来。这些题类型不一样,但都很有技巧含量,是理解语法制导翻译的好素材。
3.1 经典表达式求值:S属性定义的完整实现
题目:给定文法E -> E + T | T,T -> T * F | F,F -> (E) | id,写出计算表达式值的语法制导定义,并计算2 + 3 * 4的值。
推演过程:
- 第一个核心决定是属性设计。题目要求算值,每个文法符号只需要一个
val综合属性。不需要继承属性,所以这个SDD是S属性定义。 - 第二步是给每个产生式写语义规则。这部分需要对每个可能性都覆盖到:
E -> E1 + T,E.val = E1.val + T.valT -> T1 * F,T.val = T1.val * F.valF -> (E),F.val = E.val- 对于
F -> id和数字,F.val就是数字本身,题目通常会说明id的值或直接给出数字。
- 第三步,按照
2 + 3 * 4的推导,自底向上计算。最关键的一点是3 * 4先被归约成T,得到T.val = 12;然后2和12相加得到14。这个先乘后加的顺序不是走出来的,而是文法本身就体现了优先级,也就是说文法结构已经把优先级编码进去了,这就是语法制导翻译优于简单手工拼接的地方。
注意:写这类答案时,语义规则必须逐条写,不能只写“然后算出答案”。语义规则本身就是分,漏一条规则等于在该产生式上没做任何翻译。
3.2 带符号数的翻译:继承属性也有用武之地
题目:给定文法S -> sign E | E,E -> digit | E digit,其中sign表示正负号,要求设计SDD,计算带符号数的绝对值(即最终得到数的数值)。
推演过程:
- 这道题的技巧点在于,
sign的符号信息需要“传给”E才能让最终结果带符号,而E在右下侧,信息是自左向右传的。用综合属性做不到这一点,因为S -> sign E里S.val要依赖于E的值,而E的值又依赖于sign的信息——这是典型的继承属性场景。 - 设计思路:给
E增加一个继承属性E.neg,用来标记是否取负。S -> sign E时,E.neg = true;S -> E时,E.neg = false。 - 然后
E.val的定义就要考虑neg标记:E -> digit,E.val = E.neg ? -digits : digits;E -> E1 digit,E.val = E1.val * 10 + (E.neg ? -digit : digit)。 - 等写完反思一下,可以发现这个SDD不是S属性定义,因为引入了继承属性
neg。但它满足L属性定义的要求:E.neg只依赖左边的S的固有属性,而且每个继承属性都能在从左到右的遍历中被确定。
这题特别适合用来训练“什么时候该用继承属性”的判断力,因为考试中给你一个看似简单的场景,如果你只会用综合属性,往往会发现绕不过去。
3.3 中缀表达式转后缀:语义动作的位置就是输出时机
题目:为产生中缀表达式后缀式的文法写SDT。文法:E -> E + T | T,T -> T * F | F,F -> (E) | id。
推演过程:
- 最直观的方式是“在后缀式中,运算符出现在右部所有符号都处理完之后”。因此可以直接在产生式末尾添加输出运算符的语义动作:
E -> E1 + T { print('+') }T -> T1 * F { print('*') }F -> (E)不需要输出F -> id { print(id.lexeme) }
- 这个SDT能工作的原因在于:语义动作在右部末尾,也就是在归约发生时执行。自底向上分析中,当归约完成,右部的所有符号已经处理完,恰好运算符的左右操作数都输出过了,所以此时print运算符刚刚好。
- 顺便说一个易错点:如果把
print('+')放在E -> E + T的中间而不是末尾,也就是写成E -> E1 { print('+') } + T,那输出的后缀式就会变成E1的后缀式 + '+' + T的后缀式,显然不对。这题考察的就是“语义动作的位置决定执行时机”这一核心思想,做题时一定要考虑动作的位置。
3.4 生成三地址码的经典考题:指向赋值的表达式
题目:给出文法S -> id := E,E -> E1 + E2 | E1 * E2 | - E1 | (E1) | id,要求设计翻译方案,生成三地址码。
推演过程:
- 三地址码的核心是把复杂的表达式拆成一堆“三地址指令”,每条指令最多包含一个运算符和三个地址。
- 采用类似“寄存器分配”的思路,每个非终结符
E维护一个E.place属性,代表存放其计算结果的临时变量名或地址。 - 关于如何生成代码,要分产生式来设计:
E -> id:E.place = id.name,不生成指令,直接引用了变量的名字。E -> E1 + E2:先生成E1的代码求左操作数,再生成E2的代码求右操作数,然后E.place = newtemp(),emit(E.place ':=' E1.place '+' E2.place)。E -> - E1:E.place = newtemp(),emit(E.place ':=' 'minus' E1.place)。S -> id := E:先生成E的代码,然后emit(id.place ':=' E.place)。
- 练习时你会发现,这个翻译方案如果直接写在答案里,会显得很长。但阅卷时最看重的是你有没有正确“拼接”子表达式的代码,以及有没有正确引入临时变量。少了一条
emit,答案就不完整。
4. 做题过程中最容易踩的五个坑
这些坑我在刷题时几乎都踩过一遍,有些题做错之后看答案才发现是自己对概念理解有偏差。我整理成清单,你们可以直接避开。
4.1 继承属性往上“传”:方向搞反
典型错误:写规则时把继承属性写在“父节点到子节点”的反方向,比如D -> T L里,把T.type写成由L计算得出传给T。这直接导致语义规则无法在自顶向下遍历中执行。
改正方法:每次写继承属性,先问自己一句:“这个信息是我在进入这棵子树之前就知道的,还是处理完子树之后才知道的?”如果是前者才能用继承属性。
4.2 语义动作的位置随意摆放
典型错误:为了省事,把语义动作全部放在产生式末尾。某些题可以这样,但像输出后缀式的题,动作放在末尾就错了。
改正方法:语义动作的位置反映动作的执行时机。动作如果在中间,说明它依赖左侧符号的信息,并且会影响右侧符号的处理。写位置前想清楚“我要在什么时候做这件事”。
4.3 S属性定义和L属性定义的概念混淆
典型错误:判断题里看到某个SDD既有综合属性又有继承属性,就认为它一定是L属性定义。这个结论是错的。
改正方法:L属性定义要求所有继承属性的依赖关系满足“从左到右”的限制,但并不是所有带继承属性的SDD都是L属性定义。比如某继承属性在产生式右部最右侧符号上,却依赖于右侧第3个符号的属性,这种就不满足L属性定义。
4.4 三地址码的临时变量重复使用
典型错误:在生成a := b + c * d的三地址码时,误把乘法结果存到了和加法同一个临时变量里,或者干脆直接用表达式“占位”,写出t1 := b + c * d这种复合表达式。这不符合三地址码的规范。
改正方法:三地址码里每条指令的右部只能有一个运算符。遇到多重运算,每算一步就新建一个临时变量。宁可多分配几个t1, t2, t3,也不要省变量导致指令不合法。
4.5 回填技术中忘记区分“真出口”和“假出口”
典型错误:在翻译if语句时,把真假分支的跳转目标都填成同一个标号,导致程序逻辑成一团乱。
改正方法:遇到布尔表达式或控制流语句,先画控制流图或标出每个跳转点需要回填的位置,再按顺序生成代码,最后统一回填列表。这也是陈鄞老师课上总说“拉链”的意义。
5. 陈鄞MOOC课程配套习题的使用建议
这套习题合集本身是哈工大陈鄞老师MOOC《编译原理》的配套训练资料,网上很多同学都在刷。我个人的体验是,它设计得和讲课内容贴合度高、难度梯度合理,题量上8-10讲这组“语法制导翻译”合集的覆盖面尤其完整。不过要发挥它的最大价值,你不能只“对答案”,关键是把每道题当成一次“小考试”来对待。
我的具体操作建议分三步,这里一并分享出来:
第一步,独立完成再对答案。先不看答案,把每道题自己推演一遍,哪怕不确定也要先把思路写下来。对答案后不要只画对错,要把做错的地方对应到具体的概念,比如“我错在继承属性方向没判断对”,然后在题号旁边写一行反思。
第二步,把题目的考察点“解码”回课程讲义。陈鄞老师的MOOC每一讲都有明确的知识目标,习题通常是围绕这些目标来出的。做完题之后,回到讲义里找到对应章节,把习题涉及的定义、算法、例子再重新看一遍,形成“题目→知识点→讲义→习题”的闭环。
第三步,过一段时间重新做。我间隔了两周重做同一套题,发现很多之前“背下来”的答案其实已经忘了,但重新推演的速度明显比第一次快。这说明底层的思维方式已经形成了。对于考研或者期末复习来说,这种“二次刷题”比做新题更有效,因为它能帮你确认是否真的内化了知识。
6. 从习题到实战:语法制导翻译的真正价值
很多人学编译原理会有个疑问:“我以后又不写编译器,学这个干嘛?”我在做语法制导翻译这套习题的时候,恰好对这个问题有了新的认识。实际上语法制导翻译的思想早已渗透到日常开发中。
比如写一个解析器、一个模板引擎、一个SQL查询构造器,或者实现一个简单的配置格式转换器,只要你在处理“结构化输入”,就会用到“由语法驱动的翻译”这套思路。你把AST遍历一遍,一边走一边输出目标代码或执行动作,本质上就是在写一个语法制导翻译器。
还有很多人会在面试时遇到手写计算器或AST解释器的题目。如果面试者懂语法制导翻译,写出来的答案会先用文法定义好表达式结构,再为每个产生式设计属性计算或求值动作,代码清晰、正确性一目了然,比直接写一堆if-else判断字符串要高级得多。编译原理这门课最核心的思维训练,恰恰就是“先定义结构,再基于结构做处理”的工程哲学。
最后再分享一个小技巧:刷这类题时,尽量把所有中间步骤都写在草稿纸上,哪怕答案看起来再显然。因为语法制导翻译题的错误往往发生在“你以为你已经会了”的最基础环节上,而把过程写下来,能帮你清晰地看到属性在每一步是怎么流动的。做题和考试如此,真实工程里调试代码也是如此。希望这篇经验整理能帮你在语法制导翻译上少走一些弯路。