news 2026/9/18 23:03:54

语法制导翻译核心解析:SDD/SDT、属性与中间代码生成实战

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
语法制导翻译核心解析:SDD/SDT、属性与中间代码生成实战

说实话,编译原理这门课,很多人学到“语法制导翻译”这一章就开始掉队了。前面词法分析、语法分析好歹还有个直观的“识别字符串”的感觉,到了语法制导翻译,突然冒出来一堆“综合属性”“继承属性”“语义规则”“翻译方案”,很多人直接懵在原地:这到底是在分析句子,还是在算什么东西?答案是:从这一章开始,编译器的“前端分析”正式转向“后端生成”,你不再只是判断“输入对不对”,而是要回答“输入是什么意思、该生成什么中间代码”。

我最近在刷哈工大陈鄞老师的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 ... 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 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.val
    • T -> T1 * FT.val = T1.val * F.val
    • F -> (E)F.val = E.val
    • 对于F -> id和数字,F.val就是数字本身,题目通常会说明id的值或直接给出数字。
  • 第三步,按照2 + 3 * 4的推导,自底向上计算。最关键的一点是3 * 4先被归约成T,得到T.val = 12;然后212相加得到14。这个先乘后加的顺序不是走出来的,而是文法本身就体现了优先级,也就是说文法结构已经把优先级编码进去了,这就是语法制导翻译优于简单手工拼接的地方。

注意:写这类答案时,语义规则必须逐条写,不能只写“然后算出答案”。语义规则本身就是分,漏一条规则等于在该产生式上没做任何翻译。

3.2 带符号数的翻译:继承属性也有用武之地

题目:给定文法S -> sign E | EE -> digit | E digit,其中sign表示正负号,要求设计SDD,计算带符号数的绝对值(即最终得到数的数值)。

推演过程

  • 这道题的技巧点在于,sign的符号信息需要“传给”E才能让最终结果带符号,而E在右下侧,信息是自左向右传的。用综合属性做不到这一点,因为S -> sign ES.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判断字符串要高级得多。编译原理这门课最核心的思维训练,恰恰就是“先定义结构,再基于结构做处理”的工程哲学。

最后再分享一个小技巧:刷这类题时,尽量把所有中间步骤都写在草稿纸上,哪怕答案看起来再显然。因为语法制导翻译题的错误往往发生在“你以为你已经会了”的最基础环节上,而把过程写下来,能帮你清晰地看到属性在每一步是怎么流动的。做题和考试如此,真实工程里调试代码也是如此。希望这篇经验整理能帮你在语法制导翻译上少走一些弯路。

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/9/18 23:02:31

从IaaS到容器集群:DCE如何用容器化重构企业交付与运维

简介:这份PPT资源围绕DaoCloud Enterprise(DCE)容器云平台展开,适合正在规划企业容器化转型、了解云原生与微服务架构的架构师、运维及技术决策者。内容从传统IT架构在快速迭代、横向扩展和可靠性方面遇到的挑战切入,系…

作者头像 李华
网站建设 2026/9/18 23:01:21

新生研讨课高效指南:信息检索、协作与自动化PPT制作

简介:这是一份面向大一新生和高校教师的“新生研讨课”总结精选文档,内容围绕研讨课的内容、参与过程与心得体会展开,旨在帮助新生快速建立对大学专业学习、研究方法和团队协作的初步认识。资源包内共 1 个 DOC 文档,大小约 25KB&…

作者头像 李华