尧图网站设计 尧图网站设计YAOTU DESIGN
ARTICLE DETAIL

资讯详情

深耕网站设计与一线实操的经验洞察。

解析器二度设计:从正则堆叠到词法分析、Pratt解析与AST

解析器二度设计:从正则堆叠到词法分析、Pratt解析与AST 1. 梅开二度为什么第一版解析器我必须推翻琴语言基础课讲到第7讲这次不搬新语法专门聊聊解析器的一次“梅开二度”设计。琴语言在云藏山鹰代数信息系统里负责公式入口用户把一串代数式文本交进来系统要把它变成能求值、能化简、能求导的内部对象。我第一版解析器拼了两天就上线能用但越用越别扭最后在0.4版本里整体推翻重写才有了现在这版“二度设计”。先说说“二度”这个说法的来历。梅开二度不是同一朵梅花开两次而是同一棵树上长出了第二轮花。琴语言语法没有变变量绑定、函数定义、幂运算、函数调用这些规则都还在变的是解析器底层的编译思路从原来“正则堆叠字符串切分”的路子换成“词法分析器递归下降解析器AST中间表示”的经典结构。这一换后续的代数计算模块才算真正有了立足点。1.1 第一版解析器的三大罪状旧解析器的核心用一个巨型正则表达式做 token 切分大概长这样TOKEN_PATTERN r(?PNUM\d(\.\d)?)|(?PVAR[a-zA-Z_]\w*)|(?POP[\-*/^(),])|(?PSPACE\s)看着简洁但问题全被“简洁”盖住了。第一个问题是中文变量支持不了。云藏山鹰代数系统的用户经常写“变量甲”“系数乙”这种命名旧正则的[a-zA-Z_]直接把中文字符拦在门外用户稍微按直觉命名就报错。第二个问题是正则顺序决定了优先级一旦出现新运算就得往模式串里塞一段新分组前面某个分组一失配整个 token 流全乱。第三个问题是 AST 几乎没有旧解析器解析完就直接求值导致化简、求导这类需要“看树结构”的功能全部无法落地后来只能在求值器里做二次正则修补越补越脏。1.2 从线上数据看必须修的点集中在三处我把旧版本的报错日志拉出来统计过大概分三类括号不匹配导致解析崩溃占比约37%。旧代码遇到 EOF 时如果括号还没闭合直接抛异常退出连行号列号都不给。表达式稍微嵌套深一层就卡住占比约22%。旧递归函数没有层次控制(ab)*(cd)这种两层以上括号就开始慢。函数调用参数顺序错乱占比约18%。像f(12, 3)这种带表达式的实参旧解析器会把12当成两个参数。这些现象指向同一个根源没有把“读字符”和“读结构”分开。所以我下决心做模块化把变形虫一样的解析器拆成 Lexer、Parser、AST 三层。2. 琴语言的语法收敛与 AST 重新设计二度设计的第一步是把琴语言的语法彻底拧紧。以前语法写在文档里解析器实现却在脑子里两边随时会打架。这一版先把语法写成“铁律”解析器只是铁律的翻译。2.1 琴语言的入门语法定调琴语言面向代数信息系统核心诉求是“能让人快速把纸面上的公式敲进机器”。因此语法向数学记法靠拢同时显式区分计算与定义定义 x : 2 * a 3 * b 函数 平均(a, b) : (a b) / 2 求平均值 : 平均(1, 2)定义用于绑定变量函数用于定义函数二者都使用:作为“定义为”符号避免和等号混淆。等号保留为“判断相等”的语义。求导(expr, var)、化简(expr)这类内建高阶函数直接以函数调用形式出现。在语法设计上我有意做了一个取舍不搞“紧凑数学风”不隐含乘法。也就是说2a必须写成2*a。原因是隐式乘法会让词法分析出现大量歧义ab究竟是变量ab还是a*b如果是后者那3sin(x)怎么解释为了避免代数系统里最常见的“无声 bug”我宁愿让用户多打一个星号。2.2 优先级表必须显式声明解析表达式本质是跟优先级和结合性打交道。琴语言把优先级分成七层优先级运算符结合性说明1或者或左结合逻辑或2并且且左结合逻辑与3 ! 左结合比较运算4 -左结合加法减法5* /左结合乘法除法6^右结合幂运算7-(一元)前缀取负^用右结合是为了跟数学惯例一致2^3^2应该解析成2^(3^2)结果是512不是64。很多新手解析器在这里栽跟头我第一版就栽过当时写死了左结合还振振有词“从左到右总没错”直到被用户拿2^3^2打脸。2.3 AST 节点不能一团糨糊AST 是整个解析器和后续代数引擎之间的契约。二度设计里我坚持“节点类型明确字段最小”五个核心节点先定下来NumberNode数字字面量保存原始文本。SymbolNode变量或未知量如x、变量甲。BinaryNode二元运算持有运算符和左右子节点。UnaryNode一元负号。CallNode函数调用持有函数名和参数列表。后来又补充了DefineNode变量定义和FunctionDefNode函数定义放在顶层语句里。所有节点都实现同一个walk()遍历方法后续的化简器、求值器只认这套遍历接口不再各自去翻内部字段。AST 设计的另一个重点是丢失源位置。我给每个节点都附加起始 token 的行列号调试时报错能直接指到“第3行第7列附近”这比第一版的“解析出错”四个字友好太多。3. 实操二度解析器的完整实现流程下面这段是核心实操我直接按当时重构的顺序写。整个过程分四步词法分析、语法分析、错误恢复、语义接驳。3.1 第一步写一个真正的词法分析器词法分析器只做一件事把原始文本变成 token 流。琴语言的 token 类型有这些TOKEN_TYPES { NUMBER, IDENT, 真, 假, 定义, 函数, PLUS, MINUS, STAR, SLASH, CARET, ASSIGN, EQ, NEQ, LT, GT, LE, GE, LPAREN, RPAREN, COMMA, NEWLINE, EOF }扫描主循环并不复杂但有一个关键点中文和 Unicode 字母必须按 Unicode 属性识别而不是靠 ASCII 范围判断。我用的是 Python 的unicodedata.categorydef is_ident_start(ch: str) - bool: if ch _: return True cat unicodedata.category(ch) return cat.startswith(L) # 所有字母字符 def is_ident_part(ch: str) - bool: if ch in (_, $): return True cat unicodedata.category(ch) return cat.startswith(L) or cat.startswith(N) or cat Mn这样“变量甲”“系数_beta”“α_1”都能正常被识别为标识符。每个 token 生成时顺手记下行列坐标dataclass class Token: kind: str value: str row: int col: int扫描数字时要注意代数系统里小数很多但没有必要直接解析成 float因为后续要做高精度有理数计算。我让NumberNode只保存3.14这样的原始字符串真正的数值转换推迟到语义阶段这样就留住了精度空间。3.2 第二步用 Pratt 解析器处理表达式优先级表达式解析我选了 Pratt 解析法也叫优先级攀爬。它比传统的“表达式因子分层”写法更简洁最重要的是优先级表可以单独维护增删运算符不需要重写整个递归结构。核心实现只有两个函数def parse_expr(self, min_prec: int 0): left self.parse_prefix() while True: token self.peek() if token.kind in BINARY_PREC: prec BINARY_PREC[token.kind] if prec min_prec: break self.advance() if token.kind in RIGHT_ASSOC: # 右结合右子树使用当前优先级不用 prec1 right self.parse_expr(prec) else: right self.parse_expr(prec 1) left BinaryNode(token.value, left, right) else: break return leftparse_prefix()负责处理前缀部分数字、变量、括号分组、一元负号、函数调用。def parse_prefix(self): tok self.advance() if tok.kind NUMBER: return NumberNode(tok.value) if tok.kind IDENT: if self.peek().kind LPAREN: return self.parse_call(tok) return SymbolNode(tok.value) if tok.kind MINUS: operand self.parse_expr(8) # 一元负号优先级高于二元幂 return UnaryNode(-, operand) if tok.kind LPAREN: expr self.parse_expr(0) self.expect(RPAREN) return expr raise ParseError(tok.row, tok.col, f无法解析的起始 token: {tok.value})这里有个关键细节一元负号调用parse_expr(8)。我设置的幂优先级是级别6而这里传8意味着负号会先绑定紧邻的幂表达式所以-5^2会解析成-(5^2)符合数学惯例。而5^(-2)里-2作为右操作数会优先被parse_prefix捕获对数正确。调用的解析是 Pratt 的一个亮点。参数列表内继续调用parse_expr所以f(12, 3)中12天然是一个实参不会像第一版那样被拆散。def parse_call(self, func_token: Token): self.expect(LPAREN) args [] while True: args.append(self.parse_expr(0)) if not self.match(COMMA): break self.expect(RPAREN) return CallNode(func_token.value, args)3.3 第三步错误处理要能“救人”不是“杀人”第一版解析器遇到语法错误直接退出后续所有逻辑全部瘫痪。二度设计里我加入了两层容错第一层是前置括号平衡检查。在正式解析之前先把整个输入过一遍统计(和)的数量。如果右括号缺失但已经在当前行尾收尾就自动补齐并给用户提示“检测到括号未闭合已自动补全”。这解决了我线上数据里占比最大的那个问题。第二层是行级同步恢复。当语法错误发生时解析器不是立刻崩溃而是抛出ParseError(row, col, message)然后外层语句循环捕获错误丢弃当前不完整的语句定位到下一个顶层关键字继续解析。这样处理一个长文件时不会因为一行错误丢掉整个文件。def parse_block(self): statements [] while self.peek().kind ! EOF: try: stmt self.parse_statement() statements.append(stmt) except ParseError as e: self.synchronize() errors.append(e) return statementssynchronize()的做法是持续吞掉 token直到看到下一个定义、函数或NEWLINE。虽然会丢掉某些连带语法但保证了解析器不会挂死。实用优先这对 REPL 场景非常友好。3.4 第四步AST 到代数引擎的语义接驳解析器输出 AST 之后云藏山鹰代数信息系统的后续模块才对 AST 做真正的计算。这里采用“不立刻求值”的中间路线把所有语法节点变成内部代数对象再在代数对象上施加规则。例如化简(2 * x 3 * x)解析器只负责生成CallNode(化简, [BinaryNode(, BinaryNode(*, 2, x), BinaryNode(*, 3, x))])之后化简器遍历树发现两个乘法项结构都是BinaryNode(*, Number, SymbolNode)并且右边符号相同就触发同类项合并规则把系数相加。这个“延迟计算”设计是二度解析器能在代数系统里生存的核心。我见过很多解析器项目解析完立刻求值结果一旦要做符号计算就推倒重来。剪枝和整形的坑我踩过一次这次坚决不碰。4. 代数场景里容易翻车的三类语法特例琴语言不是通用编程语言它是代数信息系统的专用入口所以有一些“别的语言不需要照顾、但代数计算必须照顾”的特例。这里单独拿出来讲。4.1 一元负号在和积环境中的歧义3 * -5这种写法数学书里常见但通用语言通常要求写成3 * (-5)。琴语言我选择支持前一种写法。实现前提是当运算符*被扫出来后获得左操作数准备解析右操作数时parse_prefix必须能识别以MINUS开头的表达式把它转成UnaryNode。这个能力在解析负数参数时也很有用求导(f(x), -1)里的-1不需要额外加括号。不过要提醒一元负号不能出现在中缀运算符前面比如3 -- 5必须报错否则两个负号很容易被误读成减号再取负链条一长错误定位会很难受。我的解析器目前只允许前缀负号出现在表达式起始位置或二元运算符右操作数的起始位置。4.2 隐含乘法的“温柔拒绝”前面说了琴语言不支持2x这种隐式乘法。但有不少用户来自数学系习惯这么写。我在实际接入时做了一个友好的过渡词法分析器遇见2x会识别出NUMBER后紧跟IDENT的模式并提示提示琴语言不支持隐含乘法请写 2*x。这不是语法错误是一种“提示级警告”加上解析器不会因此中断。经验是数学 DSL 的语法约束越严格用户前期的学习成本越高但后期出错的概率显著下降。反正骂名晚来不如早来干脆在词法层就明说。4.3 幂运算里的符号绑定规则幂运算是代数系统最容易产生歧义的地方。a^b^c右结合没问题但-a^b也存在两种理解。我按数学惯例选-(a^b)因为负号是对值的操作幂运算的优先级理应高于一元负号。前面parse_prefix传parse_expr(8)就能保证这一点。另一个常见坑是x^2y会被扫描成x ^ 2y而 2y 会被“数字后紧跟标识符”规则拦截。如果没有这个拦截解析器就可能把2y当成一个名为2y的非法标识符报错信息更难懂。宁可一步到位提示用户补乘号也不想把错误留到语义阶段。5. 常见问题与排查技巧实录下面这几条问题都是我实际调试时碰到过的列成速查表方便有相同遭遇的同学直接对照。现象通常原因排查思路解析3-4报错一元负号在parse_prefix里没被处理检查 parse_prefix 是否有 MINUS 分支2^3^2结果是64幂运算被写成左结合确认^在 RIGHT_ASSOC 集合里中文字符变量显示乱码词法分析器只按 ASCII 判断字符改用 Unicode 字母属性扫描标识符括号嵌套十几层就卡顿递归解析器触发了系统递归限制调大sys.setrecursionlimit或把深层嵌套走迭代栈报错总指向文件末尾token 没有记录行列号递归回溯时丢失了位置确保每个 token 都携带 row/colf(12, 3)被拆成三个参数参数列表解析没有调用 parse_expr检查 parse_call 里实参是否为完整表达式除了上面这些排查点我再补三个实测心得。第一个心得lexer 和 parser 坚决不要合并。我见过有人为了“少写一个类”把 token 识别和表达式关联写在一个循环里当时很爽后来加一条语法就改一轮。分开之后改优先级只碰 parser 的优先级表改中文字符支持只碰 lexer 的字符判断互相不牵连。第二个心得AST 节点不要贪多。我第一版设计了十几类节点结果大半没有独立语义纯粹是“为了层级而层级”。二度设计砍回七类核心节点爽快许多。节点多了不是扩展性强是维护负担大。哪怕后面要支持矩阵、积分也可以先复用CallNode和BinaryNode等语法稳定了再添加真正的专用节点。第三个心得必须留一套语法测试集。我把大约三百条琴语言表达式做成回归用例每条都带有期望 AST 或期望报错信息。重构期间这套用例帮我堵住了至少两轮返工。尤其是-5^2、2^3^2、f(12,3)这些边角几乎每次改动都会被误伤。6. 一些实战体会这次把解析器从“能跑的脚本”重构成“工程化的编译器前端”花的力气不少但收益真切。最大的变化不是性能数据好看而是云藏山鹰代数信息系统里的化简器、求导器、有理数计算模块终于愿意跟解析器做正式对接了因为它们能依赖一个稳定的 AST 结构而不是在字符串碎片上做判定。对我来说判断一次重构是否成功的标准很简单改动解析器时其他模块第三天还能正常跑那就是真的拆清楚了。最后再分享一个小技巧给 REPL 写输入缓冲时不要光按行切分要监听括号深度。用户复制一段多行公式进来时如果括号还没闭合就别急着把文本送去解析等右括号齐了再交给词法分析器。这个“括号平衡预检查”花不了半小时但能把在线体验提升一大截。我上线这个功能后报错率肉眼可见地降了一截这也是我这次“梅开二度”里最划算的一笔投资。
返回列表