做PTA上编译原理练习5的时候,很多同学会陷进一种怪圈:词法分析练得还不错,语法分析也勉强能画出推导树,一到“语义分析”这个主题,选择和判断题的正确率就直线下降。我一开始也这样,后来发现,语义分析考的不是“背概念”,而是“辨析概念”。这篇文章我想把自己刷sdut这套练习5的完整思路整理出来,包括命题范围、高频考点、易错题型和复习方法,适合正在刷PTA练习、准备期末考或者复试复习编译原理的同学参考。
1. 语义分析到底在“分析”什么——先看练习5在考什么
1.1 编译器里的“语义”和日常理解的“意思”不是一回事
很多同学对语义分析的第一反应是“读代码,理解代码什么意思”,这个理解放在自然语言里没问题,放在编译器里就跑偏了。编译器里的语义分析,指的是语法分析通过之后,编译程序对源程序做一系列静态语义检查,它不执行程序,也不预测程序运行时的行为,只检查程序在“含义层面”是否合法。
举一个最经典的例子:a = "abc" + 1。这句话从词法角度没问题,变量名合法、字符串和整数都是合法单词;从语法角度,赋值语句、加法表达式也都能套进文法规则。但它能通过编译吗?不能。因为字符串类型和整数类型做加法,类型不相容,这就是语义分析要管的事。PTA练习5里很多题就围绕着这种“语法上挑不出毛病、语义上一眼假”的例子出。
我刷题时发现,语义分析题目最大的迷惑点在于:命题人喜欢把编译期错误和运行期错误混在选项里。比如数组越界、除数为零、空指针访问,这些不是语义分析阶段能静态判定的问题,但很多同学会误选成“语义错误”。反过来,break 出现在循环外、使用未声明的变量、函数调用参数个数不匹配,这些才是语义分析阶段要报的错。弄不清这条界线,判断题基本靠蒙。
1.2 练习5的命题范围:五个高频知识块
把练习5里反复出现的知识点归类后,基本可以框定在五个知识块内:
| 知识块 | 常见题型 | 典型考察点 |
|---|---|---|
| 属性文法与语法制导翻译 | 选择、填空、简答 | 综合属性、继承属性、S-属性定义、L-属性定义 |
| 中间代码生成 | 选择、填空、手算题 | 逆波兰式、三元式、四元式、抽象语法树 |
| 符号表管理 | 选择、判断 | 符号表字段、作用域、重复声明 |
| 类型检查 | 选择、判断 | 类型等价、类型相容、静态类型检查规则 |
| 编译阶段划分 | 选择、判断 | 错误分类、阶段边界、前后端关系 |
这里我想多说一句:这五个知识块不是孤立存在的。属性文法是描述语义的工具,语法制导翻译是执行语义动作的过程,中间代码则是语义分析阶段输出或半输出的结果,符号表和类型检查是语义分析真正要做的工作。把它们串起来理解,做题时就不容易碎。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 高频考点拆解:属性文法、中间代码和符号表
2.1 属性文法:综合属性、继承属性别再混了
属性文法是在上下文无关文法的基础上,为每个文法符号关联若干属性,并给出语义规则。PTA里最常考的就是判断某个属性是综合属性还是继承属性。
综合属性的计算方向是自底向上:子节点的属性先算出来,然后得出父节点的属性。最经典的是算术表达式求值:
code复制E -> E1 + T
E.val = E1.val + T.val
E.val 是由子节点 E1.val 和 T.val 计算得到的,所以它是综合属性。想象成“孩子给父母报成绩”,必须先知道孩子的分,才能算出父母的综合评分。
继承属性的计算方向是自顶向下或从左到右:父节点或左边兄弟节点的属性传入当前节点。比如声明语句:
code复制D -> T id
id.type = T.type
id.type 是从左兄弟 T.type 那里继承来的,所以是继承属性。想象成“父母给孩子布置任务”,先有父母的安排,孩子才知道该怎么执行。
在此基础上,S-属性定义指只含综合属性的属性文法,可以在自底向上分析过程中一边归约一边计算。L-属性定义的继承属性只允许来自父节点或左边兄弟节点,适合在自顶向下或自底向上过程中用合适方式处理。记忆方法很简单:S是“Self-contained”,L是“Left-to-right”。PTA判断“一个属性文法是不是S-属性定义”,只需要看有没有继承属性出现,有就不是。
2.2 语法制导翻译:语义动作安排在哪个位置
语法制导翻译是在语法分析的同时执行语义动作。动作可以放在产生式末尾,也可以放在产生式中间。PTA里关于这部分的题常常给一个翻译方案,问你它的功能,或者问你某个文法的语义动作写在哪一步执行。
举个例子,把中缀表达式翻译成后缀形式,可以设计这样的语法制导定义:
| 产生式 | 语义动作 |
|---|---|
| E -> E1 + T | print('+') |
| E -> E1 - T | print('-') |
| E -> T | 无 |
| T -> T1 * F | print('*') |
| T -> F | 无 |
| F -> id | print(id.name) |
这个翻译方案本质上就是逆波兰式的生成过程:从左到右扫描,遇到操作数输出,遇到运算符等操作数输出完再输出。这里的语义动作都放在产生式末尾,在自底向上分析时,当某个产生式被归约出来后就执行动作。如果动作放在产生式中间,情况会复杂一些,PTA里会考察“什么时候执行这个动作”,答案通常要结合分析栈的状态来理解。
简答题里如果让你“简述逆波兰式”,不要只写“运算符跟在操作数后面”这一句。要把三点说全:一是定义,正是表达式的一种后缀表示,运算对象顺序不变,运算符按优先级和结合性出现在对应操作数后面;二是转换规则,括号显式改变优先级,内层括号最先处理;三是用途,方便栈式机器求值,也是很多编译器中间代码的雏形。能说清这三点,分就到手了。
2.3 中间代码的几种形态:不只是逆波兰式
语义分析之后通常要生成中间代码,练习5里常见的中间代码形式有四种:逆波兰式、三元式、四元式、抽象语法树。它们之间的关系可以用一张表说清楚:
| 形式 | 结构特征 | 典型用途 | PTA考点 |
|---|---|---|---|
| 逆波兰式 | 运算符在后,无需括号 | 栈式求值、简单编译器输出 | 中缀转后缀 |
| 三元式 | (op, arg1, arg2) | 三地址代码的变体 | 编号引用、结果表示 |
| 四元式 | (op, arg1, arg2, result) | 最接近目标代码的中间表示 | 临时变量生成顺序 |
| 抽象语法树 | 压缩语法树,保留优先级 | 很多编译器的前端输出 | 与语法树的区别 |
以 a*(b+c)-d 为例,它的逆波兰式是 abc+*d-。转换规则不复杂:操作数顺序保持原样,运算符按优先级入栈,遇到右括号弹栈直到左括号。 a*b+c 和 a*(b+c) 这两个表达式我建议你亲自动手各转一遍,前者是 ab*c+,后者是 abc+*,就一个括号之差,PTA里每次出现都有一批人选反。
三元式和四元式的区别在 result 字段:三元式用三元式编号隐式表示结果,四元式显式给出一个临时变量作为结果。四元式后面代码优化阶段调整顺序更方便,所以实际编译器更常用。抽象语法树和语法树的区别也要注意,语法树保留每个非终结符节点,抽象语法树只保留运算需要的节点,比如 a+b*c 的抽象语法树根是 +,右子树根是 *,不会出现那个多余的 T 或 E 节点。
3. 那些让人反复踩坑的题:逆波兰、四元式、作用域和类型检查
3.1 逆波兰式转换的优先级陷阱
练习5里逆波兰式最经典的坑,是把 a + b * c 转成 ab+c*。为什么错?因为加号优先级低于乘号,必须先算乘法再算加法,所以后缀结果应该是 abc*+。人脑在面对这种式子时往往会“先看到哪个运算符就先写哪个”,这是惯性,得改。
我自己的做法是,遇到这类题先不急着写结果,按下面四步走:
- 把表达式按优先级补全成完全括号化形式,比如
a + b * c写成a + (b * c),(a+b)*c保持原样。 - 从最内层括号开始,把括号内的子表达式转成后缀。
- 操作数按原顺序排列,运算符则按“右括号出现时弹出”的方式处理。
- 检查有没有单目运算符,比如负号。
单目运算符是另一个大坑。大多数教材会把单目负号单独记成一个符号,比如 -a + b * c 转换成 a@bc*+,其中的 @ 表示单目取负。但有些题目里并不约定 @,而是直接写成 a-bc*+,这种写法其实有歧义,因为你没法区分它是“ a 减 bc*+ 的结果”还是“ -a 加 bc*+ 的结果”。做题前一定先看题干有没有给出运算符约定,没给的话,优先按教材默认方式处理。
3.2 四元式生成时,临时变量编号怎么数
四元式是练习5里手算题的重头戏。格式是 (op, arg1, arg2, result),其中 result 通常是临时变量 T1、T2……。临时变量的编号规则最容易错,很多同学不是不会生成四元式,而是数着数着编号就乱了。
拿 a = b * c + d 举例,正确四元式序列是:
code复制(*, b, c, T1)
(+, T1, d, T2)
(=, T2, _, a)
注意几个细节:第一,第一个四元式算 b*c,结果存入 T1;第二个四元式要把 T1 当作左操作数,而不是把 b*c 再写一遍。第二,赋值语句对应的四元式 arg2 位置一般留空,有的教材写 _,有的写空,这不是错误选项的依据。第三,临时变量编号从 T1 开始,每生成一个新的中间结果就加 1,不是每个运算符都必须有独立编号,也不是所有四元式都产生临时变量。
练习5里常迷惑人的错误选项,会把第二个四元式写成 (+, b*c, d, T2),看起来好像更直观,但这不是合法的四元式形式,因为 arg1 和 arg2 必须是变量、常量或临时变量,不能是表达式。数组和逻辑表达式也有类似规则,但不同教材约定差异大,建议优先按课堂用的教材来写。
3.3 符号表:作用域遮蔽和重复声明
符号表记录的是标识符的各种信息,PTA里围绕它的判断题特别容易错,因为很多选项看起来很有道理,但经不起推敲。比如“符号表只保存变量名”,这明显不对,符号表通常还要保存类型、作用域、存储位置、长度等信息;再比如“同一个作用域内允许重复定义同名变量”,这也不对,大多数语言里同一作用域重复定义同名标识符属于语义错误。
作用域遮蔽是选择爱考的点。给一段很短的代码:
code复制int x;
void f() {
int x;
x = 1;
}
内层声明 int x 出现在外层声明之后,从声明处开始,内层的 x 遮蔽外层的 x,所以 x = 1 赋值的是内层变量。这类题我建议不要凭感觉,直接画出作用域嵌套结构,标出每个名字从哪个声明开始生效,一眼就能判断。
需要注意一个细节:作用域何时开始,不同教材有细微差别。有的规定从声明语句结束后开始,有的规定从声明处立即开始,PTA的判断题如果在这个点上设坑,答案会依赖教材约定。我刷题时遇到过几次,后来统一按“声明处开始生效、遇到作用域结束失效”来理解,再结合题目选项里的限定词判断。
3.4 类型检查的边界情况
类型检查是语义分析的核心,练习5关于它的题集中在三类边界情况。
第一类是数组下标。数组下标必须是整数类型,a[1.5] 这种写法在语义分析阶段就应该报错,有的同学会觉得“编译时不知道下标是不是合法”,这是混淆了类型检查和越界检查。下标类型不对是静态可判定的,下标越界则不一定能在编译期判定,严格来说多数情况下不是语义分析阶段的主要职责。
第二类是函数调用。参数个数不匹配、参数类型与形参类型不相容,都要在语义分析阶段报告。比如定义一个 int f(int x),调用 f("abc"),这就是类型错误,不是语法错误。
第三类是赋值相容。把一个字符串赋给整型变量,把一个数组名赋给一个整数,这些都属于类型不相容。这里还要区分类型等价和类型相容。类型等价是两个类型完全相同;类型相容是赋值运算中允许的类型匹配,通常还带有隐式转换规则。比如整数和实数在某些语言里做运算时会自动把整数转换成实数,这属于相容但不等价。PTA里如果题干明确说了“按C语言规则”,那隐式转换就要考虑进去;如果没说,要优先判断“能不能通过语义分析”这一层,而不是去猜运行时行为。
4. 从错题反推知识点:一套能快速提分的复习路径
4.1 把PTA题当“找错题”做
练习5的选择题和判断题,最大的价值不在选对,而在让你把每个选项都过一遍脑子。我建议你做每道选择题时,先不看答案,把每个选项单独拎出来问一句:这个选项如果是对的,依据是什么?如果是错的,错在哪里?判断不出来就说明这个知识点没有掌握,正好标记下来。
判断题更要狠一点,把题干里的每个限定词圈出来:“一定”“总是”“不需要”“必须”这类绝对化词汇,在很多错误选项里都会出现。比如“语义分析阶段一定不会生成中间代码”,这句话错在“一定”上,因为很多编译器的语义分析阶段往往会顺便生成中间代码。表面上考的是语义分析,实际考的是你对限定词有没有敏感度。
我第一次刷练习5时用这个方法,速度慢了不少,但正确率从七成提到九成以上。刷题不是比谁先把题做完,而是比谁在每道题上获得的信息多。
4.2 按知识点整理错题,而不是按题目顺序
错题本千万不要按“第1题、第2题”记录,要按知识点分块。我的错题本是按“属性文法”“中间代码”“符号表”“类型检查”“编译阶段”五个板块组织的,每块记录这么几项:
| 字段 | 作用 |
|---|---|
| 题目在考什么 | 一句话概括考点 |
| 我当时怎么想的 | 还原错误思路 |
| 正确思路是什么 | 写出规范的判断过程 |
| 同类题对比 | 把同考点的题放一起比较 |
比如我在逆波兰式上错过一次,就是因为没处理单目负号。那么我在“中间代码”这个板块下补充一条:遇到单目负号,先看题目有没有约定符号,没有就按教材默认的 @ 或“直接作为一元运算符处理”来写。之后再遇到同类题,扫一眼这行记录就能避开。
4.3 考前一张A4纸速记
复习到最后阶段,我把所有知识点压缩在一张A4纸上,正反两面。正面写转换规则和例子,反面写概念辨析。这张纸不追求全面,只追求“看到就能想起完整的知识网络”。
正面核心内容大概是这样的:
- 后缀表达式:操作数顺序不变,运算符按优先级弹栈;括号内先转完,一元运算符单独标记。
- 四元式:临时变量从T1开始编号,每出现一个新结果加1;赋值语句四元式形如
(=, T, _, 目标变量)。 - 三元式:
(op, arg1, arg2),结果用编号代替,无显式result字段。
反面核心内容:
- 综合属性:子节点传给父节点,自底向上;S-属性定义只含综合属性。
- 继承属性:父节点或左兄弟传入,自顶向下/从左到右;L-属性定义的继承属性管来源。
- 符号表字段:名字、类型、作用域、存储位置,不只是名字。
- 类型检查:赋值相容、数组下标整型、函数参数个数与类型匹配。
这张纸考前十分钟看一遍,效果比翻一整本练习册还有用。
5. 写在最后:关于练习5的三点实测心得
5.1 速度不是第一位的,读题才是
我最初限时刷练习5,正确率只有七成左右,错题大部分不是不会,而是读题太急。比如题目问“下列哪个不是语义分析阶段的工作”,我扫一眼看到“生成中间代码”就选了,其实很多编译器在语义分析阶段确实会顺带生成中间代码,这个选项反而是对的。改成每道题读两遍再动笔之后,正确率明显回升。PTA允许反复提交,但考试不允许,平时养成“读题圈考点”的习惯,到考场上才不会慌。
5.2 用“模拟编译”代替死记硬背
只做题不亲手推一遍,到考试时容易手生。我刷练习5期间,每天拿一个表达式做“模拟编译”:先写出逆波兰式,再写出四元式序列,最后标出哪些节点是综合属性、哪些需要继承属性。比如 x = a + b * c - d / e 我至少写了三遍,每一遍都逼自己不看教材回答“临时变量T2到底是 a+b*c 的结果还是 b*c-a/d 的结果”。这个过程没有编译器在背后兜底,每步都要自己判断,印象非常深。
5.3 把练习5当成后续实验的“跳板”
语义分析学明白之后,中间代码生成的实验会顺手很多。很多课程的实验题就是要求输出表达式的四元式序列,或者实现一个简单计算器的逆波兰式求值,这些和练习5里的手算题几乎一一对应。练习5最妙的地方在于,它没有让你直接写一个语义分析器,却用选择、判断、填空把所有语义分析中的关键判断都过了好几遍。把这套题吃透,后面不管是写实验还是准备考试,都会轻松不少。
