编译原理实战:从正则表达式到AST,手把手构建一个微型词法语法分析器

张开发
2026/4/18 20:12:56 15 分钟阅读

分享文章

编译原理实战:从正则表达式到AST,手把手构建一个微型词法语法分析器
1. 从零开始为什么需要自己写词法分析器第一次接触编译原理时很多人会被各种晦涩的概念吓退。有限自动机、上下文无关文法、递归下降分析...这些术语听起来就像天书。但当我真正动手实现一个微型编译器前端后才发现这些概念其实非常直观。让我们从一个简单的算术表达式解析器开始比如处理age 10 5这样的表达式。你可能想问现成的解析工具那么多为什么还要自己造轮子我在实际项目中深有体会——当使用Antlr这类工具遇到复杂语法规则时如果不懂底层原理调试起来简直像盲人摸象。自己实现一遍后再看这些工具的文档突然就豁然开朗了。2. 搭建基础正则表达式与词法分析2.1 设计Token类型我们先定义需要识别的Token类型。对于算术表达式基本需要这几类from enum import Enum class TokenType(Enum): IDENTIFIER 0 # 变量名如age NUMBER 1 # 数字如10 OPERATOR 2 # 运算符如, LPAREN 3 # 左括号 RPAREN 4 # 右括号 EOF 5 # 结束标记2.2 实现正则匹配用Python的re模块可以轻松实现基础匹配import re token_specs [ (r\s, None), # 空白字符跳过 (r[0-9], NUMBER), # 整数 (r||||, OPERATOR), # 比较运算符 (r\|-|\*|/, OPERATOR), # 算术运算符 (r\(, LPAREN), (r\), RPAREN), (r[a-zA-Z_][a-zA-Z0-9_]*, IDENTIFIER) # 变量名 ] def build_regex(): return [(re.compile(pattern), type) for pattern, type in token_specs]这里有个实际开发中的经验正则的编写顺序很重要。比如把放在前面否则会优先匹配到导致错误。3. 构建有限自动机从正则到状态转换3.1 手工绘制状态转换图虽然正则很方便但理解底层原理需要状态机。以识别数字为例初始状态等待第一个数字接收数字进入数字中状态遇到非数字回到初始完成识别用代码实现这个状态机def number_lexer(input_str): state start value for char in input_str: if state start: if char.isdigit(): state in_number value char else: return None elif state in_number: if char.isdigit(): value char else: return int(value) return int(value) if state in_number else None3.2 处理更复杂的情况实际项目中我们需要处理各种边界情况。比如浮点数3.14需要额外处理小数点科学计数法1e-5需要更多状态错误恢复遇到非法字符时的处理策略4. 语法分析从Token流到语法树4.1 定义表达式文法使用BNF定义简单算术表达式expr : term ((PLUS | MINUS) term)* term : factor ((MUL | DIV) factor)* factor : NUMBER | IDENTIFIER | LPAREN expr RPAREN这个文法巧妙地解决了运算符优先级问题乘除比加减优先级高括号优先级最高。4.2 递归下降解析实现class Parser: def __init__(self, tokens): self.tokens tokens self.current 0 def parse(self): return self.expr() def expr(self): node self.term() while self.match(PLUS, MINUS): operator self.previous() right self.term() node BinaryOp(node, operator, right) return node def term(self): # 类似expr实现处理乘除 pass def factor(self): if self.match(NUMBER): return Number(self.previous().value) elif self.match(IDENTIFIER): return Variable(self.previous().value) elif self.match(LPAREN): expr self.expr() self.consume(RPAREN) return expr这里有个关键技巧每个方法对应文法中的一个非终结符方法调用链自然形成了递归下降。5. 构建AST让代码理解表达式含义5.1 设计AST节点class ASTNode: pass class BinaryOp(ASTNode): def __init__(self, left, op, right): self.left left self.op op self.right right class Number(ASTNode): def __init__(self, value): self.value value class Variable(ASTNode): def __init__(self, name): self.name name5.2 可视化AST调试AST时可视化非常有用。可以简单实现为def print_ast(node, indent0): prefix * indent if isinstance(node, BinaryOp): print(f{prefix}BinaryOp: {node.op.value}) print_ast(node.left, indent 2) print_ast(node.right, indent 2) elif isinstance(node, Number): print(f{prefix}Number: {node.value}) elif isinstance(node, Variable): print(f{prefix}Variable: {node.name})对于表达式age 10 5输出类似BinaryOp: Variable: age BinaryOp: Number: 10 Number: 56. 实战技巧与常见陷阱6.1 错误处理的艺术好的错误信息能极大提升开发体验。在词法分析阶段def advance(self): self.pos 1 if self.pos len(self.source): self.current_char None else: self.current_char self.source[self.pos] if self.current_char not in self.valid_chars: raise LexerError( f非法字符 {self.current_char} f在位置 {self.pos} )6.2 性能优化技巧当处理大量代码时性能很重要预编译正则表达式使用生成器惰性处理Token流避免在热路径中频繁内存分配# 使用生成器提高内存效率 def tokenize(input_str): regexes build_regex() pos 0 while pos len(input_str): for regex, type in regexes: match regex.match(input_str, pos) if match: if type: # 不是要跳过的Token yield Token(type, match.group()) pos match.end() break else: raise SyntaxError(f无法识别的字符: {input_str[pos]})7. 扩展你的微型编译器完成基础功能后可以考虑添加更多运算符类型支持变量赋值实现条件表达式添加函数调用支持每次只添加一个小功能确保每个步骤都充分测试。我在实现过程中发现先写测试用例再实现功能能显著减少调试时间。实现一个完整的编译器前端确实需要不少工作但当你第一次看到自己写的解析器正确处理复杂表达式时那种成就感绝对值得。最重要的是这个过程会让你对编程语言的理解达到全新高度——现在每次写代码时我都能直观地想象出代码是如何被解析和执行的。

更多文章