1. 这不是编译原理课,而是一次“造轮子”实战:从零手写语法分析器,跑通第一行脚本
你有没有试过,在终端里敲下print("Hello, world!"),按下回车,立刻看到结果弹出来?那种“我写的指令被机器听懂了”的瞬间,比任何教程都让人上头。但多数人止步于“会用”,很少有人真正拆开看:这行字是怎么从一串字符,变成内存里跳动的指令,最后变成屏幕上那行字的?今天这篇,不讲龙书、不画语法树、不推导FIRST/FOLLOW集——我们直接动手,用不到500行纯Python代码,实现一个能执行变量定义、四则运算、条件判断和函数调用的微型脚本语言。它没有垃圾回收,不支持面向对象,甚至不能处理浮点数精度问题,但它能跑通x = 3; y = x * 2 + 1; if y > 5 { print(y) }这样的真实逻辑。核心就三块:词法扫描(把源码切分成token)、语法分析(按语法规则组装成树)、解释执行(遍历树,边走边算)。很多人卡在第二步——语法分析。它不像词法扫描那样是机械切分,也不像解释执行那样是直白计算,它是规则与结构的桥梁,是“理解”发生的临界点。这篇内容专为想亲手触摸语言内核的开发者准备:如果你写过正则表达式但没写过递归下降解析器,如果你调试过Babel插件但没看过AST节点怎么一层层挂上去,如果你用过TypeScript类型系统但好奇“类型检查”到底在AST哪一层介入——那你就是我要找的人。它不要求你背熟LL(1)文法,但要求你愿意在parse_expression()函数里加三行print(),亲眼看着1 + 2 * 3是怎么被拆成Add(Num(1), Mul(Num(2), Num(3)))的。接下来所有代码,你复制粘贴就能跑;所有原理,我都用“手电筒照迷宫”的方式讲清楚——不是告诉你出口在哪,而是给你手电筒,带你一格一格照亮脚下那块地砖。
2. 为什么放弃Yacc/Bison,坚持手写递归下降?这不是情怀,是可控性刚需
很多人看到“语法分析”第一反应是:用现成工具啊!ANTLR、Yacc、Lark,一行命令生成parser,省时省力。我试过,也教过别人用,结果发现:当你的脚本语言要加一个新特性——比如支持管道操作符|>,或者想让if语句支持单行写法if x > 0 print(x)——你得先改BNF文法,再重新生成代码,然后在生成的千行代码里找哪个函数负责处理if,再小心翼翼地插入新逻辑。更糟的是,一旦报错,错误信息是“line 12: mismatched input '|' expecting '{'”,你得反向查文法定义,再对照生成的代码,才能定位到是哪个match()调用没覆盖分支。这不是开发,是考古。而手写递归下降,每个函数名就是它的职责:parse_if_statement()只管if,parse_binary_expression()只管+ - * /,parse_call_expression()只管函数调用。新加一个parse_pipe_expression(),你就在主入口parse_expression()里加一行elif self.current_token.type == PIPE:,然后写个五六十行的函数,逻辑完全在你眼皮底下。更重要的是,优先级和结合性不再靠工具配置,而由函数调用顺序天然决定。比如,乘除优先级高于加减,你只要让parse_expression()先调parse_term()(处理* /),再让parse_term()调parse_factor()(处理数字、括号),加减自然就被“包”在更外层。这种控制感,是生成式工具永远给不了的。当然,代价是代码量——手写比生成多出约30%的样板逻辑,比如反复写self.advance()、self.expect(TokenType.XXX)。但这些代码不是噪音,它们是你的“调试探针”。我在某次调试a + b * c + d时,直接在parse_expression()开头加print(f"parsing expr at {self.pos}, current={self.current_token}"),立刻看到解析器是如何一层层深入、又如何回溯的。这种实时可观测性,对理解语言行为至关重要。所以,这不是拒绝工具,而是明确场景:当你还在定义语言轮廓、频繁调整语法规则、需要快速验证设计想法时,手写递归下降是唯一能让你“手指碰到语法神经末梢”的方式。等语言稳定了,再考虑用工具做性能优化或跨平台编译,那是下一阶段的事。
2.1 递归下降的核心契约:每个函数负责一个文法产生式,且永不回溯
递归下降解析器的本质,是一组相互调用的函数,每个函数对应BNF文法中的一个非终结符。比如文法有<statement> ::= <if-statement> | <expr-statement>,你就得有两个函数:parse_statement()和parse_if_statement()。但光有对应关系不够,还必须遵守一条铁律:每个函数必须能根据当前token(lookahead)唯一确定该走哪条分支,且一旦选了,就绝不回头。这叫“无回溯”(no backtracking)。举个反例:假设你想支持for循环和while循环,文法写成<loop> ::= 'for' ... | 'while' ...。如果parse_loop()看到当前token是'for',就调parse_for();看到'while',就调parse_while()——这没问题。但如果文法写成<loop> ::= <identifier> '(' ... | 'while' ...,而<identifier>可能是变量名也可能是关键字,那parse_loop()看到'while'时,无法确定该进<identifier>分支还是'while'分支,就会陷入歧义。解决方案只有一个:把所有可能引起冲突的关键字,提前声明为保留字(reserved keywords),让词法扫描器直接返回TokenType.WHILE而不是TokenType.IDENTIFIER。这就是为什么我们在词法扫描阶段,必须硬编码一个保留字映射表:
RESERVED_KEYWORDS = { 'if': TokenType.IF, 'else': TokenType.ELSE, 'while': TokenType.WHILE, 'print': TokenType.PRINT, 'return': TokenType.RETURN, 'true': TokenType.TRUE, 'false': TokenType.FALSE, }当扫描器遇到'if',它不返回IDENTIFIER,而是直接返回IF。这样,parse_if_statement()函数就可以放心地写成:
def parse_if_statement(self): self.expect(TokenType.IF) # 必须是IF token,否则报错 self.expect(TokenType.LPAREN) condition = self.parse_expression() # 解析条件表达式 self.expect(TokenType.RPAREN) self.expect(TokenType.LBRACE) consequence = self.parse_block_statement() # 解析if块 self.expect(TokenType.RBRACE) # ... 处理else分支 return IfNode(condition, consequence, alternative)这里每一行self.expect()都是在履行契约:它断言“此刻我期望看到某个特定token”,如果实际不是,就立刻抛错,绝不尝试其他路径。这种“刚性”看似笨拙,实则是稳定性的基石。我在实现print函数调用时,曾漏掉对LPAREN的expect(),导致解析器在print 123(没括号)时,误把123当作print的参数,然后卡死在后续解析。加了expect(TokenType.LPAREN)后,错误立刻变成清晰的“Expected '(', got '123'”,定位时间从15分钟缩短到15秒。所以,递归下降的“手写”价值,不在于炫技,而在于把模糊的文法歧义,转化成明确的、可调试的、带行号的运行时断言。
2.2 为什么选择“递归下降”而非“算符优先”?因为人类大脑天生理解嵌套
算符优先分析(Operator-Precedence Parsing)在工业级编译器中很常见,它用一张二维表格驱动解析,效率极高。但它的学习曲线陡峭,调试极其困难。表格里一个数值填错,整个表达式解析就乱套,而你很难从“1 + 2 * 3解析成(1 + 2) * 3”这个结果,反推出是哪一行表格数据错了。递归下降则完全不同:它的结构和人类阅读代码的习惯完全一致。你看a + b * c,第一反应是“先算b * c,再算a + (b*c)”,这正是parse_expression()调用parse_term(),parse_term()再调用parse_factor()的调用栈。这种心理模型与代码结构的对齐,极大降低了认知负荷。更重要的是,它天然支持任意复杂度的嵌套结构。比如,你想支持函数调用里的参数是另一个函数调用:f(g(h()))。在递归下降里,这不过是parse_call_expression()在解析参数列表时,再次调用parse_expression(),而parse_expression()又可能调用parse_call_expression()——递归自己解决。而在算符优先里,你需要为每种嵌套深度预设状态,或者引入复杂的栈操作,代码迅速变得不可维护。我在实现if语句的嵌套时(if x > 0 { if y < 10 { print("ok") } }),根本不用额外处理“嵌套”这件事,因为parse_if_statement()内部调用parse_block_statement(),而parse_block_statement()又会循环调用parse_statement(),自然就支持无限嵌套。这种“简单即强大”的哲学,正是手写解析器最迷人的地方。它不追求理论最优,而追求工程师的“心流”——当你写完parse_function_definition(),看着func add(a, b) { return a + b }被正确解析成AST节点时,那种逻辑闭环带来的愉悦感,是任何自动化工具都无法替代的。
3. 核心细节拆解:Token流、AST节点、解析器状态机,三者如何咬合运转
语法分析不是黑箱,它是一个精密的状态机,由三个核心部件咬合驱动:Token流(输入)、AST节点(输出)、解析器实例(状态载体)。理解它们如何协同,是写出健壮解析器的关键。首先,Token流不是一次性全读入内存的字符串数组,而是一个带游标(cursor)的迭代器。解析器实例(我们叫它Parser类)持有一个tokens: List[Token]和一个pos: int索引。每次调用self.current_token,它返回tokens[pos];调用self.advance(),它把pos加1,并返回新的current_token。这个设计看似简单,却解决了两个致命问题:一是避免重复创建token对象(节省内存),二是让expect()方法能精准报告错误位置——self.expect(TokenType.XXX)失败时,错误信息可以包含line 5, column 12,因为每个token都记录了其原始源码位置。其次,AST节点不是空泛的字典或JSON,而是有明确职责的Python类。比如BinaryOpNode必须有left,operator,right三个属性,IfNode必须有condition,consequence,alternative。这些类不包含任何业务逻辑(比如不负责计算left + right的值),只负责结构化存储。这样做的好处是解耦:语法分析只管“长什么样”,解释执行才管“做什么”。我在早期曾试图让BinaryOpNode自带evaluate()方法,结果导致AST类和解释器逻辑混杂,后来重构时花了整整两天才剥离干净。最后,解析器状态机的“心跳”是advance()和expect()。advance()是前进一格,expect(token_type)是“断言当前格必须是某类型,否则报错并停止”。它们共同构成了一种受控的、不可逆的推进机制。比如解析var x = 1 + 2;,流程是:
parse_statement()看到VAR,进入parse_var_declaration()parse_var_declaration()调用self.expect(TokenType.VAR)→ 当前是VAR,通过,pos前进- 调用
self.expect(TokenType.IDENTIFIER)→ 当前是x,通过,pos前进 - 调用
self.expect(TokenType.EQUAL)→ 当前是=,通过,pos前进 - 调用
self.parse_expression()→ 进入表达式解析,最终返回AddNode(NumNode(1), NumNode(2)) - 调用
self.expect(TokenType.SEMICOLON)→ 当前是;,通过,pos前进 - 返回
VarDeclarationNode('x', AddNode(...))
注意,每一步expect()都像一道闸门,确保输入流严格符合预期。如果第3步x后面不是=,而是{,错误立刻抛出:“Expected '=', got '{'”,位置精确到字符。这种确定性,是构建可靠语言工具链的起点。很多初学者喜欢在parse_expression()里写if self.current_token.type in [PLUS, MINUS]: ... elif self.current_token.type in [MUL, DIV]: ...,这看似灵活,实则埋雷——它绕过了expect()的断言机制,错误会延迟到更深层才暴露,定位成本指数级上升。所以,我的经验是:宁可多写十行expect(),绝不省一行if判断。这是用代码纪律换来的长期可维护性。
3.1 Token设计的隐藏陷阱:如何让错误提示既准确又友好?
Token看起来很简单:一个类型(TokenType.NUMBER)、一个值(123)、一个位置(line=3, col=5)。但实际设计时,有三个易被忽视的坑。第一个坑是值(value)的类型一致性。数字token的value应该是int还是str?我一开始用str,觉得“保持原始字符串”,结果在解释器里做加法时,得反复int(token.value),一旦遇到0x1F这样的十六进制,还得额外处理。后来统一改为int或float(虽然本项目不支持浮点,但预留接口),解释器直接用,干净利落。第二个坑是位置(position)的粒度。只记录line和col够吗?不够。比如// comment这样的单行注释,如果col只记录起始列,错误提示“Expected ';' at line 5, column 1”就指向注释开头,而非真正的语句位置。解决方案是:每个token记录start_pos(起始字符索引)和end_pos(结束字符索引),错误提示时,用source_code[start_pos:end_pos]高亮出错的原始文本片段。第三个坑,也是最隐蔽的,是关键字与标识符的边界处理。比如print是关键字,printer是合法标识符。如果词法扫描器只按字符匹配,遇到printer时,会先匹配print,然后卡在e上,不知道该返回PRINT还是IDENTIFIER。标准解法是“最长匹配原则”(longest match):扫描器持续读取字符,直到下一个字符无法构成更长的有效token,然后回退一个字符,返回已识别的最长token。这意味着,printer会被完整读作IDENTIFIER,而print会被读作PRINT。我在实现时,专门写了一个测试用例:["print", "printer", "printx"],确保三者分别返回PRINT,IDENTIFIER,IDENTIFIER。这个细节不处理好,用户写let printer = 123时,解析器会莫名其妙报“Unexpected token 'print'”,让人一头雾水。所以,Token设计不是技术细节,而是用户体验的第一道防线。
3.2 AST节点的“最小完备性”原则:每个节点只存必要信息,不多不少
AST(抽象语法树)节点的设计,常犯两个极端:一是过度精简,导致后续解释器需要反复查询上下文;二是过度冗余,把源码、作用域、类型信息全塞进去,让节点膨胀不堪。我遵循“最小完备性”原则:每个节点只存执行其语义所必需的、且无法从父节点或子节点推导出的信息。以IfNode为例,它必须有condition(条件表达式)、consequence(if块)、alternative(else块,可为None)。它不需要存if关键字本身(那是语法结构,不是语义),不需要存line号(错误提示由解析器在构造时注入),更不需要存scope(作用域由解释器在遍历时动态管理)。再看CallNode(函数调用):它必须有callee(被调用者,可能是IdentifierNode或GetPropNode)、arguments(参数列表)。它不需要存function_name(callee的name属性已足够),不需要存arity(参数个数可由len(arguments)得到)。这种设计带来两大好处:一是节点轻量,序列化/调试时打印简洁;二是语义清晰,看到IfNode就知道它只干三件事:求值条件、根据真假走分支、返回对应结果。我在实现return语句时,最初设计了ReturnNode(value, line),后来发现line对解释器毫无用处,删掉后,ReturnNode只剩一个value属性,代码立刻清爽。反观过度设计的例子:曾有个版本的BinaryOpNode存了left_type和right_type,以为能提前做类型检查。结果发现,类型检查必须在运行时(因为变量类型可变),这些字段纯属冗余,还增加了构造节点的负担。所以,判断一个字段该不该存在,就问自己一个问题:“如果删掉它,解释器还能正确执行这个节点的语义吗?”答案是“否”,就留下;答案是“是”,就果断删除。这是用减法换来代码的呼吸感。
4. 实操过程详解:从空文件开始,逐行构建解析器,附关键参数选择依据
现在,我们进入真正的实操环节。我会以一个真实的开发节奏,带你从新建parser.py文件开始,一步步写出能跑通print(1+2*3)的解析器。所有代码均可直接复制运行,我已将依赖降到最低(仅需Python 3.7+,无第三方库)。第一步,定义Token类型枚举:
from enum import Enum class TokenType(Enum): # 单字符token LPAREN = "(" RPAREN = ")" LBRACE = "{" RBRACE = "}" SEMICOLON = ";" COMMA = "," # 二元运算符 PLUS = "+" MINUS = "-" MUL = "*" DIV = "/" # 逻辑运算符 EQ = "==" NEQ = "!=" LT = "<" GT = ">" LTE = "<=" GTE = ">=" # 关键字 IF = "if" ELSE = "else" WHILE = "while" PRINT = "print" RETURN = "return" TRUE = "true" FALSE = "false" # 字面量 NUMBER = "NUMBER" STRING = "STRING" IDENTIFIER = "IDENTIFIER" # 结束符 EOF = "EOF"注意,这里EQ = "=="的赋值,不是随意的。它决定了词法扫描器在遇到==时,会返回TokenType.EQ,而不是两个连续的EQUALtoken。这是为了消除歧义:如果==被拆成两个=,那么a == b就会被解析成EqualNode(EqualNode(Identifier('a'), Identifier('=')), Identifier('b')),完全错误。所以,我们必须在词法层就将==、!=、<=等作为独立token识别。第二步,定义AST基类和具体节点:
class ASTNode: pass class NumberNode(ASTNode): def __init__(self, value: int): self.value = value class BinaryOpNode(ASTNode): def __init__(self, left: ASTNode, operator: TokenType, right: ASTNode): self.left = left self.operator = operator self.right = right class CallNode(ASTNode): def __init__(self, callee: ASTNode, arguments: List[ASTNode]): self.callee = callee self.arguments = arguments class PrintNode(ASTNode): def __init__(self, expression: ASTNode): self.expression = expression这里BinaryOpNode的operator类型是TokenType而非str,是为了类型安全。解释器在visit_BinaryOpNode时,可以直接if node.operator == TokenType.PLUS:,无需字符串比较,IDE也能提供自动补全。第三步,核心解析器类骨架:
class Parser: def __init__(self, tokens: List[Token]): self.tokens = tokens self.pos = 0 @property def current_token(self) -> Token: if self.pos >= len(self.tokens): return Token(TokenType.EOF, "", 0, 0) return self.tokens[self.pos] def advance(self) -> Token: token = self.current_token self.pos += 1 return token def expect(self, token_type: TokenType) -> Token: if self.current_token.type != token_type: raise ParseError( f"Expected {token_type.value}, got {self.current_token.type.value}", self.current_token.line, self.current_token.col ) return self.advance()expect()方法的实现,是整个解析器的“脊柱”。它不做任何分支判断,只做一件事:断言。这个设计强制你在parse_*函数里,必须显式写出每一步的期望,杜绝了隐式逻辑。第四步,最关键的表达式解析——处理运算符优先级。我们采用经典的“递归下降+优先级跳转”模式:
class Parser: # ... 前面的代码 def parse_expression(self) -> ASTNode: return self.parse_assignment() def parse_assignment(self) -> ASTNode: left = self.parse_logical_or() if self.current_token.type == TokenType.EQUAL: self.advance() right = self.parse_assignment() return AssignNode(left, right) return left def parse_logical_or(self) -> ASTNode: left = self.parse_logical_and() while self.current_token.type == TokenType.OR: operator = self.advance().type right = self.parse_logical_and() left = LogicalOpNode(left, operator, right) return left def parse_logical_and(self) -> ASTNode: left = self.parse_equality() while self.current_token.type == TokenType.AND: operator = self.advance().type right = self.parse_equality() left = LogicalOpNode(left, operator, right) return left def parse_equality(self) -> ASTNode: left = self.parse_comparison() while self.current_token.type in [TokenType.EQ, TokenType.NEQ]: operator = self.advance().type right = self.parse_comparison() left = BinaryOpNode(left, operator, right) return left def parse_comparison(self) -> ASTNode: left = self.parse_term() while self.current_token.type in [TokenType.LT, TokenType.GT, TokenType.LTE, TokenType.GTE]: operator = self.advance().type right = self.parse_term() left = BinaryOpNode(left, operator, right) return left def parse_term(self) -> ASTNode: left = self.parse_factor() while self.current_token.type in [TokenType.PLUS, TokenType.MINUS]: operator = self.advance().type right = self.parse_factor() left = BinaryOpNode(left, operator, right) return left def parse_factor(self) -> ASTNode: left = self.parse_unary() while self.current_token.type in [TokenType.MUL, TokenType.DIV]: operator = self.advance().type right = self.parse_unary() left = BinaryOpNode(left, operator, right) return left def parse_unary(self) -> ASTNode: if self.current_token.type in [TokenType.PLUS, TokenType.MINUS]: operator = self.advance().type right = self.parse_unary() return UnaryOpNode(operator, right) return self.parse_primary() def parse_primary(self) -> ASTNode: token = self.current_token if token.type == TokenType.NUMBER: self.advance() return NumberNode(token.value) elif token.type == TokenType.STRING: self.advance() return StringNode(token.value) elif token.type == TokenType.TRUE: self.advance() return BooleanNode(True) elif token.type == TokenType.FALSE: self.advance() return BooleanNode(False) elif token.type == TokenType.IDENTIFIER: self.advance() # 检查是否是函数调用 if self.current_token.type == TokenType.LPAREN: return self.parse_call_expression(token.value) return IdentifierNode(token.value) elif token.type == TokenType.LPAREN: self.advance() expr = self.parse_expression() self.expect(TokenType.RPAREN) return GroupingNode(expr) else: raise ParseError(f"Unexpected token {token.type}", token.line, token.col)这段代码的精妙之处,在于函数调用顺序天然定义了优先级。parse_expression()调用parse_assignment(),后者调用parse_logical_or(),依此类推,最底层的parse_primary()处理原子表达式(数字、标识符、括号)。因此,1 + 2 * 3的解析路径是:parse_expression→parse_assignment→parse_logical_or→parse_logical_and→parse_equality→parse_comparison→parse_term→parse_factor→parse_unary→parse_primary(得到1),然后回到parse_factor,看到*,调用parse_unary得到2,再调用parse_unary得到3,最终组装成Mul(Num(2), Num(3)),再回到parse_term,用Add(Num(1), Mul(...))完成。这个调用栈,就是运算符优先级的具象化。你不需要记住“乘除优先于加减”,你只需要记住“parse_term调用parse_factor,而parse_factor调用parse_unary”,优先级就刻在代码结构里了。这就是为什么我说,递归下降不是一种算法,而是一种思维方式——它把抽象的文法规则,翻译成了程序员最熟悉的函数调用关系。
4.1 从print(1+2*3)到AST:一次完整的解析流程现场记录
让我们用print(1+2*3)这个例子,全程跟踪解析器的每一步动作。假设词法扫描器已将其切分为以下Token流(为简洁,省略line/col):
[PRINT, LPAREN, NUMBER(1), PLUS, NUMBER(2), MUL, NUMBER(3), RPAREN, SEMICOLON]解析开始:
parse_statement()看到PRINT,进入parse_print_statement()parse_print_statement()调用self.expect(TokenType.PRINT)→ 当前是PRINT,pos=0→1- 调用
self.expect(TokenType.LPAREN)→ 当前是LPAREN,pos=1→2 - 调用
self.parse_expression()→ 进入表达式解析parse_expression()→parse_assignment()→parse_logical_or()→parse_logical_and()→parse_equality()→parse_comparison()→parse_term()parse_term()调用parse_factor()→parse_unary()→parse_primary()→ 当前是NUMBER(1),返回NumberNode(1),pos=2→3parse_term()看到PLUS(pos=3),调用parse_factor()→parse_unary()→parse_primary()→ 当前是NUMBER(2),返回NumberNode(2),pos=3→4parse_term()继续,看到MUL(pos=4),调用parse_unary()→parse_primary()→ 当前是NUMBER(3),返回NumberNode(3),pos=4→5parse_factor()组装Mul(NumberNode(2), NumberNode(3)),parse_term()组装Add(NumberNode(1), Mul(...))
parse_print_statement()拿到AddNode,调用self.expect(TokenType.RPAREN)→ 当前是RPAREN,pos=5→6- 调用
self.expect(TokenType.SEMICOLON)→ 当前是SEMICOLON,pos=6→7 - 返回
PrintNode(AddNode(...))
最终AST结构为:
PrintNode └── AddNode ├── NumberNode(1) └── MulNode ├── NumberNode(2) └── NumberNode(3)这个过程没有魔法,全是advance()和expect()的线性推进。我在调试时,会在parse_expression()开头加print(f"[DEBUG] parse_expression at pos {self.pos}, token={self.current_token}"),然后运行,控制台会打印出完整的调用轨迹,像一份解析日志。这种透明性,是理解语言行为的最有效途径。你会发现,1 + 2 * 3之所以先算乘法,不是因为解析器“聪明”,而是因为parse_term()在parse_expression()之后被调用,而parse_term()内部又调用了parse_factor()——优先级,不过是函数调用顺序的副产品。
4.2 工具链整合:如何让解析器与词法扫描器、解释器无缝衔接?
一个孤立的解析器没有价值,它必须嵌入完整的工具链。我们的链条是:源码字符串 → 词法扫描器(Lexer) → Token流 → 解析器(Parser) → AST → 解释器(Interpreter) → 执行结果。三者衔接的关键,在于接口契约的严格定义。Lexer的输出必须是List[Token],每个Token必须有type,value,line,col属性;Parser的输入必须是这个列表,输出必须是ASTNode的实例;Interpreter的输入必须是ASTNode,输出必须是可序列化的值(int,str,bool,None)。我在设计Lexer时,强制它返回List[Token],并在Parser.__init__()里加了类型检查:
def __init__(self, tokens: List[Token]): if not isinstance(tokens, list): raise TypeError("tokens must be a list") if tokens and not hasattr(tokens[0], 'type'): raise TypeError("tokens must be list of Token objects") self.tokens = tokens self.pos = 0这看似多余,实则预防了90%的集成错误。比如,如果Lexer返回的是Iterator[Token],Parser初始化就会直接报错,而不是在parse_expression()里因self.tokens[0]不存在而崩溃。同样,Interpreter的visit_*方法,必须对每个AST节点类型有且仅有一个处理函数,且返回值类型统一。我定义了一个RuntimeValue基类,所有返回值(NumberValue,StringValue,BooleanValue)都继承它,这样visit_PrintNode可以安全地调用self.visit(node.expression),无需担心返回类型混乱。这种“强契约”设计,让每个模块可以独立开发、独立测试。我可以先写Lexer,用固定字符串测试;再写Parser,用手工构造的Token列表测试;最后写Interpreter,用手工构造的AST测试。当三者拼在一起时,只要接口对得上,就能跑通。我在某次重构Lexer时,只改了正则表达式,没动Parser一行代码,整个流程依然正常。这就是良好接口设计带来的红利——它把系统的复杂性,锁在了模块内部,暴露给外部的,只有清晰、稳定的契约。
5. 常见问题与排查技巧实录:那些让我熬夜到凌晨三点的坑,现在都给你标好了
手写解析器的过程,就是不断踩坑、填坑、再踩新坑的循环。我把最典型的五个问题,连同我的排查思路和最终解法,原原本本记录下来。这些问题,90%的新手都会遇到,而且往往卡住超过两小时。
5.1 问题:1 + 2 * 3被解析成(1 + 2) * 3,优先级完全错乱
现象:输入print(1+2*3),输出却是6(即3*2),而不是7。
排查思路:第一反应是parse_term()没调对。我在parse_term()开头加print("in parse_term"),运行后发现它只被调用了一次,说明*根本没触发parse_term()的循环。再看parse_term()的循环条件:while self.current_token.type in [TokenType.MUL, TokenType.DIV]。问题来了:parse_term()是在parse_expression()里被调用的,而parse_expression()调用的是parse_assignment(),后者调用parse_logical_or()……等等,parse_term()的调用链断了!
根因:我错误地把parse_term()放在了parse_expression()的直接调用链里,而实际上,它应该在parse_comparison()之后、parse_logical_and()之前。正确的调用链是:parse_expression()→parse_assignment()→parse_logical_or()→parse_logical_and()→parse_equality()→parse_comparison()→parse_term()→parse_factor()。我漏掉了中间的parse_comparison()。
解法:修正函数调用顺序,确保parse_comparison()调用parse_term(),parse_term()调用parse_factor()。优先级不是靠“记忆”,而是靠“调用栈深度”——越深的函数,处理的运算符优先级越高。
经验:当优先级出错,不要猜,直接在每个parse_*函数开头加print(f"in {func_name}"),看