语法制导翻译核心解析:SDD/SDT、属性与中间代码生成实战
发布时间:2026/9/18 23:03:59 作者:尧图编辑部 阅读量:1,286

说实话编译原理这门课很多人学到“语法制导翻译”这一章就开始掉队了。前面词法分析、语法分析好歹还有个直观的“识别字符串”的感觉到了语法制导翻译突然冒出来一堆“综合属性”“继承属性”“语义规则”“翻译方案”很多人直接懵在原地这到底是在分析句子还是在算什么东西答案是从这一章开始编译器的“前端分析”正式转向“后端生成”你不再只是判断“输入对不对”而是要回答“输入是什么意思、该生成什么中间代码”。我最近在刷哈工大陈鄞老师的MOOC《编译原理》配套习题正好做到第8到10讲“语法制导翻译”这个合集。这套题包含答案题目质量很高覆盖了从基础概念到中间代码生成的完整链路。今天就把我做题过程中的思考过程、踩坑教训、以及每类题目的解题套路整理出来希望能帮到正在啃这一章的同学们。1. 语法制导翻译的核心逻辑从“句子合法”到“句子有含义”语法制导翻译这个名词听起来很唬人拆开看就两层意思“语法制导”就是整个翻译过程由文法产生式来驱动“翻译”就是把输入的源语言结构转换成另一种表达形式最常见的就是中间代码、后缀式、或者直接计算出某种值。也就是说语法分析器原本只负责回答“这个句子合不合法”而语法制导翻译在此基础上额外回答“这个句子该怎么处理”。1.1 语法制导定义SDD和翻译方案SDT怎么区分这是最容易混的一对概念。做习题时我一开始也老搞反直到用一句话记住了它们语法制导定义SDD文法产生式 属性 语义规则。它是“声明式”的只说明每个产生式对应的属性之间怎么计算不规定具体什么时候执行。你可以把SDD理解成一张“计算说明书”。语法制导翻译方案SDT在产生式右部嵌入“语义动作”的程序片段用花括号括起来。它是“命令式”的明确告诉你某个动作在什么时候、什么位置执行。SDT是SDD的实现版本。有一个很经典的例子把中缀表达式翻译成后缀表达式。如果写成SDD你需要为每个产生式定义属性t表示子树翻译结果然后用拼接的方式写出规则比如E - E1 TE.t E1.t || T.t || 。如果写成SDT就直接在产生式里嵌动作E - E1 T { print() }。从考试角度看SDT更常考因为题目通常要求“写出带语义动作的翻译方案”你得知道动作插在哪个位置。1.2 综合属性与继承属性分清方向就能做对一半题属性分两类判断标准是“信息的流向”综合属性信息从子节点流向父节点。比如E - E1 TE.val E1.val T.val这就是典型的综合属性因为E的值依赖它的子节点。自底向上分析时归约到E时就能算出E.val。继承属性信息从父节点流向子节点或兄弟节点。比如声明语句D - T L需要把类型T.type传递给L让L知道它声明的每个变量是什么类型这就是继承属性。我在习题里见到最典型的综合属性考察是给出一棵带注释的分析树让你根据已知节点值推断根节点的值。这类题只要记住“综合属性向上传”基本不会错。继承属性的经典考法是判断一个SDD是否是L属性定义。L属性的核心要求是每个产生式A - X1 X2 ... XnXi的继承属性只能依赖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 LL.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 | TT - T * F | FF - (E) | id写出计算表达式值的语法制导定义并计算2 3 * 4的值。推演过程第一个核心决定是属性设计。题目要求算值每个文法符号只需要一个val综合属性。不需要继承属性所以这个SDD是S属性定义。第二步是给每个产生式写语义规则。这部分需要对每个可能性都覆盖到E - E1 TE.val E1.val T.valT - T1 * FT.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 | EE - digit | E digit其中sign表示正负号要求设计SDD计算带符号数的绝对值即最终得到数的数值。推演过程这道题的技巧点在于sign的符号信息需要“传给”E才能让最终结果带符号而E在右下侧信息是自左向右传的。用综合属性做不到这一点因为S - sign E里S.val要依赖于E的值而E的值又依赖于sign的信息——这是典型的继承属性场景。设计思路给E增加一个继承属性E.neg用来标记是否取负。S - sign E时E.neg trueS - E时E.neg false。然后E.val的定义就要考虑neg标记E - digitE.val E.neg ? -digits : digitsE - E1 digitE.val E1.val * 10 (E.neg ? -digit : digit)。等写完反思一下可以发现这个SDD不是S属性定义因为引入了继承属性neg。但它满足L属性定义的要求E.neg只依赖左边的S的固有属性而且每个继承属性都能在从左到右的遍历中被确定。这题特别适合用来训练“什么时候该用继承属性”的判断力因为考试中给你一个看似简单的场景如果你只会用综合属性往往会发现绕不过去。3.3 中缀表达式转后缀语义动作的位置就是输出时机题目为产生中缀表达式后缀式的文法写SDT。文法E - E T | TT - T * F | FF - (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 : EE - E1 E2 | E1 * E2 | - E1 | (E1) | id要求设计翻译方案生成三地址码。推演过程三地址码的核心是把复杂的表达式拆成一堆“三地址指令”每条指令最多包含一个运算符和三个地址。采用类似“寄存器分配”的思路每个非终结符E维护一个E.place属性代表存放其计算结果的临时变量名或地址。关于如何生成代码要分产生式来设计E - idE.place id.name不生成指令直接引用了变量的名字。E - E1 E2先生成E1的代码求左操作数再生成E2的代码求右操作数然后E.place newtemp()emit(E.place : E1.place E2.place)。E - - E1E.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判断字符串要高级得多。编译原理这门课最核心的思维训练恰恰就是“先定义结构再基于结构做处理”的工程哲学。最后再分享一个小技巧刷这类题时尽量把所有中间步骤都写在草稿纸上哪怕答案看起来再显然。因为语法制导翻译题的错误往往发生在“你以为你已经会了”的最基础环节上而把过程写下来能帮你清晰地看到属性在每一步是怎么流动的。做题和考试如此真实工程里调试代码也是如此。希望这篇经验整理能帮你在语法制导翻译上少走一些弯路。