从零手搓编译器:Python实现词法分析、语法分析与代码生成
1. 项目概述:为什么我们要“手搓”一个编译器?
“编译器”这个词听起来总是带着一层神秘的面纱,仿佛是高阶程序员的专属领域。每当看到GCC、Clang这些庞然大物,我们很容易产生一种错觉:构建一个编译器需要极其深厚的计算机科学功底和庞大的团队。但事实并非如此。今天,我想和你分享的,就是如何从零开始,用相对简单的思路和代码,构建一个能真正工作的“玩具级”编译器。这个过程,与其说是在造一个工业级工具,不如说是一场深入计算机语言核心的“解剖实验”。
这个项目的核心目标不是与GCC竞争,而是彻底理解“从文本到机器可执行指令”这条流水线究竟是如何运转的。我们将亲手实现经典的编译器三阶段:词法分析、语法分析和代码生成。你会看到,一段诸如a = 3 + 5的简单文本,是如何被拆解成单词(词法分析),如何被组织成一棵体现运算优先级的结构树(语法分析),最后又是如何被翻译成类似汇编的底层指令或另一种高级语言代码(代码生成)的。这个过程能极大地加深你对编程语言本身、对代码执行过程的理解,这种理解是单纯使用编译器无法获得的。
无论你是对编译原理感到好奇的学生,还是希望夯实底层知识的中级开发者,甚至是想要自己设计一门领域特定语言(DSL)的探索者,这个“手搓编译器”的旅程都将是一次宝贵的实践。它剥离了复杂优化和庞大生态,直击核心概念,让你获得“造物主”般的视角。接下来,我们就从最基础的思路设计开始,一步步把它搭建起来。
2. 整体设计与核心思路拆解
在动手写第一行代码之前,我们必须把整个编译器的蓝图设计清楚。一个典型的编译器前端流程可以看作一个精密的翻译管道:源代码文本从一端流入,经过层层处理,最终从另一端流出目标代码。我们的“简单编译器”将严格遵循这个管道模型,但每个环节都做最大程度的简化。
2.1 核心架构:三段式流水线
我们的编译器架构采用最经典的三段式设计,这也是绝大多数教学编译器和早期工业编译器的基础模型。
词法分析器:这是管道的第一个环节。它的任务像是一个敏锐的“单词扫描仪”。我们写的源代码本质上是一个长长的字符串,里面混杂着关键字、标识符、数字、运算符等各种元素。词法分析器的工作就是从左到右扫描这个字符串,根据预定义的规则(比如数字由连续的数字字符组成,标识符以字母开头等),将它们切割成一个个有意义的独立单元,这些单元被称为“词法单元”或“Token”。例如,对于
sum = 10 + count,词法分析器会产出五个Token:[标识符“sum”], [赋值运算符“=”], [数字“10”], [加法运算符“+”], [标识符“count”]。这个阶段会忽略空格、换行和注释这些无关紧要的字符。语法分析器:拿到Token序列后,语法分析器扮演了“结构工程师”的角色。它根据预先定义好的“语法规则”(通常用上下文无关文法描述),检查Token序列是否符合语言的结构规范,并构建出一棵“抽象语法树”。这棵树清晰地表达了代码的嵌套层次和运算优先级。例如,对于
10 + 2 * 3,语法分析器能识别出乘法*的优先级高于加法+,从而生成一棵树,其根节点是+,左孩子是10,右孩子是一棵以*为根、2和3为孩子的子树。这棵树是后续所有处理的基础数据结构。代码生成器:这是最后的“翻译官”。它遍历抽象语法树,根据每个节点的类型,生成等价的目标代码。目标代码的选择很灵活,为了简单起见,我们可以选择生成一种非常简单的“栈式虚拟机指令”,或者生成另一种高级语言(如Python)的代码。例如,对于上面的AST,代码生成器可能会生成这样的指令序列:“将10压入栈;将2压入栈;将3压入栈;执行乘法(弹出栈顶两个元素相乘,结果压回栈);执行加法”。如果生成Python代码,那就是简单的
10 + 2 * 3。
注意:我们这里刻意省略了“语义分析”(如类型检查)和“优化”这两个在工业级编译器中至关重要的阶段。这是为了保持项目的核心聚焦和可控性。我们的目标是理解主干流程,枝繁叶茂的优化可以留待日后探索。
2.2 语言定义:我们编译什么?
在构建编译器之前,必须先定义它要编译的语言。我们设计一个超级简化的算术表达式语言,它只包含以下元素:
- 整数:如
0,42,100。 - 变量:由字母开头的字母数字串组成,如
x,total,count1。 - 运算符:四则运算
+,-,*,/,以及赋值=。 - 括号:
(和)用于改变运算优先级。 - 语句:一个程序由多个“赋值语句”组成,每个语句形式为
变量 = 表达式;。
例如,下面就是一个合法的“程序”:
a = 10; b = 20; result = a + b * 2;这个语言虽然简单,但已经具备了构成一门编程语言最核心的要素:数据(整数、变量)、运算和顺序执行。以此为基础,我们足以演示完整的编译流程。
2.3 工具选型:为什么用Python?
实现语言的选择很多,C、Java、Go都可以。但我强烈推荐使用Python作为第一次“手搓编译器”的实现语言,原因如下:
- 快速原型:Python语法简洁,能让我们将精力集中在算法和逻辑本身,而非内存管理、复杂类型声明等细节上。
- 强大的内置数据结构:列表、字典、字符串处理等功能非常适合实现Token流、语法树等结构。
- 易于表达和调试:我们可以轻松地打印出Token列表、以缩进形式可视化AST,每一步的中间结果都清晰可见,极大降低了调试难度。
- 目的契合:我们的目标是教学和理解,而非追求极致性能。Python的慢速在这个场景下完全不是问题。
当然,如果你对性能有执念,或者想更贴近底层,用C语言重写一遍将是绝佳的进阶练习。但第一步,请先用Python把路走通。
3. 第一阶段实战:构建词法分析器
词法分析器,也叫扫描器,是整个编译过程的“开路先锋”。它的实现思路主要有两种:手动编写状态机,或者使用生成工具(如Lex)。为了彻底理解原理,我们选择手动实现一个基于有限状态自动机思想的分析器。
3.1 Token的设计与定义
首先,我们需要定义我们的语言中有哪些类型的Token。我们可以用一个Python类或命名元组来代表一个Token,它至少包含两个信息:类型(Type)和值(Value)。
class Token: def __init__(self, type_, value=None): self.type = type_ # Token类型,如 ‘INTEGER‘, ’PLUS‘ self.value = value # Token的原始字符串值,如 ‘123‘, ’+‘ def __repr__(self): return f‘Token({self.type}, {repr(self.value)})‘接下来,我们为迷你语言定义Token类型。我们可以用字符串常量来表示:
# Token 类型 INTEGER = ‘INTEGER‘ PLUS = ‘PLUS‘ MINUS = ‘MINUS‘ MUL = ‘MUL‘ DIV = ‘DIV‘ LPAREN = ‘LPAREN‘ # 左括号 ( RPAREN = ‘RPAREN‘ # 右括号 ) ASSIGN = ‘ASSIGN‘ # 赋值 = ID = ‘ID‘ # 标识符,如变量名 SEMI = ‘SEMI‘ # 分号 ; EOF = ‘EOF‘ # 文件结束标志3.2 手动实现扫描器
我们将编写一个Lexer类,它接收源代码字符串,并提供一个get_next_token()方法,每次调用都返回下一个Token。
class Lexer: def __init__(self, text): self.text = text # 源代码字符串 self.pos = 0 # 当前字符索引 self.current_char = self.text[self.pos] if self.text else None def error(self): raise Exception(‘Invalid character‘) def advance(self): """移动‘pos‘指针,获取下一个字符。""" self.pos += 1 if self.pos > len(self.text) - 1: self.current_char = None # 输入结束 else: self.current_char = self.text[self.pos] def skip_whitespace(self): """跳过所有空白字符(空格、制表符、换行)。""" while self.current_char is not None and self.current_char.isspace(): self.advance() def integer(self): """读取一个多位整数。""" result = ‘‘ while self.current_char is not None and self.current_char.isdigit(): result += self.current_char self.advance() return int(result) def identifier(self): """读取一个标识符(变量名)。""" result = ‘‘ # 标识符以字母或下划线开头 while self.current_char is not None and (self.current_char.isalnum() or self.current_char == ‘_‘): result += self.current_char self.advance() return result def get_next_token(self): """词法分析器的核心方法:获取下一个Token。""" while self.current_char is not None: if self.current_char.isspace(): self.skip_whitespace() continue if self.current_char.isdigit(): return Token(INTEGER, self.integer()) if self.current_char.isalpha() or self.current_char == ‘_‘: id_name = self.identifier() # 这里可以检查是否是关键字,我们语言简单,暂无关键字 return Token(ID, id_name) # 处理单字符操作符 if self.current_char == ‘+‘: self.advance() return Token(PLUS, ‘+‘) if self.current_char == ‘-‘: self.advance() return Token(MINUS, ‘-‘) if self.current_char == ‘*‘: self.advance() return Token(MUL, ‘*‘) if self.current_char == ‘/‘: self.advance() return Token(DIV, ‘/‘) if self.current_char == ‘(‘: self.advance() return Token(LPAREN, ‘(‘) if self.current_char == ‘)‘: self.advance() return Token(RPAREN, ‘)‘) if self.current_char == ‘=‘: self.advance() return Token(ASSIGN, ‘=‘) if self.current_char == ‘;‘: self.advance() return Token(SEMI, ‘;‘) self.error() # 遇到无法识别的字符 # 源代码已全部处理完毕 return Token(EOF)实操心得:在手动编写词法分析器时,最容易出错的地方就是状态切换。比如,在读取完一个整数123后,current_char已经指向了数字后面的字符,此时一定要确保get_next_token方法的下一次调用是从这个新位置开始,而不是重复读取。我们的advance()方法很好地管理了这个指针。另外,skip_whitespace()中的continue语句至关重要,它确保在跳过空白后立即重新开始判断字符类型,而不是直接返回。
3.3 测试词法分析器
让我们用一段简单的代码测试一下我们的词法分析器是否工作正常。
def test_lexer(): code = ‘‘‘ x = 42; y = (10 + 2) * 3; ‘‘‘ lexer = Lexer(code) tokens = [] while True: token = lexer.get_next_token() tokens.append(token) if token.type == EOF: break print(tokens) # 期望输出类似: # [Token(ID, ‘x‘), Token(ASSIGN, ‘=‘), Token(INTEGER, 42), Token(SEMI, ‘;‘), # Token(ID, ‘y‘), Token(ASSIGN, ‘=‘), Token(LPAREN, ‘(‘), Token(INTEGER, 10), ... , Token(EOF)]如果测试通过,恭喜你!你已经成功地将源代码字符串转换成了结构化的Token流。这是理解编译器工作的第一步,也是最直观的一步。接下来,我们要让这些零散的Token变得有层次、有结构。
4. 第二阶段实战:构建语法分析器
语法分析是编译器的“大脑”,它负责理解Token序列的结构性含义。我们采用“递归下降”分析法来实现,这是最直观、最适合手工编写语法分析器的方法。它的核心思想是为语法规则中的每个非终结符(如“表达式”、“项”)编写一个对应的解析函数。
4.1 定义语法规则
首先,我们需要用形式化的方式描述我们迷你语言的语法。我们使用巴科斯范式(BNF)或其扩展形式EBNF来定义。对于我们包含赋值语句的语言,规则可以这样写:
program : statement+ statement : ID ‘=‘ expr ‘;‘ expr : term ((PLUS | MINUS) term)* term : factor ((MUL | DIV) factor)* factor : INTEGER | ID | LPAREN expr RPAREN解释一下:
program(程序)由至少一条statement(语句)组成。statement(语句)是一个标识符(变量名),后跟赋值号=,再跟一个表达式expr,最后以分号;结束。expr(表达式)由一个term(项)开头,后面可以跟零个或多个((PLUS|MINUS) term),即加减运算。term(项)由一个factor(因子)开头,后面可以跟零个或多个((MUL|DIV) factor),即乘除运算。factor(因子)可以是一个整数、一个标识符(变量),或者一个用括号括起来的表达式。
这种分层定义巧妙地隐含了运算符的优先级:括号factor最高,其次是乘除term,最后是加减expr。赋值=的优先级最低,并且在语句级处理。
4.2 实现递归下降语法分析器
我们将创建一个Parser类,它内部封装一个词法分析器实例,并提供一个current_token来跟踪当前正在查看的Token。
class Parser: def __init__(self, lexer): self.lexer = lexer self.current_token = self.lexer.get_next_token() # 初始化,获取第一个Token def error(self): raise Exception(‘Invalid syntax‘) def eat(self, token_type): """“消耗”当前Token。如果当前Token类型匹配期望的类型,则获取下一个Token;否则报错。""" if self.current_token.type == token_type: self.current_token = self.lexer.get_next_token() else: self.error() def factor(self): """解析因子:整数 | 标识符 | ( 表达式 )""" token = self.current_token if token.type == INTEGER: self.eat(INTEGER) return NumNode(token.value) # 返回一个数字节点 elif token.type == ID: self.eat(ID) return VarNode(token.value) # 返回一个变量节点 elif token.type == LPAREN: self.eat(LPAREN) node = self.expr() # 递归解析括号内的表达式 self.eat(RPAREN) return node else: self.error() def term(self): """解析项:因子 ((乘|除) 因子)* """ node = self.factor() # 解析第一个因子 # 循环处理连续的乘除运算 while self.current_token.type in (MUL, DIV): token = self.current_token if token.type == MUL: self.eat(MUL) elif token.type == DIV: self.eat(DIV) # 解析右边的因子,并与之前的节点构成一个新的二元运算节点 node = BinOpNode(left=node, op=token.value, right=self.factor()) return node def expr(self): """解析表达式:项 ((加|减) 项)* """ node = self.term() # 解析第一个项 # 循环处理连续的加减运算 while self.current_token.type in (PLUS, MINUS): token = self.current_token if token.type == PLUS: self.eat(PLUS) elif token.type == MINUS: self.eat(MINUS) node = BinOpNode(left=node, op=token.value, right=self.term()) return node def statement(self): """解析语句:ID = 表达式 ; """ # 当前Token应该是一个标识符(变量名) var_name = self.current_token.value self.eat(ID) self.eat(ASSIGN) # 消耗掉 ‘=‘ expr_node = self.expr() # 解析等号右边的表达式 self.eat(SEMI) # 消耗掉 ‘;‘ return AssignNode(var_name, expr_node) # 返回一个赋值语句节点 def program(self): """解析程序:语句+ """ statements = [] # 持续解析语句,直到遇到文件结束符 while self.current_token.type != EOF: statements.append(self.statement()) return ProgramNode(statements) # 返回程序根节点上面的代码中,我们引入了几个AST节点类,用于在内存中构建树形结构:
class ASTNode: """所有AST节点的基类。""" pass class NumNode(ASTNode): def __init__(self, value): self.value = value class VarNode(ASTNode): def __init__(self, name): self.name = name class BinOpNode(ASTNode): def __init__(self, left, op, right): self.left = left self.op = op self.right = right class AssignNode(ASTNode): def __init__(self, var_name, expr_node): self.var_name = var_name self.expr_node = expr_node class ProgramNode(ASTNode): def __init__(self, statements): self.statements = statements注意事项:递归下降分析器的函数调用栈直接对应了语法规则的嵌套层次。expr调用term,term调用factor,factor可能递归调用expr(当遇到括号时)。这种清晰的对应关系使得代码非常易于理解和调试。关键在于每个函数都“承诺”解析完它负责的语法单元,并将当前Token推进到该单元之后的位置。
4.3 可视化抽象语法树
为了直观地看到解析结果,我们可以为AST实现一个简单的可视化方法。
def print_ast(node, indent=0): """递归打印AST,用缩进表示层级。""" prefix = ‘ ‘ * indent if isinstance(node, NumNode): print(f‘{prefix}Num({node.value})‘) elif isinstance(node, VarNode): print(f‘{prefix}Var({node.name})‘) elif isinstance(node, BinOpNode): print(f‘{prefix}BinOp({node.op})‘) print_ast(node.left, indent + 1) print_ast(node.right, indent + 1) elif isinstance(node, AssignNode): print(f‘{prefix}Assign(to: {node.var_name})‘) print_ast(node.expr_node, indent + 1) elif isinstance(node, ProgramNode): print(f‘{prefix}Program‘) for stmt in node.statements: print_ast(stmt, indent + 1) # 测试语法分析 code = ‘‘‘ a = 3 + 5 * 2; ‘‘‘ lexer = Lexer(code) parser = Parser(lexer) ast = parser.program() print_ast(ast)运行上述代码,你将会看到类似下面的输出,它清晰地展示了5 * 2先结合,然后再与3相加的树形结构:
Program Assign(to: a) BinOp(+) Num(3) BinOp(*) Num(5) Num(2)至此,我们已经成功地将线性的Token序列转换成了层次化的树形结构。这棵AST完整地保留了源代码的语义,并且消除了括号、运算符优先级等语法细节,是进行代码生成的完美起点。
5. 第三阶段实战:实现代码生成器
代码生成器是编译器的“产出部门”,它遍历AST,为每个节点生成对应的目标代码。目标代码的形式多种多样,为了最大化教学意义并保持简单,我们这里实现两种:一种是生成“三地址码”这种中间表示,另一种是生成可执行的Python代码。
5.1 生成三地址码
三地址码是一种非常常见的中间表示形式,每条指令最多涉及三个地址(操作数)。它非常接近底层汇编,但又保持了硬件无关性。通常形式如:result = arg1 op arg2。
我们为AST节点类添加一个generate_tac方法,该方法返回一个指令列表。
class NumNode(ASTNode): # ... 其他代码同上 ... def generate_tac(self, code_gen): # 为数字生成一个临时变量名来保存它的值 temp_var = code_gen.new_temp() code_gen.emit(f‘{temp_var} = {self.value}‘) return temp_var # 返回存放该值的变量名 class VarNode(ASTNode): # ... 其他代码同上 ... def generate_tac(self, code_gen): # 变量节点直接返回变量名本身 return self.name class BinOpNode(ASTNode): # ... 其他代码同上 ... def generate_tac(self, code_gen): # 递归生成左右操作数的代码,并获取它们的结果变量 left_var = self.left.generate_tac(code_gen) right_var = self.right.generate_tac(code_gen) # 为本次运算结果创建一个新的临时变量 result_var = code_gen.new_temp() # 生成三地址码指令 code_gen.emit(f‘{result_var} = {left_var} {self.op} {right_var}‘) return result_var # 返回本次运算结果的变量名 class AssignNode(ASTNode): # ... 其他代码同上 ... def generate_tac(self, code_gen): # 生成右侧表达式的代码,并获取结果变量 expr_var = self.expr_node.generate_tac(code_gen) # 生成赋值指令 code_gen.emit(f‘{self.var_name} = {expr_var}‘) return self.var_name class ProgramNode(ASTNode): # ... 其他代码同上 ... def generate_tac(self): code_gen = CodeGenerator() for stmt in self.statements: stmt.generate_tac(code_gen) return code_gen.instructions class CodeGenerator: """辅助类,用于管理临时变量和指令序列。""" def __init__(self): self.instructions = [] self.temp_counter = 0 def new_temp(self): """生成一个新的临时变量名,如 t1, t2...""" self.temp_counter += 1 return f‘t{self.temp_counter}‘ def emit(self, instruction): """添加一条指令。""" self.instructions.append(instruction)现在,我们可以用之前的AST来生成三地址码了:
code = ‘‘‘ a = 3 + 5 * 2; ‘‘‘ lexer = Lexer(code) parser = Parser(lexer) ast = parser.program() tac_instructions = ast.generate_tac() for instr in tac_instructions: print(instr)输出将会是:
t1 = 5 t2 = 2 t3 = t1 * t2 t4 = 3 t5 = t4 + t3 a = t5这些指令顺序执行,就能计算出a的最终值。三地址码非常利于后续的优化(比如删除t4 = 3这样冗余的赋值)和翻译成真正的机器码。
5.2 生成Python代码
生成另一种高级语言(如Python)的代码则简单直接得多,本质上就是对AST进行递归拼接字符串。
class NumNode(ASTNode): # ... 其他代码同上 ... def to_python(self): return str(self.value) class VarNode(ASTNode): # ... 其他代码同上 ... def to_python(self): return self.name class BinOpNode(ASTNode): # ... 其他代码同上 ... def to_python(self): # 注意:需要给子表达式加括号以保证优先级,但我们的AST结构已经保证了优先级,所以可以不加。 # 但为了安全,可以给非叶子节点都加上括号。 left_str = self.left.to_python() right_str = self.right.to_python() return f‘({left_str} {self.op} {right_str})‘ class AssignNode(ASTNode): # ... 其他代码同上 ... def to_python(self): return f‘{self.var_name} = {self.expr_node.to_python()}‘ class ProgramNode(ASTNode): # ... 其他代码同上 ... def to_python(self): lines = [stmt.to_python() for stmt in self.statements] return ‘\n‘.join(lines) # 测试生成Python代码 code = ‘‘‘ a = 3 + 5 * 2; b = (10 - 2) / 4; ‘‘‘ lexer = Lexer(code) parser = Parser(lexer) ast = parser.program() python_code = ast.to_python() print(“生成的Python代码:“) print(python_code) print(“\n执行结果:“) # 动态执行生成的代码(注意:在生产环境中需谨慎使用eval/exec) exec_globals = {} exec(python_code, exec_globals) print(f“a = {exec_globals.get(‘a‘)}“) print(f“b = {exec_globals.get(‘b‘)}“)输出:
生成的Python代码: a = (3 + (5 * 2)) b = ((10 - 2) / 4) 执行结果: a = 13 b = 2.0实操心得:代码生成阶段最需要小心的是“上下文”问题。在生成三地址码时,我们通过CodeGenerator类来管理全局的指令列表和临时变量计数器。在生成Python这类语言时,则要特别注意运算符优先级和括号的添加。虽然我们的AST结构保证了优先级,但在转换为字符串时,为每个二元运算节点都加上括号是一种简单且安全的策略,虽然可能产生多余的括号,但保证了语义绝对正确。
6. 集成测试与问题排查实录
将词法分析、语法分析和代码生成三个阶段串联起来,我们就得到了一个完整的编译器流水线。让我们构建一个简单的Compiler类来封装整个过程,并进行端到端的测试。
6.1 构建完整的编译器类
class SimpleCompiler: def __init__(self): pass def compile(self, source_code, target=‘python‘): """编译源代码。 Args: source_code: 源代码字符串。 target: 目标代码类型,‘python‘ 或 ‘tac‘(三地址码)。 Returns: 生成的目标代码字符串。 """ # 1. 词法分析 lexer = Lexer(source_code) # 2. 语法分析 parser = Parser(lexer) ast = parser.program() # 3. 代码生成 if target == ‘python‘: return ast.to_python() elif target == ‘tac‘: instructions = ast.generate_tac() return ‘\n‘.join(instructions) else: raise ValueError(f“Unsupported target: {target}“) # 使用编译器 compiler = SimpleCompiler() source = ‘‘‘ x = 1; y = 2; sum = x + y * 10; ‘‘‘ print(“=== 生成Python代码 ===") py_output = compiler.compile(source, ‘python‘) print(py_output) print(“\n=== 生成三地址码 ===") tac_output = compiler.compile(source, ‘tac‘) print(tac_output)6.2 常见问题与调试技巧
在“手搓编译器”的过程中,你几乎一定会遇到下面这些问题。这里我把自己踩过的坑和解决方法记录下来。
问题1:词法分析器无法识别多字符运算符(如==,>=)。
- 现象:输入
a == b被识别为a,=,=,b四个Token。 - 原因:我们的词法分析器是单字符匹配,看到第一个
=就返回了ASSIGNToken。 - 解决:在
get_next_token方法中,识别单字符=后,可以“偷看”下一个字符(peek)。如果下一个字符也是=,则组合成EQ(等于)Token。这需要修改advance和peek的逻辑。
问题2:语法分析器报告“Invalid syntax”,但代码看起来没错。
- 排查步骤:
- 打印Token流:首先将词法分析器输出的Token列表打印出来,确认分词是否正确。常见错误是标识符包含了数字、数字识别错误等。
- 检查当前Token:在语法分析器每个解析函数的开头和
eat调用前打印current_token,看程序实际执行到了哪一步,在哪里出的错。 - 核对语法规则:仔细检查你的BNF规则和递归下降函数是否一一对应。特别是
*(零次或多次)和+(一次或多次)的循环处理部分,while循环的条件和结束条件是否正确。
问题3:生成的代码执行结果不对,尤其是运算优先级错误。
- 现象:输入
3 + 5 * 2,生成代码3 + 5 * 2,但执行结果却是16(先算了3+5)。 - 原因:这通常不是语法分析器的问题,因为我们的AST构建是正确的。问题出在代码生成阶段。如果你在
BinOpNode.to_python()中没有为子表达式添加括号,而直接拼接为f‘{left_str} {self.op} {right_str}‘,那么对于表达式3 + 5 * 2,其AST为BinOp(+, Num(3), BinOp(*, Num(5), Num(2)))。递归生成代码时,会先得到left_str=“3“,right_str=“5 * 2“,拼接后就是“3 + 5 * 2“。在Python中,这符合优先级,没问题。但如果你生成的是某些优先级规则不同的语言,或者进行字符串拼接时顺序不对,就会出错。 - 解决:最稳妥的办法是在
BinOpNode.to_python()中,无论左右节点是什么,都为其加上括号:return f‘({left_str} {self.op} {right_str})‘。虽然会产生多余括号如((3) + ((5) * (2))),但保证了万无一失。对于三地址码,则不存在优先级问题,因为每条指令都是原子的。
问题4:如何处理更复杂的语句,比如if或while?
- 思路扩展:这需要扩展我们的语言定义和编译器。
- 词法分析:增加对应的关键字Token,如
IF,WHILE,ELSE。 - 语法规则:在BNF中添加新的规则。例如:
statement : assign_stmt | if_stmt | while_stmt if_stmt : IF expr ‘{‘ program ‘}‘ (ELSE ‘{‘ program ‘}‘)? while_stmt : WHILE expr ‘{‘ program ‘}‘ - AST节点:创建
IfNode和WhileNode,包含条件表达式和语句块。 - 代码生成:在生成代码时,
IfNode需要生成条件跳转指令(三地址码)或if ...:语句(Python)。
- 词法分析:增加对应的关键字Token,如
这个过程虽然工作量增加,但模式是重复的:定义Token,更新语法规则,实现新的AST节点和对应的解析函数,最后实现代码生成。这就像搭积木,核心框架搭建好后,添加新功能是模块化的。
给初学者的最终建议:不要试图一开始就做一个功能完整的编译器。从我们今天这个只能处理赋值和四则运算的迷你编译器开始,每成功添加一个新功能(比如支持负数、支持print语句、支持if),你都会对编译原理有更深的理解。动手调试,观察每一步的中间结果(Token流、AST、生成代码),是学习这个过程最有效的方式。当你看到自己写的编译器,将一段文本成功转换成可以执行的指令时,那种成就感是无与伦比的。
