☰
LL(1)语法分析器实战:从预测表构建到可运行解析器
2026/10/13 7:17:01 网站建设 项目流程

1. 项目概述:这不是教科书里的玩具,而是一把能切开真实语法结构的刀

“编译原理-LL(1)语法分析器”——这八个字一出来,很多人第一反应是:哦,又是那门挂科率常年前三的课设。但如果你真把它当成作业应付,最后调试到凌晨三点还在对着FIRST集和FOLLOW集发呆,那说明你还没摸到它的真正脉门。我带过几届某高校编译原理实验课,发现一个特别有意思的现象:那些最终能独立写出完整LL(1)分析器的学生,不是最懂理论的,而是最早动手画出预测分析表、最早用真实小语言(比如只支持加减乘除和括号的表达式语言)去喂它、最早在控制台里看到“匹配成功”四个字跳出来的那批人。LL(1)从来就不是用来背定义的,它是编译器前端最精悍的“语法哨兵”,负责在词法分析之后,用一张静态查表的方式,干净利落地判断输入符号串是否符合语法规则,并同步构建出语法树的雏形。它不处理语义,不优化代码,但它必须快、必须确定、必须零歧义——就像机场安检口的X光机,不负责判断你包里东西能不能带上飞机,但必须一眼看出有没有违禁品轮廓。这个项目适合两类人:一类是正在啃编译原理教材、被递归下降和LR(0)绕得头晕的本科生,另一类是想从零理解“代码怎么变成可执行文件”的开发者。它不依赖任何高级框架,核心逻辑甚至可以用纯C或Python几十行搞定,但每一步都直指编译本质。下面我会带你从一张空表格开始,亲手把它填满、跑通、调稳,而不是告诉你“LL(1)文法要满足无左递归、无公共前缀”这种正确但无用的废话。

2. 整体设计与思路拆解:为什么选LL(1)?因为它够小、够亮、够实在

2.1 为什么不是递归下降?也不是LR(0)?

刚接触语法分析时,很多人会纠结:递归下降写起来多直观,为啥还要学LL(1)?LR(0)看起来更强大,为啥不直接上?这个问题我试过三次:第一次用递归下降写一个带if-else嵌套和while循环的小语言,结果函数嵌套八层,调试时连自己写的return都数不清;第二次硬着头皮手推LR(0)项目集规范族,推到第7个状态就发现冲突太多,最后不得不回退改文法;第三次才真正静下心来,用LL(1)从头搭起一个能解析a+b*c-(d/e)这种表达式的分析器。结果发现,LL(1)的“限制”恰恰是它的优势。它强制你把文法改造成一种极度清晰的结构:每个产生式右部开头的终结符必须互不重叠,且不能有ε产生式搅局。这个过程本身就是在训练你对语言结构的“分治”思维——把一个复杂表达式,拆成“项”、“因子”、“原子”三级,每一级只管自己该吃掉什么,绝不越界。而递归下降虽然自由,但自由过了头就容易失控;LR(0)虽然强大,但强大背后是状态爆炸和冲突排查的噩梦。LL(1)像一把瑞士军刀里的主刀,不大,但够锋利、够可靠、够解决90%的日常语法识别问题。

2.2 核心架构:三块板子钉死一个闭环

一个真正能跑起来的LL(1)分析器,绝不是教科书里那个只有预测分析表的抽象模型。它必须由三个物理上可触摸、可调试、可打印日志的模块组成:

  1. 词法分析器(Lexer):这是它的“眼睛”。它不关心语法,只负责把源代码字符串(比如"3 + 4 * (5 - 1)")切成一个个带类型和值的记号(Token),如{type: NUMBER, value: "3"}、{type: PLUS, value: "+"}、{type: LPAREN, value: "("}。很多初学者卡在这里,以为可以跳过,直接拿字符串数组当输入。实测下来,一旦遇到123abc这种非法标识符,或者1.23e+4这种浮点数,没有Lexer的LL(1)分析器会当场崩溃。我建议用正则表达式预定义好所有Token模式,按最长匹配原则扫描,这样既简单又健壮。

  2. 预测分析表(Predictive Parsing Table):这是它的“大脑”。它是一个二维表,行是非终结符(如E,E',T,T',F),列是终结符(如+,-,*,/,(,),id,$)。表中每个格子填的是:当栈顶是非终结符X,当前输入符号是a时,应该用哪条产生式去展开X。这张表不是凭空画的,它必须严格基于FIRST集和FOLLOW集计算得出。我见过太多人直接抄网上的表,结果自己的文法稍有改动(比如加了个负号-),整个表就全错。所以后面我会手把手带你算,不是背公式,而是用“穷举+排除”的笨办法,确保你每填一个格子都心里有底。

  3. 驱动程序(Driver):这是它的“手脚”。它维护一个符号栈(初始为[E, $],$代表输入结束符),然后不断循环:如果栈顶是终结符,就和当前输入Token比对,匹配则弹出并读取下一个Token;如果栈顶是非终结符X,就查预测分析表,找到对应产生式X→α,把α逆序压入栈(因为栈是后进先出)。这个循环极其简单,但正是这种简单,让它异常稳定。我曾用它解析过上千行的微型配置语言,没出过一次栈溢出或死循环。

这三块板子,缺一不可。少了Lexer,它就是个瞎子;少了预测分析表,它就是个没地图的旅人;少了驱动程序,它就是一堆静态数据。它们组合在一起,才构成一个能呼吸、能反馈、能报错的真实分析器。

2.3 文法选择:从“能跑通”到“有延展性”的务实路径

选什么文法来练手,直接决定你三天还是三周能跑通。我强烈建议放弃教科书里那个经典的“算术表达式文法”(E → E + T | T),因为它有左递归,必须手动消除,新手第一步就卡死。我的方案是:从一个“极简但无毒”的文法起步,再逐步加料。第一步,用这个:

S → E $ E → T E' E' → + T E' | - T E' | ε T → F T' T' → * F T' | / F T' | ε F → ( E ) | id | num

注意看,这里E'和T'的ε产生式,是为了解决左结合性而设计的,不是为了炫技。它让1+2+3能被解析成((1+2)+3),而不是(1+(2+3))。这个文法没有二义性,没有左递归,FIRST和FOLLOW集计算起来非常清爽。等你用它跑通了1+2*3、(1+2)*3这些经典case,再考虑加if语句:Stmt → if ( Expr ) Stmt | if ( Expr ) Stmt else Stmt | other。这时候你会发现,else的悬空问题(dangling else)会立刻暴露出来——你的预测分析表里,Stmt行、else列会同时有两条产生式可选,这就违反了LL(1)条件。解决它?很简单,强制规定else总是和最近的if配对,然后在文法里用MatchedStmt和UnmatchedStmt两个非终结符来区分。这个过程,就是你从“会写”走向“懂设计”的分水岭。

3. 核心细节解析与实操要点:FIRST集、FOLLOW集、预测表,一个都不能少

3.1 FIRST集:终结符的“身份证”,非终结符的“可能性清单”

FIRST集的本质,就是回答一个问题:“这个符号(终结符或非终结符)能‘吐’出来的第一个东西,可能是什么?”终结符的FIRST集最简单:FIRST(a) = {a},比如FIRST(+)= {+},FIRST(id)= {id}。难点在非终结符。以F → ( E ) | id | num为例,F能吐出的第一个东西,要么是(,要么是id,要么是num,所以FIRST(F) = {(, id, num}。但如果是E' → + T E' | - T E' | ε呢?E'能吐出的第一个东西,要么是+,要么是-,要么是ε(空)。所以FIRST(E') = {+, -, ε}。关键来了:ε不是终结符,它代表“什么也不吐”。这意味着,当E'出现在某个产生式右部开头时,它有可能“隐身”,把机会让给后面的符号。这个特性,是计算FOLLOW集的基础。我教学生时,让他们用一张A4纸,左边写所有非终结符,右边留白,然后逐条扫描所有产生式:看到X → Y Z,就把FIRST(Y)抄到X的右边;如果Y能推出ε,就把FIRST(Z)也抄上;如果Z也能推出ε,那就继续……这个“抄作业”法,比背公式管用十倍。实测下来,一个中等复杂度的文法,15分钟就能手算完所有FIRST集,而且几乎不会错。

3.2 FOLLOW集:终结符的“上岗证”,非终结符的“上下文边界”

如果说FIRST集回答“能吐什么”,那么FOLLOW集就回答“在什么情况下会被叫到”。FOLLOW(X)是所有可能紧跟在X后面的终结符集合。比如在E → T E'中,E'后面什么也没有,所以$(输入结束符)一定在FOLLOW(E')里;又因为在E' → + T E'中,E'出现在右部末尾,所以FOLLOW(E)里的所有符号,也必须加入FOLLOW(E')。这个“传递”关系,是FOLLOW集的核心。我总结了一个三步走口诀:一找右邻,二找继承,三补结束。

  • “一找右邻”:遍历所有产生式A → α X β,把FIRST(β)中所有非ε元素,加到FOLLOW(X)里;
  • “二找继承”:如果β能推出ε(即ε ∈ FIRST(β)),就把FOLLOW(A)全部加到FOLLOW(X)里;
  • “三补结束”:对文法开始符号S,把$加到FOLLOW(S)里。

用这个口诀算FOLLOW(E):从S → E $看,E后面是$,所以$ ∈ FOLLOW(E);从E → T E'看,E后面是E',而E'能推出ε,所以FOLLOW(E) ⊆ FOLLOW(E');再从E' → + T E'看,E'后面是E'自己,又因为E'能推出ε,所以FOLLOW(E') ⊆ FOLLOW(E'),形成闭环。最终FOLLOW(E) = {$, ), +, -}。这个结果,直接决定了预测分析表里,E行、)列能不能填产生式——因为)在FOLLOW(E)里,而E又有ε产生式(没有,但E'有),所以E行、)列必须为空。这个判断,就是LL(1)文法合法性的最终裁决者。

3.3 预测分析表:把数学集合,翻译成计算机能执行的指令

预测分析表,就是FIRST集和FOLLOW集的“工程实现”。它的填充规则只有两条:

  1. 对于每个产生式A → α,对每个a ∈ FIRST(α),把A → α填入M[A, a];
  2. 如果ε ∈ FIRST(α),那么对每个b ∈ FOLLOW(A),把A → α填入M[A, b]。

还是以E' → + T E' | - T E' | ε为例。FIRST(+ T E') = {+},所以M[E', +] = E' → + T E';FIRST(- T E') = {-},所以M[E', -] = E' → - T E';FIRST(ε) = {ε},所以要把E' → ε填入所有b ∈ FOLLOW(E')的列。前面我们算出FOLLOW(E') = {$, ), +, -},所以M[E', $]、M[E', )]、M[E', +]、M[E', -]都要填E' → ε。等等,M[E', +]现在有两个产生式?冲突了!这就是LL(1)文法的“红灯”:同一个格子里不能填两个不同的产生式。解决方案只有一个:检查文法。你会发现,E' → + T E'和E' → ε之所以在+列冲突,是因为+ ∈ FIRST(+ T E'),同时也+ ∈ FOLLOW(E')。这意味着,当输入是+时,分析器无法判断:是该用+ T E'去消耗这个+,还是该用ε跳过E',让上层去处理这个+。这暴露了文法设计的缺陷——E'的ε产生式,不该和它自己的FIRST集重叠。修正方法?把E'的定义改成只处理+和-之后的部分,而把ε的职责交给更上层。这个调试过程,就是你和文法深度对话的过程,比任何理论讲解都深刻。

3.4 驱动程序:15行代码,撑起整个分析流程

驱动程序的伪代码,教科书上都有,但真正写成可运行的代码,有几个魔鬼细节必须处理:

stack = ['S', '$'] # 初始栈,S是开始符号 input_tokens = lexer.tokenize(source_code) # 词法分析结果 pos = 0 # 当前输入位置 while stack: top = stack.pop() if top == '$': if pos == len(input_tokens): print("分析成功") break else: raise SyntaxError(f"期待输入结束,但还有token: {input_tokens[pos]}") elif is_terminal(top): # top是终结符 if pos < len(input_tokens) and input_tokens[pos].type == top: pos += 1 # 匹配成功,消费token else: raise SyntaxError(f"期待{top},但得到{input_tokens[pos].type}") else: # top是非终结符 if pos >= len(input_tokens): a = '$' # 输入已尽,用$查表 else: a = input_tokens[pos].type production = parse_table.get((top, a)) if not production: raise SyntaxError(f"在{top}和{a}处无可用产生式") # 将产生式右部逆序压栈 for symbol in reversed(production.right): if symbol != 'ε': # ε不压栈 stack.append(symbol)

注意三个关键点:

  1. $的双重身份:它既是栈底哨兵,也是输入结束符。当栈顶是$时,必须严格检查输入是否真的结束了,否则就是语法错误。
  2. a的动态获取:a不是固定值,它取决于pos是否越界。越界了,a就是$;没越界,a就是当前token的类型。这个判断,是驱动程序健壮性的基石。
  3. ε的静默处理:ε产生式意味着“什么都不做”,所以右部为空,自然不压栈。很多初学者会试图压一个None或空字符串,结果导致栈永远清不完。

我实测过,这段逻辑用Python写,加上Lexer和Table构建,总共不到120行,就能解析任意长度的合法表达式。它的魅力在于:每一行代码,都对应着编译原理里的一个核心概念,没有一行是黑盒。

4. 实操过程与核心环节实现:从空文件夹到可执行的完整 walkthrough

4.1 环境准备与工具链:轻装上阵,拒绝臃肿

这个项目,我坚持“零外部依赖”原则。不需要安装ANTLR、Bison这些重型工具,也不需要配置复杂的IDE。一台装了Python 3.8+的电脑,一个文本编辑器(VS Code或Sublime Text即可),一个终端,足矣。原因很简单:LL(1)分析器的核心价值,在于让你亲手触摸每一个决策点。如果用ANTLR,你敲antlr4 -Dlanguage=Python3 Expr.g4,它瞬间给你生成几百行代码,但你根本不知道predict()函数里到底在查哪张表。所以,我们的工具链就是:

  • Python:作为宿主语言,语法简洁,列表和字典操作方便,适合快速原型;
  • 正则表达式(re模块):用于Lexer,几行代码就能搞定基础词法;
  • 纯文本文件:文法定义、测试用例,都用.txt保存,方便版本管理。

我创建了这样一个目录结构:

ll1-parser/ ├── grammar.txt # 文法定义,格式:S -> E $ | ... ├── test_cases/ # 测试用例目录 │ ├── valid/ # 合法输入 │ │ ├── expr1.txt # 内容:1+2*3 │ │ └── expr2.txt # 内容:(1+2)*3 │ └── invalid/ # 非法输入 │ └── expr3.txt # 内容:1++2 ├── parser.py # 主程序 ├── lexer.py # 词法分析器 └── table_builder.py # 预测分析表构建器

这个结构,强迫你把每个模块的职责切得清清楚楚。table_builder.py的任务,就是读grammar.txt,自动计算FIRST/FOLLOW集,生成parse_table字典。它不参与分析,只负责“造表”。这样,当你想换文法时,只需改grammar.txt,重新跑一遍table_builder.py,新表就自动生成。这种“数据驱动”的设计,是工程化思维的第一步。

4.2 Lexer实现:别小看这20行,它是整个系统的地基

很多人觉得Lexer trivial,随便split()一下就行。但真实世界里,123abc是非法标识符,1.23是浮点数,/* comment */要忽略,"hello"是字符串字面量。我们的目标是:能识别,能报错,能定位。以下是我用正则写的极简Lexer核心:

import re TOKEN_SPEC = [ ('NUMBER', r'\d+(\.\d*)?'), # 整数或浮点数 ('ID', r'[a-zA-Z_]\w*'), # 标识符 ('PLUS', r'\+'), ('MINUS', r'-'), ('TIMES', r'\*'), ('DIVIDE', r'/'), ('LPAREN', r'\('), ('RPAREN', r'\)'), ('ASSIGN', r'='), ('SEMI', r';'), ('NEWLINE', r'\n'), ('SKIP', r'[ \t]+'), # 空格和制表符,跳过 ('MISMATCH',r'.'), # 任何其他字符,报错 ] def tokenize(code): tokens = [] line_num = 1 for mo in re.finditer('|'.join('(?P<%s>%s)' % pair for pair in TOKEN_SPEC), code): kind = mo.lastgroup value = mo.group() if kind == 'NUMBER': value = float(value) if '.' in value else int(value) elif kind == 'ID' and value in ['if', 'else', 'while']: # 关键字 kind = value.upper() elif kind == 'NEWLINE': line_num += 1 continue elif kind == 'SKIP': continue elif kind == 'MISMATCH': raise SyntaxError(f'非法字符 {value!r} 在第 {line_num} 行') tokens.append(Token(kind, value, line_num)) return tokens

这个Lexer的精妙之处在于TOKEN_SPEC的顺序。正则引擎是贪婪匹配,且按列表顺序尝试。所以NUMBER必须放在ID前面,否则123abc会被ID规则先匹配成123abc,而123就漏掉了。SKIP和NEWLINE放在后面,确保空格和换行被安静地跳过。MISMATCH放在最后,作为兜底,捕获所有未定义的字符。我试过,用它解析x = 1 + 2.5 * (y - 3);,能准确切出12个Token,每个Token都带行号。当测试用例报错时,你能立刻定位到第几行哪个字符出了问题,而不是面对一长串SyntaxError干瞪眼。

4.3 表构建器:把枯燥的集合运算,变成可验证的代码逻辑

table_builder.py是整个项目的“智慧结晶”。它不追求性能,只追求可读、可调试、可验证。我把它拆成四个函数:

  1. compute_first(grammar):输入文法字典({'S': [['E', '$']], 'E': [['T', "E'"]], ...}),输出first_set字典。核心算法是迭代:初始化所有first_set[X]为空集,然后反复扫描所有产生式,只要first_set[Y]变了,就重新计算所有依赖Y的first_set[X],直到收敛。这个“不动点”思想,是形式语言理论的精髓。

  2. compute_follow(grammar, first_set):同理,用迭代法计算follow_set。关键是在A → α X β中,当β能推出ε时,要把follow_set[A]加到follow_set[X]里,这会导致follow_set[X]变大,进而影响所有依赖X的follow_set,所以必须迭代。

  3. build_parse_table(grammar, first_set, follow_set):这才是真正的“填表”函数。它遍历每条产生式A → α,计算first_of_alpha = FIRST(α),然后对每个a in first_of_alpha,如果a != ε,就table[(A, a)] = production;如果ε in first_of_alpha,就对每个b in follow_set[A],table[(A, b)] = production。

  4. print_table(table, nonterminals, terminals):把table字典,格式化成一个漂亮的Markdown表格,直接打印出来。这个函数的价值,远超显示——它让你能一眼看出冲突。比如,如果table[('E', '+')]有两条产生式,终端里会清晰地显示出来,你不用在代码里打无数个print()。

我曾经用这个构建器,处理过一个包含12个非终结符、35条产生式的教学文法。它花了不到0.5秒,就生成了一张12×15的表,并高亮标出3处冲突。这3处冲突,就是我重构文法的全部依据。没有它,我可能要在黑暗中调试一周。

4.4 完整端到端测试:用真实case,验证每一个齿轮是否咬合

写完所有模块,别急着庆祝。真正的考验,是端到端的集成测试。我设计了一套“渐进式”测试策略:

第一层:单元测试(Lexer)
用test_cases/valid/expr1.txt(内容:1+2*3),断言tokenize()返回的Token序列长度为5,且类型依次为NUMBER, PLUS, NUMBER, TIMES, NUMBER。失败?说明Lexer的正则写错了。

第二层:中间件测试(Table Builder)
用grammar.txt生成表后,手动检查M['E', '(']是否为E → T E',M['F', '(']是否为F → ( E )。失败?说明FIRST/FOLLOW计算有误。

第三层:系统测试(Driver)
运行parser.py,输入test_cases/valid/expr1.txt,期望输出“分析成功”;输入test_cases/invalid/expr3.txt(内容:1++2),期望抛出SyntaxError: 期待+,但得到+(注意,第二个+是非法的,因为+后面必须跟一个T,而+不是T的FIRST集成员)。这个报错信息,必须精准到token类型,不能是模糊的“语法错误”。

我记录过一次完整的调试日志:Lexer通过,Table Builder显示M['E', '+']为空,但理论上它应该有E → T E'。追查发现,FIRST(T E')我算成了{(, id, num},但忘了T本身也能推出F T',而F能推出id和num,所以FIRST(T)没错。问题出在E → T E'这条产生式上——E'能推出ε,所以FIRST(T E') = FIRST(T) ∪ (if ε∈FIRST(T) then FIRST(E') else ∅)。而ε ∉ FIRST(T),所以FIRST(T E') = FIRST(T)。最终发现,是grammar.txt里我把E'写成了E',但代码里读成了E'(多了一个空格),导致first_set里根本没有E'这个key。一个空格,debug了40分钟。这就是真实世界的LL(1):它不难,但要求你对每一个字符、每一个空格,都保持敬畏。

5. 常见问题与排查技巧实录:那些没人告诉你的坑,我都替你踩过了

5.1 “分析器卡死了!”——栈不弹出的隐形杀手

现象:程序运行后,CPU占用100%,控制台没输出,也没报错,就是卡住。
原因:驱动程序进入了无限循环。最常见的原因是:stack里一直压着一个非终结符X,而parse_table里M[X, a]为空,但代码里没有raise,而是用了一个默认的continue,导致while stack:永远为真。
排查技巧:在while stack:循环开头,加一行print(f"Stack: {stack}, Pos: {pos}, Token: {input_tokens[pos] if pos < len(input_tokens) else '$'}")。运行后,你会看到栈顶一直是E,而输入一直是+,但M['E', '+']是None。这时,立刻去查table_builder.py的输出,看E行+列是不是空的。如果是,说明文法有问题,或者FIRST/FOLLOW算错了。
避坑心得:永远不要在production = parse_table.get((top, a))后,用if not production: continue。必须if not production: raise SyntaxError(...)。宁可报错,也不要静默失败。

5.2 “FIRST集算出来全是ε!”——左递归的幽灵

现象:compute_first()函数返回的first_set里,几乎所有非终结符的FIRST集都包含ε,导致预测表里大片空白。
原因:文法存在直接或间接左递归。比如E → E + T | T,E能推出E + T,E + T又能推出E + T + T……无限下去,最终能推出ε(如果所有路径都允许ε的话)。但LL(1)文法严禁左递归。
排查技巧:手动追踪一个非终结符。比如E,看它所有的产生式右部,第一个符号是不是E自己?如果是,就是直接左递归。如果不是,再看那个第一个符号(比如T)的产生式,它的第一个符号是不是E?如果是,就是间接左递归。
避坑心得:消除左递归,不是靠感觉,而是有标准算法。对于A → Aα | β,替换成A → β A',A' → α A' | ε。这个替换,必须机械地执行,不能省略任何一步。我见过有人只改了A,忘了改A',结果A'又成了新的左递归源。

5.3 “报错位置完全不对!”——Lexer的定位失准

现象:输入x = 1 + + 2;,分析器报错说“第1行,期待NUMBER,但得到+”,但错误的+明明是第三个token。
原因:Lexer没有正确跳过空白和注释,导致pos计数错乱。比如,x = 1 + + 2;里,两个+之间有一个空格,如果SKIP规则没生效,Lexer会把空格当成MISMATCH,提前报错,后续的+就根本没被读到。
排查技巧:在tokenize()函数末尾,加一行print([t.type for t in tokens]),看输出的Token类型序列,是否和你肉眼预期的一致。如果不一致,问题100%在Lexer。
避坑心得:TOKEN_SPEC的顺序,就是Lexer的“宪法”。把SKIP和NEWLINE放在MISMATCH前面,确保所有空白和换行都被安静处理。MISMATCH必须是最后一个,作为兜底。

5.4 “表建好了,但分析器总说‘期待$’!”——输入结束符的迷思

现象:分析器对1+2*3报错:“期待输入结束,但还有token: PLUS”。
原因:Lexer没有在输入末尾自动添加$终结符,或者驱动程序没有正确处理pos越界。$不是输入的一部分,它是分析器内部的哨兵。当pos == len(input_tokens)时,a应该被设为$,而不是让input_tokens[pos]抛出IndexError。
排查技巧:在驱动程序里,if pos >= len(input_tokens): a = '$'这一行,加一个print(f"Input exhausted, using $ as lookahead")。如果这行没打印,说明pos还没越界,问题在Lexer没把所有输入切完。
避坑心得:永远把$当作一个特殊的“虚拟token”,它只存在于分析器的逻辑里,不应该出现在Lexer的输出中。Lexer的职责是切出所有真实token,分析器的职责是用$来标记结束。

5.5 “文法明明没问题,表里却有冲突!”——ε产生式的陷阱

现象:E' → + T E' | - T E' | ε,FIRST(+ T E') = {+},FIRST(- T E') = {-},FIRST(ε) = {ε},FOLLOW(E') = {$, ), +, -},所以M[E', +]应该有两条:E' → + T E'和E' → ε,冲突。
原因:LL(1)文法的定义要求:对每个非终结符A,其所有产生式的FIRST集必须两两不相交;并且,如果某个产生式能推出ε,那么它的FIRST集和FOLLOW(A)也必须不相交。这里的+既在FIRST(+ T E')里,又在FOLLOW(E')里,违反了第二条。
解决方案:这不是bug,是文法设计的必然结果。E'的ε产生式,本意是表示“E'可以结束”,但它和+、-的FIRST集重叠,造成了歧义。标准解法是:承认E'的ε产生式在+和-处无效,只在$和)处有效。这意味着,当输入是+时,E'必须用+ T E'去匹配,不能跳过。所以,M[E', +]只填E' → + T E',M[E', -]只填E' → - T E',而M[E', $]和M[E', )]填E' → ε。这个“人为裁剪”,是LL(1)分析器工程实践中的常见妥协,它不违背理论,只是让文法在实际中更“听话”。

提示:所有LL(1)分析器的健壮性,都建立在“严格报错”之上。它不猜测,不修复,不宽容。当它说“期待A,但得到B”,那一定是你的输入或文法,真的不符合LL(1)的铁律。拥抱这个“不近人情”,才是深入理解编译原理的开始。

6. 从LL(1)出发:它不是终点,而是你理解整个编译流水线的起点

写完这个LL(1)分析器,你手上握着的,已经不止是一个课设作业。它是一把钥匙,能打开编译器世界的大门。接下来,你可以沿着三条清晰的路径,继续深挖:

  • 向“下”挖,接上语义分析:在驱动程序的if top == token.type:匹配成功时,不光弹栈,还触发一个emit_code()函数,生成三地址码。比如1+2*3,会生成`t1 = 2 *

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询