琴语言解析器第二版重构:用Pratt解析与错误恢复实现高效DSL解析
先给结论琴语言解析器第二版代码量只比第一版多了约40%却能处理第一版三倍的语法形态并且把每个语法错误的定位时间压缩到一毫秒以内。这篇是云藏山鹰代数信息系统里《琴语言基础100讲》的一个特殊专题专门讲解析器为什么“梅开二度”以及第二版到底改了什么。如果你也在写DSL、表达式引擎、公式解析器或者正纠结一个已经能跑的解析器要不要推倒重来这篇值得看完因为里面所有坑都是我亲手踩出来的。琴语言本来只是云藏山鹰这套代数系统内部用来描述公式、方程和变换规则的小脚本语言。第一版解析器从设计到跑通只花了三天当时觉得很顺利。可等真实业务脚本从几十行涨到几千行之后问题一个接一个冒出来。与其继续打补丁我决定从头写第二版。这篇文章会把第一版失败的原因、第二版的技术选型、Pratt解析器的具体实现、错误恢复策略和实测数据全部拆开讲。1. 第一版解析器是怎么把自己逼上绝路的1.1 从一行大括号开始的失控第一版琴语言解析器核心思路朴素到有点天真用一个parse_expression()函数根据当前字符调用不同的分支去组装节点。词法分析没有单独做直接按字符遍历AST节点也用Python字典代替没有固定结构优先级通过递归调用的顺序硬编码在函数里。# 第一版的核心函数后来被删掉的原型 def parse_expr(s, i): if s[i].isdigit() or s[i] .: return parse_number(s, i) if s[i] (: inner, i parse_expr(s, i 1) # 期待一个右括号 return (paren, inner), i 1 if s[i] : left, i parse_expr(s, i 1) right, i parse_expr(s, i) return (add, left, right), i ...这段代码能解析1 2 * 3但靠的是parse_expr里函数调用的嵌套顺序来体现优先级。想加一个^幂运算符就得把所有已经写完的分支重新盘一遍想支持这种双字符运算符又得在for循环里多判一次长度。表面上是“一个函数搞定”实际上每个新功能都在催生下一个新Bug。1.2 五个让我无法继续打补丁的问题我复盘的时候把第一版的问题归纳成五个按严重程度排序。第一是错误定位为零。解析器报SyntaxError: invalid syntax但不告诉我在第几行第几列也没有周围代码上下文。语法检查一个几百行的脚本我只能人肉二分注释定位极其痛苦。第二是优先级严重硬编码。、*、、^四种运算的优先级藏在递归层级里加一个新运算符要在不同层函数之间跳来跳去一旦跳错函数解析结果就变成完全不同的语法树。第三是词法和语法混在一起。数字、标识符、括号、运算符全部在同一个parse_expr函数里靠if判断导致无法单独测试词法也无法做多字符运算符的匹配更别提跳过注释这类基础功能。第四是解析器与求值器强耦合。第一版的解析结果边上边求值遇到let x (a b) * 2这种语句会一边建树一边算值想改成先解析再统一执行牵一发动全身。第五是完全没有错误恢复。一个错误抛出来整个脚本终止。琴语言后续要做编辑器内联提示需要一种“报错后还能继续解析”的机制第一版结构上根本支撑不了。1.3 推倒重来是技术债的必然选择这三个月的使用里第一版的问题越积越多。我试过打补丁把变量名解析抽出来、把数字解析抽出来、给异常加上 token 走向……每次改动后都能解决局部问题但代码复杂度增长比功能增长更快。最终让我下决心的导火索是为了支持矩阵乘法运算符我在优先级链里插了三个位置结果原本正常的1 2 * 3 ^ 2被解析成(1 2) * (3 ^ 2)。那一刻我意识到继续给这套代码打补丁等于在骨质疏松的骨架上继续挂重物。与其这样不如把解析器重写一遍。所谓“梅开二度”不是标新立异而是第一版的架构债务已经还不动了必须彻底重构才能接住琴语言后面的代数运算需求。2. 琴语言的语法边界先定规则再写代码2.1 这套DSL要承接的代数任务写解析器之前先把“琴语言到底要解析哪些语法”写成一份清单比直接动手写代码重要得多。我这次的开端不是打开编辑器而是拿出纸笔列出语法范围。琴语言在云藏山鹰系统里的核心使用场景是三类描述代数表达式、定义变量绑定、调用系统能力比如展开、因式分解、求导。因此第一阶段语法只需要覆盖数值字面量整数、小数、科学计数法如3、3.14、1.5e-3标识符与变量如x、alpha、var_1一元运算符负号-x、正号x二元运算符 - * / ^ 以及比较运算符 ! 括号改变优先级(a b) * c函数调用sin(x)、gcd(12, 18)赋值语句let a 表达式、a : 表达式表达式语句直接写solve(x^2 - 4)让系统执行列表字面量[1, 2, 3]、[x, y]去掉那些暂时不需要的东西没有类定义没有控制流没有lambda。先跑通最小闭环再往里面加模块这是第二版的重要原则——语法范围扩张必须跟着解析器的表驱动架构走。2.2 Token清单把字形和含义分开第二步是把每个语法元素拆成token类型。这一步如果不做后面写词法扫描器时又会回到“按字符判断”的老路。from enum import Enum, auto class TokenType(Enum): NUMBER auto() IDENTIFIER auto() KEYWORD auto() # let, solve, expand 等 OPERATOR auto() # - * / ^ ! LPAREN auto() # ( RPAREN auto() # ) LBRACKET auto() # [ RBRACKET auto() # ] COMMA auto() # , SEMICOLON auto() # ; EOF auto()然后定义token的数据结构每个token都要带start和end这两个值是解析和报错的生命线。我在第一版里没做这个所以报错永远只能给个字符串。第二版里start/end保存的是字符在源码中的偏移位置后面可以换算成行列。2.3 运算符表驱动优先级和结合性不再硬编码琴语言里所有二元运算符的优先级、结合性统一放进一张表。这张表是Pratt解析器可以顺利工作的关键。运算符左绑定力度右绑定力度结合性1010右结合 ! 2021左结合 -3031左结合* / 4041左结合^5050右结合左边的数字是“左绑定力度”left binding power右边是“右绑定力度”right binding power这两个值的含义我后面的章节专门讲。关键是这张表可以通过修改数字灵活调整运算符优先级不需要动解析器主流程。新增一个%取模运算符时只需要在表里加一行MOD, (40, 41)解析逻辑完全不用变。3. 梅开二度的架构选择两阶段加树3.1 处理流水线从字符流到语法树的两次旅行第二版架构把解析过程拆成了两个阶段、一个中间产物。字符流经过词法分析变成token流。token流经过语法分析变成AST。AST作为后续所有操作求值、化简、微分、展开的共享输入。词法阶段只负责把字符切成“词”不关心这些词合在一起是否合法语法阶段只负责按规则把词组成树不负责算值。这样拆开的最直接收益是词法层可以独立测试语法层也可以独立测试。我在写第二版时给词法写了一个小测试用例集跑一轮只需要20毫秒。3.2 词法分析器手写扫描器而不是正则堆叠第二版词法分析器是纯手写循环没有用正则表达式。原因很简单正则表达式在解析数字、标识符这类模式时还好但一旦要处理多字符运算符优先级、错误定位、注释跳过就变得特别绕。手写扫描器的逻辑非常直白class Scanner: def __init__(self, code: str): self.code code self.pos 0 def skip_whitespace(self): while self.pos len(self.code) and self.code[self.pos].isspace(): self.pos 1 def scan_number(self): start self.pos while self.pos len(self.code) and (self.code[self.pos].isdigit() or self.code[self.pos] .): self.pos 1 # 处理科学计数法 1e-3 if self.pos len(self.code) and self.code[self.pos] in eE: self.pos 1 if self.pos len(self.code) and self.code[self.pos] in -: self.pos 1 while self.pos len(self.code) and self.code[self.pos].isdigit(): self.pos 1 return Token(TokenType.NUMBER, self.code[start:self.pos], start, self.pos)多字符运算符的匹配顺序也很关键先匹配再匹配否则一个会被拆成和。我把所有运算符按“只看前两个字符能否构成更长运算符”的逻辑放在一个函数里严格从长到短匹配。3.3 表达式解析的选型Pratt解析比递归下降更合适说到表达式解析大家第一反应往往是递归下降。递归下降本身没问题它也够用但放在琴语言这种以运算符丰富的代数场景里有两个麻烦每个优先级要写一个函数运算符一多函数层数就爆炸新增运算符时修改位置容易出错。Pratt解析器用“绑缚力度”表驱动的方式解决了这两个问题。它不再区分“加减法层”“乘除法层”而是统一一个主循环在循环里根据运算符表决定何时停止解析、何时吞掉右边的表达式。对于分句、块结构、函数体这种语法密度不高的地方我依然用递归下降对于表达式这个核心密集区用Pratt。两种方案不冲突混合才是工程常态。3.4 AST节点的元数据设计让后续阶段有据可查AST节点这次不再用裸字典而是用一个带元数据的数据结构dataclass class Node: kind: str # number / binary / unary / call ... value: object # 具体值或算子名 children: list start: int # token 起始位置 end: int # token 结束位置这个结构让后续的求值器、化简器、语法检查器拿到节点时都能知道这个节点在源码中的位置。第一版没有这些信息报错时只能拿到一个孤零零的节点不知道对应什么代码。第二版里所有错误提示都能定位到精确字节位置这在调试代数表达式时简直救命。4. 核心实现Pratt解析器怎么做到“认出”全部语法4.1 Parser对象的全局状态与推进逻辑解析器设计成一个类维护token流和当前位置。这样状态清晰函数之间传参负担小。核心方法就是那几个peek()看当前tokenadvance()消费tokenmatch(type)判断并消费expect(type)消费并报错。class Parser: def __init__(self, tokens: list): self.tokens tokens self.pos 0 def peek(self): return self.tokens[self.pos] def advance(self): tok self.tokens[self.pos] self.pos 1 return tok def expect(self, type_: TokenType): tok self.peek() if tok.type ! type_: raise ParseError(f期望 {type_.name}但遇到 {tok.value}, tok) return self.advance()这套推进逻辑是解析器的基础。所有语句解析函数都建立在这几个方法之上后面写赋值语句、函数调用、列表都不会偏离这个骨架。4.2 nud与led表达式的两种“启动方式”Pratt解析器里有两个核心概念nudnull denotation和ledleft denotation。前者处理“这个token开头的表达式”比如数字、变量、括号、一元负号后者处理“这个token左边已经有了表达式右边还要再接一个表达式”的情况比如、*、^这些二元运算符。def expression(self, min_bp0): tok self.peek() # nud前缀解析 if tok.type TokenType.NUMBER: tok self.advance() left Node(number, float(tok.value), [], tok.start, tok.end) elif tok.type TokenType.IDENTIFIER: tok self.advance() if self.peek().type TokenType.LPAREN: left self.parse_call(tok) else: left Node(var, tok.value, [], tok.start, tok.end) elif tok.type TokenType.OPERATOR and tok.value -: tok self.advance() operand self.expression(70) # 一元负号绑缚力更高 left Node(neg, None, [operand], tok.start, operand.end) elif tok.type TokenType.LPAREN: self.advance() left self.expression(0) rp self.expect(TokenType.RPAREN) left.end rp.end else: raise ParseError(f意外的 token: {tok.value}, tok) # led中缀解析 while self.peek().type TokenType.OPERATOR: op self.peek() op_str op.value if op_str not in BIND_POWER: break left_bp, right_bp BIND_POWER[op_str] if left_bp min_bp: break self.advance() right self.expression(right_bp) left Node(binary, op_str, [left, right], left.start, right.end) return left这里有个很关键的判断if left_bp min_bp: break。它决定了运算符优先级。比如解析1 2 * 3时先遇到它的left_bp是30大于当前的min_bp0所以吞掉右边。解析右边2 * 3时min_bp变成31再看到*的 left_bp 是40不小于31继续吞。等到1 * 2 3这种场景先吃掉*之后遇到的 left_bp30它小于当前min_bp41于是不会属于*的右操作数而是回到外层循环处理。右结合如^的实现方式是把right_bp设成与left_bp相同的值比如(50, 50)这样当解析2 ^ 3 ^ 4时内部的^可以继续把右边的3 ^ 4吞进来形成右结合树。4.3 分句逻辑赋值语句与表达式语句表达式解析只能处理单个表达式AST真正要表达的是“琴语言脚本由多条语句构成”。第二版的语句解析很简单def parse_statement(self): tok self.peek() if tok.type TokenType.KEYWORD and tok.value let: self.advance() name self.expect(TokenType.IDENTIFIER) self.expect_operator() value self.expression(0) semicolon self.expect(TokenType.SEMICOLON) return Node(let, name.value, [value], tok.start, semicolon.end) else: expr self.expression(0) if self.peek().type TokenType.SEMICOLON: self.advance() return Node(expr, None, [expr], expr.start, expr.end)第一版把赋值判断塞进了表达式解析里导致赋值运算符的优先级和其他二元运算符混在一起经常出现let a b c解析结果不符合预期。第二版把赋值提升到语句级别赋值语义更清晰表达式层级里不需要再放等号。4.4 括号、函数调用与列表字面量扩展三个常见的扩展点在Pratt框架里都是加nud分支括号(的nud就是吃掉左括号递归调用expression(0)然后期望右括号。函数调用标识符的nud里如果后面跟(就进入parse_call循环解析逗号分隔的参数然后期望)。列表字面量[的nud里循环解析元素每个元素之间用逗号分隔遇到]结束。def parse_call(self, id_tok): self.expect(TokenType.LPAREN) args [] while self.peek().type ! TokenType.RPAREN: args.append(self.expression(0)) if self.peek().type TokenType.COMMA: self.advance() rp self.expect(TokenType.RPAREN) return Node(call, id_tok.value, args, id_tok.start, rp.end)这几个扩展点没有互相纠缠全部通过“nud前置规则”完成。想新增一个矩阵字面量就再写一个{的nud分支不影响其他语法。4.5 一个完整例子的AST输出用第二版解析let y 3 * (x 2);生成的AST结构大致如下let ├─ name: y └─ value: binary * ├─ number 3 └─ paren/pending └─ binary ├─ var x └─ number 2我当时把这个AST用JSON打印出来贴进调试工具里第一版完全做不到这种清晰输出因为节点没有固定结构也没有start/end。有了AST之后后续各种遍历工具都顺着children递归即可写起来非常轻松。5. 错误处理解析器给人用的关键5.1 Token定位把行列号做进异常解析器最容易忽略的工作是错误处理。我第一版直接抛SyntaxError: invalid syntax第二版从头就建立了一个带位置的异常类型class ParseError(Exception): def __init__(self, message, token): self.message message self.token token # 根据 token.start 计算行号和列号 super().__init__(f{message} at offset {token.start})真正给用户看的时候需要把字节偏移换算成行列号。做法很简单预先把源码按行分好根据token.start找到所在行和偏移然后拼接提示信息。5.2 单错误恢复与“跳到下一分号”编辑器内联提示需要一个能力一段脚本有错误时解析器不能直接崩溃要至少把所有代码都过一遍尽量多报几个问题。第二版采用的是一个经典且实用的策略——panic mode恢复遇到错误记录一条错误信息然后跳过所有token直到遇到分号或句末再继续从下一句开始解析。def parse_script(self): errors [] while self.peek().type ! TokenType.EOF: try: self.parse_statement() except ParseError as exc: errors.append(exc) self.recover_to_semicolon() return errorsrecover_to_semicolon的实现是循环advance()直到遇到分号或EOF为止。这个策略不完美但胜在简单可控。多错误恢复算法比如同步集合在琴语言这种语句分隔符明确的语言里收益不大单错误恢复已经能把一个几百行脚本里的独立语法错误都挖出来。5.3 提示语设计不说“语法错误”说“期望什么”用户最怕看到的是干巴巴的“语法错误”。第二版设计错误信息时坚持一个原则必须说明“期望什么”和“在哪里遇到什么”。我做一个表格把这些常见错误对应的提示语写死错误场景提示语操作符后面没有操作数期望一个表达式但遇到)括号没闭合期望)但遇到 EOF赋值语句缺少等号期望但遇到函数调用参数缺失期望表达式但遇到,结束位置多了个}意外的 token:}比如用户写let a (1 2;第二版会报“第2行第5列期望)但遇到;”把源码行、列、期望内容都写在提示里。这种提示足够支持编辑器定位到具体字符。6. 实测、性能与工程化心得6.1 大脚本压测对比解耦带来的收益我用同一台开发机对第一版和第二版跑了一组1000行琴语言脚本内容包含大量变量赋值、混合运算、函数调用和列表字面量。数据如下指标第一版第二版解析1000行脚本耗时约320ms约110ms错误定位精度只能到语句粒度精确到行、列和字节偏移新增一个运算符的成本修改递归层级容易改错加一行绑定力度表能否单独测试词法否是错误恢复能力无一错就停可以继续解析后续语句差距来源不是某个魔法优化主要是第一版解析与求值耦合很多计算在解析时反复做第二版把求值完全剥离解析只负责建树速度自然快。另外一个因素是 token 级别缓存Scanner 生成的 token 不会重复遍历源码。6.2 递归深度与极端输入Pratt解析器和递归下降一样面对超深嵌套表达式时可能触碰Python递归上限。我实测用((((...))))依次包300层没问题包到2000层时抛出了RecursionError。琴语言正常使用场景不会出现这么极端的嵌套但为了稳妥我在解析器外层设置了嵌套深度计数器超过1000层直接报一个更友好的错误而不是让Python的递归异常裸奔。其实还有一个更长的思维如果未来真要支持极端深度表达式可以把递归改成显式栈遍历但那会牺牲代码可读性。现阶段量级下加深度保护就够了。6.3 解析器与求值器分离后的下游收益第二版架构最大的红利是下游工具不再关心语法细节。求值器只遍历AST节点看到binary 就递归求值左右孩子化简器只做模式匹配看到binary *且一边是number 1时可以把它替换成另一边。这些都建立在AST节点格式统一的基础上。举个例子琴语言里有一个展开表达式的能力我需要把(x1)^2变成x^2 2x 1。第一版这个功能基本没法写因为解析器边解析边求值只能得到结果拿不到中间过程。第二版拿到AST后可以自由遍历、改写、再遍历展开算法的实现成本大大降低。6.4 后续扩展增量解析与编辑器提示解析器重构完成后我又给琴语言做了一个非常基础的编辑器提示输入过程中每当用户敲完一个分号就对当前脚本做一次解析把错误列表显示为波浪线。这种场景非常依赖错误恢复能力如果每次都要停止在第一个错误用户根本没法往下写。第二版因为有了完整的 token 位置信息和 panic mode 恢复这个编辑器提示只花了一个下午就接上了。增量解析是下一步的方向目前是每次全量重扫。好在1000行脚本全量解析才110ms编辑器里暂时够用。真到上万行的时候再考虑按分号切块做增量词法缓存。7. 写在最后梅开二度之后我重新理解的解析器设计第二版做完之后回头再看第一版最大的收获不是“我换了个算法”而是认识到解析器本质上是一个翻译器它把字符翻译成结构把结构暴露给所有下游工具。第一版的错误是把翻译和最终计算混在了一起才导致各种结构信息全部丢失。如果让我给准备自己写解析器的人一句话先画语法范围再写 token 清单再设计 AST 节点最后才写解析核心。顺序一旦乱后面全都是补丁套补丁。还有一个小建议解析器的测试用例一定要留一份固定的解析快照任何一次改动代码后跑一遍快照对比AST能快速发现优先级变动、括号错位这类无声病。琴语言的语法范围还会继续扩大但解析器骨架已经稳定下来。后面加矩阵操作、加自定义函数都不需要再动解析核心了。对一个语言项目来说解析器修好了整个系统才算真正止住了血。