☰
手写JSON parser:编译原理词法分析与递归下降实战
2026/10/27 12:59:56 网站建设 项目流程

1. 为什么我拿 JSON parser 当编译原理的第一个练手项目

JSON parser 大概是我见过性价比最高的编译原理练手项目。它足够小,一天之内就能从零写完;又足够完整,词法分析、句法分析、AST 构建、错误恢复这几件事一个不落,全都能摸一遍。很多人学完编译原理课程之后,脑子里装了一堆 LL(1)、FIRST 集、消除左递归,但真要动手写一个能跑的东西,反而不知道从哪下手。问题不在理论,在于没有一个规模合适的载体去承接这些概念。JSON 就是那个载体。

我最早写这个东西是为了搞清楚一件事:Python 里json.loads那一行调用,底下到底发生了什么。后来写顺了,又拿它做过几件挺实用的事,比如给一个配置文件系统加自定义语法校验、给日志流做一个流式的增量解析器、给某个内部工具做 JSON 与另一种结构化格式的互转。所以这篇内容不只是“教学”,它真的能落地。

这篇适合谁看?如果你会写 Python 基础语法,知道什么是类、什么是递归,但没写过解析器,那这篇是给你准备的;如果你写过 parser 但只会用现成的解析库,想知道手写的边界在哪、坑在哪,那也合适。全文的技术栈就是纯 Python 标准库,不需要任何第三方依赖,代码可以直接抄下去跑。我会把词法分析和句法分析分成两大块,每一块都给出能直接运行的实现,并且把我在实际写的过程中踩过的坑一条一条摊开讲。

先说清楚一件事:手写 JSON parser 在生产环境里的价值不在于替代标准库。标准库那套是 C 实现,性能差着一两个数量级。它的价值在于——你从此以后再看任何一门语言的编译器前端,都不会觉得那是黑魔法。

2. 先把语法吃透:从 RFC 文本到可执行的规则

2.1 用 EBNF 重写一遍 JSON 语法

动手写代码之前,一定要把语法用形式化的方式写出来。这不是学术洁癖,是因为你后面写parse_value的时候,函数的分支结构应该和文法规则一一对应。规则理不清,代码必然是 if-else 乱炖。

JSON 的完整文法用 EBNF 写出来是这样:

json := value value := object | array | string | number | 'true' | 'false' | 'null' object := '{' (pair (',' pair)*)? '}' pair := string ':' value array := '[' (value (',' value)*)? ']' string := '"' (char | escape)* '"' number := '-'? int frac? exp? int := '0' | [1-9] [0-9]* frac := '.' [0-9]+ exp := [eE] [+-]? [0-9]+

这份文法有三个特点值得注意。第一,它是递归的,value里套object,object里又套value,这就决定了递归下降是最自然的实现方式。第二,它没有左递归,所有递归都发生在“括号内部”,所以不需要做消除左递归的预处理,省掉了教科书里最烦人的一步。第三,它的谓词足够清晰,看到{就知道要进 object,看到[就进 array,看到"就进 string,一个字符的向前看(lookahead)就够用了,属于标准的 LL(1) 文法。

为什么要强调 LL(1)?因为只要文法满足这个条件,你写出来的递归下降解析器就是确定性的,不需要回溯,不需要试错,每个 token 看一眼就知道往哪走。这个性质直接决定了后面代码的简洁度。我见过有人写 JSON parser 写成“先试着 parse 成 object,失败了再回滚去试 array”,那就是没把文法看清楚。

2.2 词法单元的设计:Token 该带哪些信息

词法分析的产物是一串 Token。Token 这个数据结构看着简单,设计得好不好直接影响后面调试的痛苦程度。我早期的版本只存了(type, value)两个字段,结果一出错就只能打印“解析失败”,完全不知道错在第几行第几列。后来加上位置信息,调试效率立刻上了一个台阶。

我的做法是每个 Token 至少带四个字段:类型、值、行号、列号。类型用枚举,值保留原始语义(字符串就是 str,数字就是 int 或 float),行列号用来在报错时精确定位。

字段类型作用是否可省
typeTokenType 枚举驱动 parser 的分支判断不可省
valueobject承载字面量的实际值不可省
lineint错误定位、IDE 高亮强烈建议保留
colint同上,列级精度强烈建议保留

Token 类型我定义为下面这些。注意结构符号(括号、冒号、逗号)我也是单独建类型,而不是合并成一个PUNCT,原因是 parser 里expect的时候可以精确匹配,报错信息也能说“期望:但得到,”,而不是含糊的“标点错误”。

import enum class TokenType(enum.Enum): LBRACE = "LBRACE" # { RBRACE = "RBRACE" # } LBRACKET = "LBRACKET" # [ RBRACKET = "RBRACKET" # ] COLON = "COLON" # : COMMA = "COMMA" # , STRING = "STRING" NUMBER = "NUMBER" TRUE = "TRUE" FALSE = "FALSE" NULL = "NULL" EOF = "EOF"

EOF这个类型非常关键。很多人写 tokenizer 的时候忘了它在末尾补一个结束标记,结果 parser 在读取最后一个值之后还要去peek()下一个 token,直接索引越界。补上 EOF 之后,parser 的收尾逻辑就变成“parse 完一个 value 之后,expect(EOF)”,干净利落,还能顺带捕获“{"a":1} trailing garbage”这种错误。

2.3 有限自动机:把字符流切成 Token 的心智模型

词法分析器的本质是一个确定性有限自动机(DFA)。虽然实际写代码的时候我们不会真的去画状态转移图再转成表驱动,但脑子里要有这张图,否则处理边界条件的时候会漏。

以数字为例,它的状态转移是这样的:起始状态遇到-进入“符号已读”,遇到数字进入“整数部分”,整数部分遇到.进入“小数部分”,遇到e/E进入“指数部分”。每个状态都要明确两件事:接受状态(此时可以结束并产出一个 token)和转移条件(读到什么字符往下走)。JSON 的数字有个坑:0之后不能再跟数字,1.后面必须至少有一位,这些都属于状态机的约束,不是“容错处理”。

字符串的状态更简单但更繁琐:起始态读"进入“字符串体内”,体内遇到\进入“转义态”,转义态根据下一个字符决定是普通转义还是\u四位十六进制。这个自动机是线性的,没有嵌套,很好写。

注意:字符串的转义处理我建议单独抽成一个函数,不要塞在主循环里。主循环保持“读一个字符 → 判断大类 → 分派给专门的扫描函数”这个结构,否则主循环会长到没法读。

先把文法想清楚,再动手。这一步花的半小时,能省掉后面三小时的 debug。

3. 词法分析器实现:手写 scanner 的每一处细节

3.1 基础骨架:游标、位置追踪与 peek

我先定义一个Lexer类,内部维护四个状态:源文本、当前位置、当前行、当前列。所有扫描动作都通过peek和advance两个原子操作完成,这样位置追踪就集中在一个地方,不会漏更新。

class JsonSyntaxError(Exception): def __init__(self, msg, line, col): super().__init__(f"第 {line} 行第 {col} 列: {msg}") self.line = line self.col = col class Lexer: def __init__(self, text: str): self.text = text self.pos = 0 self.line = 1 self.col = 1 self.n = len(text) def peek(self, offset: int = 0) -> str: i = self.pos + offset return self.text[i] if i < self.n else "" def advance(self) -> str: ch = self.text[self.pos] self.pos += 1 if ch == "\n": self.line += 1 self.col = 1 else: self.col += 1 return ch

这里有个细节:peek越界时返回空字符串""而不是抛异常。这看起来是小事,但它让后面的判断可以写成if self.peek() == '"'这种自然形式,不用每处都加边界检查。空字符串和任何单字符比较都不相等,逻辑天然正确。

还有一个更隐蔽的点:advance里对\n的处理。如果把“跳过空白”和“更新行号”拆成两套逻辑,很容易出现“跳过空白时没更新行号”的 bug,导致报错的行号全部偏移。我的做法是把行号更新绑死在advance里,只要字符是被advance消费掉的,位置就一定准确,从根上杜绝这类问题。

3.2 主循环:一个大 while 加六个分支

tokenize的骨架非常朴素:循环、跳空白、读一个字符、分派、产出 token、重复,直到读完,最后补 EOF。

def tokenize(self) -> list[Token]: tokens = [] while True: self._skip_whitespace() if self.pos >= self.n: tokens.append(Token(TokenType.EOF, None, self.line, self.col)) return tokens line, col = self.line, self.col ch = self.peek() if ch == "{": self.advance(); tokens.append(Token(TokenType.LBRACE, ch, line, col)) elif ch == "}": self.advance(); tokens.append(Token(TokenType.RBRACE, ch, line, col)) elif ch == "[": self.advance(); tokens.append(Token(TokenType.LBRACKET, ch, line, col)) elif ch == "]": self.advance(); tokens.append(Token(TokenType.RBRACKET, ch, line, col)) elif ch == ":": self.advance(); tokens.append(Token(TokenType.COLON, ch, line, col)) elif ch == ",": self.advance(); tokens.append(Token(TokenType.COMMA, ch, line, col)) elif ch == '"': tokens.append(self._scan_string()) elif ch == "-" or ch.isdigit(): tokens.append(self._scan_number()) elif ch.isalpha(): tokens.append(self._scan_literal()) else: raise JsonSyntaxError(f"无法识别的字符 {ch!r}", line, col)
_skip_whitespace只认四个字符:空格、制表、换行、回车。这是 JSON 规范里的定义,不要图省事用str.isspace(),那会把各种奇奇怪怪的 Unicode 空白也吃掉,虽然多数情况下不出错,但严格来说扩大了可接受输入的范围。
def _skip_whitespace(self): while self.pos < self.n and self.peek() in " \t\n\r": self.advance()

3.3 字符串扫描:转义序列才是最花时间的部分

字符串扫描的逻辑主干很短,麻烦全在转义上。我把它拆成三层:主循环负责吃普通字符,遇到\交给转义分派,遇到\u再交给十六进制处理。

_ESCAPE_MAP = { '"': '"', "\\": "\\", "/": "/", "b": "\b", "f": "\f", "n": "\n", "r": "\r", "t": "\t", } def _scan_string(self) -> Token: line, col = self.line, self.col self.advance() # 吃掉开头的引号 buf = [] while True: if self.pos >= self.n: raise JsonSyntaxError("字符串未闭合", line, col) ch = self.peek() if ch == '"': self.advance() return Token(TokenType.STRING, "".join(buf), line, col) if ch == "\\": self.advance() esc = self.peek() if esc == "u": self.advance() buf.append(self._scan_unicode_escape(line, col)) elif esc in _ESCAPE_MAP: self.advance() buf.append(_ESCAPE_MAP[esc]) else: raise JsonSyntaxError(f"非法转义 \\{esc}", self.line, self.col) continue if ord(ch) < 0x20: raise JsonSyntaxError( f"字符串中出现未转义的控制字符 U+{ord(ch):04X}", self.line, self.col, ) buf.append(ch) self.advance()

\u的坑比想象中多。第一,它后面必须正好四位十六进制,少一位或者出现非十六进制字符都要报错。第二,JSON 允许用两个\u表示一个超出 BMP 的字符(代理对),比如 emoji。如果你不做合并,chr(0xD83D) + chr(0xDE00)会得到两个孤立的代理码点,在 Python 里能存但打印出来是乱码或者报编码错误。

def _scan_unicode_escape(self, sl, sc) -> str: hex4 = self.text[self.pos:self.pos + 4] if len(hex4) < 4 or any(c not in "0123456789abcdefABCDEF" for c in hex4): raise JsonSyntaxError("\\u 后必须跟 4 位十六进制", self.line, self.col) for _ in range(4): self.advance() code = int(hex4, 16) # 高代理,看看后面是不是低代理 if 0xD800 <= code <= 0xDBFF: if self.peek() == "\\" and self.peek(1) == "u": self.advance(); self.advance() hex4b = self.text[self.pos:self.pos + 4] if len(hex4b) == 4 and all(c in "0123456789abcdefABCDEF" for c in hex4b): low = int(hex4b, 16) if 0xDC00 <= low <= 0xDFFF: for _ in range(4): self.advance() code = 0x10000 + ((code - 0xD800) << 10) + (low - 0xDC00) return chr(code) raise JsonSyntaxError("孤立的代理项", sl, sc) if 0xDC00 <= code <= 0xDFFF: raise JsonSyntaxError("孤立的代理项", sl, sc) return chr(code)

这段代码我第一次写的时候没加后面的低代理检查,结果一个畸形输入把整个解析器搞崩了——抛的是UnicodeEncodeError而不是我自己的语法错误,调用方完全没法区分“输入错误”和“程序 bug”。加上之后,所有非法输入都统一走JsonSyntaxError,接口就干净了。

提示:如果你的场景不要求严格兼容,代理对的合并可以简化。但只要涉及用户输入的网络数据处理,建议老实按规范做,畸形代理对是很多安全问题的源头之一。

3.4 数字扫描:用状态约束代替事后校验

数字是 JSON 里最容易写错的部分。看几个例子就明白了:01非法(前导零),1.非法(小数点后必须有数字),.5非法(整数部分必须有),1e非法,-单独也非法。这些约束如果靠“先扫出一串字符再用正则校验”,会导致错误定位很不精确;正确做法是在扫描过程中就用状态约束住。

def _scan_number(self) -> Token: line, col = self.line, self.col start = self.pos if self.peek() == "-": self.advance() # 整数部分 if self.peek() == "0": self.advance() if self.peek().isdigit() and self.peek() in "0123456789": raise JsonSyntaxError("数字不允许前导零", self.line, self.col) elif self.peek() in "123456789": while self.peek() and self.peek() in "0123456789": self.advance() else: raise JsonSyntaxError("'-' 后必须跟数字", self.line, self.col) is_float = False # 小数部分 if self.peek() == ".": is_float = True self.advance() if not (self.peek() and self.peek() in "0123456789"): raise JsonSyntaxError("小数点后必须有数字", self.line, self.col) while self.peek() and self.peek() in "0123456789": self.advance() # 指数部分 if self.peek() in ("e", "E"): is_float = True self.advance() if self.peek() in ("+", "-"): self.advance() if not (self.peek() and self.peek() in "0123456789"): raise JsonSyntaxError("指数部分必须有数字", self.line, self.col) while self.peek() and self.peek() in "0123456789": self.advance() raw = self.text[start:self.pos] value = float(raw) if is_float else int(raw) return Token(TokenType.NUMBER, value, line, col)

有个设计选择值得说:是先转成 Python 数值再存进 Token,还是先存原始字符串,到 parser 里再转?我选了前者,理由是 parser 不需要关心字面量的形态,拿到就能用,代码更集中。代价是如果需要保留原始精度(比如超大整数、金融场景的定点小数),就得改成存字符串。实际项目中我确实改过一次,把一个配置解析器的数字改成延迟转换,因为用户会写超出 int64 的 ID,直接转 float 会丢精度。

3.5 字面量与其他零碎

true/false/null三个字面量的扫描逻辑一样:读一串字母,然后查表比对。不要用startswith一股脑匹配,因为truex这种输入应该报错而不是被截断成true再加一个非法 token。

_LITERALS = { "true": (TokenType.TRUE, True), "false": (TokenType.FALSE, False), "null": (TokenType.NULL, None), } def _scan_literal(self) -> Token: line, col = self.line, self.col start = self.pos while self.peek() and self.peek().isalpha(): self.advance() word = self.text[start:self.pos] if word not in _LITERALS: raise JsonSyntaxError(f"未知字面量 {word!r}", line, col) ttype, value = _LITERALS[word] return Token(ttype, value, line, col)

isalpha()这里也有同样的 Unicode 问题,'a'.isalpha()是 True。不过因为后面有word not in _LITERALS兜底,非法字母会落到报错分支,结果是对的,只是错误信息里会出现奇怪的字符。可以接受。

到这里词法分析器就完整了。跑一下Lexer('{"a": [1, 2.5, true, null]}').tokenize(),能拿到一串带位置信息的 token,这一层就算过了。

4. 句法分析器:递归下降的骨架与返回值设计

4.1 为什么 JSON 用递归下降是最优解

parser 的实现路线有好几条:递归下降、表驱动的 LL(1)、LR 系列、组合子(parser combinator)。JSON 这个场景下我毫不犹豫选递归下降,理由有三条。

第一,文法和代码一一对应。parse_object对应object产生式,parse_array对应array产生式,读文法就能读懂代码,读代码就能验证文法,双向可维护。

第二,不需要回溯。前面说过 JSON 是 LL(1),每个非终结符看一个 token 就能决定走哪条分支,递归下降的最坏情况(指数级回溯)在这儿根本不会发生,性能是线性的。

第三,错误信息好写。表驱动的解析器出错时你只知道“状态机上某个格子是空的”,要转成人话还得反查表。递归下降出错的位置就是代码出错的位置,直接把期望值和实际值拼进异常消息就行。

那什么时候不该用递归下降?比如文法有大量公共前缀、需要多 token 向前看、或者需要容错解析(IDE 那种边输入边解析的场景),这时候要么改写文法提取左公因子,要么上组合子加回溯。JSON 不在这个范围里。

4.2 非终结符到函数的映射关系

我把每个产生式对应成一个方法,映射关系列成表,写代码的时候照着填:

产生式方法输入起点返回类型
valueparse_value任意 tokenPython 对象
objectparse_object{dict
arrayparse_array[list
pair内联在 parse_object 里STRING无(写入 dict)
string由词法层处理—str
number由词法层处理—int/float

pair我没有单独抽方法,因为它只被parse_object调用,且逻辑就是“键、冒号、值”三步,抽出来反而增加跳转成本。这种“产生式是否独立成方法”的判断,标准是:它有没有被多处复用,以及它的逻辑是否超过十行。两个条件都不满足就内联。

Parser 类本身只需要两个状态:token 列表和一个下标。

class Parser: def __init__(self, tokens): self.tokens = tokens self.i = 0 def _peek(self) -> Token: return self.tokens[self.i] def _advance(self) -> Token: tok = self.tokens[self.i] self.i += 1 return tok def _expect(self, ttype: TokenType) -> Token: tok = self._peek() if tok.type is not ttype: raise JsonSyntaxError( f"期望 {ttype.name} 但得到 {tok.type.name}" f"(值 {tok.value!r})", tok.line, tok.col, ) return self._advance()

_expect是 parser 里最高频的辅助函数,它的错误消息设计值得花点心思。只写“期望 RBRACE”没用,使用者不知道 RBRACE 是什么。我的写法是同时给出期望的类型名、实际类型名和实际值,这样期望 COLON 但得到 RBRACE(值 '}')一眼就能看懂问题在哪。

4.3 核心三个函数:value、object、array

parse_value是分派中心,它看当前 token 类型决定往哪走。注意这里不需要循环,也不需要回溯,就是纯分派。

def parse_value(self): tok = self._peek() t = tok.type if t is TokenType.LBRACE: return self.parse_object() if t is TokenType.LBRACKET: return self.parse_array() if t is TokenType.STRING: return self._advance().value if t is TokenType.NUMBER: return self._advance().value if t is TokenType.TRUE: return self._advance().value if t is TokenType.FALSE: return self._advance().value if t is TokenType.NULL: return self._advance().value raise JsonSyntaxError( f"此处不应出现 {t.name}(值 {tok.value!r})", tok.line, tok.col )

parse_object要处理空对象这个特殊情况。很多人写的时候用“循环 + 判断下一个是不是}”的写法,结果{}能过但{"a":1,}也悄悄过了——因为逗号之后又判断了一次}。我的写法是先单独处理空对象,然后进入“键值对 + 可选逗号”的循环,逗号后面必须强制跟一个键,尾逗号自然就报错了。

def parse_object(self): self._expect(TokenType.LBRACE) obj = {} if self._peek().type is TokenType.RBRACE: self._advance() return obj while True: key_tok = self._expect(TokenType.STRING) self._expect(TokenType.COLON) value = self.parse_value() obj[key_tok.value] = value if self._peek().type is TokenType.COMMA: self._advance() continue break self._expect(TokenType.RBRACE) return obj

parse_array的结构完全对称,只是没有键和冒号。

def parse_array(self): self._expect(TokenType.LBRACKET) arr = [] if self._peek().type is TokenType.RBRACKET: self._advance() return arr while True: arr.append(self.parse_value()) if self._peek().type is TokenType.COMMA: self._advance() continue break self._expect(TokenType.RBRACKET) return arr

顶层入口再包一层,负责校验“解析完一个值之后必须到结尾”。

def parse(self): value = self.parse_value() tok = self._peek() if tok.type is not TokenType.EOF: raise JsonSyntaxError( f"内容结束后出现多余字符 {tok.value!r}", tok.line, tok.col ) return value

4.4 关于重复键与递归深度,规范没说的那些事

有两个行为 JSON 规范没规定死,但实际使用中一定会遇到,必须提前决定。

重复键怎么办?规范说“名称应该唯一”,但没说重复了必须报错。我见过三种实现:后者覆盖前者、前者优先、收集成数组。Python 标准库和 JavaScript 都是后者覆盖前者,我的实现跟随这个行为。如果业务上需要检测重复(比如配置合并场景里重复键往往是人为失误),可以在obj[key] = value之前加一句if key in obj: raise ...。这个开关值得做成参数暴露出去。

嵌套太深怎么办?递归下降的天然缺陷是递归深度和嵌套层数成正比。Python 默认递归上限是 1000 左右,也就是说大约 300 层嵌套的 JSON 就会触发RecursionError。这不是语法错误,是运行时错误,异常类型都不一样,调用方很难处理。

我有两个处理方式。轻量的做法是在入口处sys.setrecursionlimit(10000),但这是全局副作用,改多了容易和其他库打架。稳妥的做法是在parse_value里维护一个深度计数器,超过阈值主动抛自己的异常:

MAX_DEPTH = 200 def parse_value(self, depth: int = 0): if depth > MAX_DEPTH: tok = self._peek() raise JsonSyntaxError( f"嵌套层级超过上限 {MAX_DEPTH}", tok.line, tok.col ) ... # 递归调用时传 depth + 1

注意:如果你解析的是不受信任的外部输入,深度限制不是可选项而是必选项。构造一个深度几万的嵌套数组,几 KB 的输入就能让对方进程崩掉,这是最省成本的攻击方式之一。

5. 组装、对拍测试与性能实测

5.1 三行代码组装出完整解析器

把词法和句法两层接起来,对外只暴露一个函数。

def loads(text: str): tokens = Lexer(text).tokenize() return Parser(tokens).parse()

这种“先全量分词,再全量解析”的两段式结构有一个明显缺点:内存里同时存在源文本、完整 token 列表、最终结果三份数据,解析大文件时内存峰值会很高。改进方向是惰性词法——把tokenize改成生成器,parser 通过一个单 token 的缓冲区按需拉取。

class StreamParser(Parser): def __init__(self, lexer): self._lex = lexer.tokenize() # 生成器 self._buf = [next(self._lex)] # 预读一个 self.i = 0 def _peek(self): return self._buf[0] def _advance(self): tok = self._buf[0] self._buf[0] = next(self._lex, Token(TokenType.EOF, None, 0, 0)) return tok

改完之后内存占用从 O(n) 降到 O(嵌套深度),代价是每次_advance多一点函数调用开销。实测下来解析 10MB 文件的峰值内存能从 200MB 级别降到 30MB 级别,非常值得。

5.2 用对拍法建立信心

自己写的 parser 最大的风险是“看起来对”。我用的方法是拿标准库当参照物,做差分测试(对拍):同一批输入,两个实现的结果必须完全一致;同一批非法输入,两个实现必须都报错。

import json as stdjson VALID = [ 'null', 'true', 'false', '0', '-0', '123', '-123', '1.5', '-1.5e10', '1E+2', '1e-2', '""', '"hello"', '"a\\nb"', '"\\u4e2d\\u6587"', '[]', '[1,2,3]', '[[[]]]', '[1, "a", true, null, {"k": 1}]', '{}', '{"a": 1}', '{"a": {"b": [1, 2, {"c": null}]}}', ' {"a" : 1} ', ] INVALID = [ '', ' ', '{', '[', '"abc', '{"a"}', '{"a":}', '{"a":1,}', '[1,]', '[1,,2]', '01', '1.', '.5', '1e', '-', 'tru', 'nulll', '{"a":1}x', '{"a" 1}', "'single'", '{"a": 1} {"b": 2}', ] for s in VALID: mine, ref = loads(s), stdjson.loads(s) assert mine == ref and type(mine) is type(ref), f"不一致: {s!r}" for s in INVALID: ok_mine = _try(loads, s) # 内部吞异常返回 None ok_ref = _try(stdjson.loads, s) assert not ok_mine and not ok_ref, f"非法输入未报错: {s!r}"

注意type(mine) is type(ref)这个断言。只比较值是不够的,1和1.0在 Python 里==成立,但语义完全不同——前者应该来自整数字面量,后者应该来自带小数点或指数的字面量。我第一次跑对拍就是漏了这个,结果1e2被解析成 int 也没发现。

还有个小差异值得记录:Python 标准库默认接受NaN、Infinity、-Infinity,这是它自己的扩展,不是标准 JSON。做对拍的时候这几个要排除掉,否则会误判。

5.3 三个版本的性能对比

我在同一台机器上测了三组输入(小对象、1MB 数组、深度嵌套),跑 100 次取平均,量级大致如下。数字会随机器不同浮动,看倍数关系更有意义。

实现小对象(≈50B)1MB 扁平数组深度 100 嵌套峰值内存
标准库 json.loads基准 1x基准 1x基准 1x基准
本文两段式版本约 60x约 80x约 70x高
本文惰性版本约 65x约 85x约 75x低约 6 倍

慢一两个数量级是必然的,纯 Python 的逐字符循环和 C 实现的差距就在这儿。但有两个优化点收益不错,值得写进来:

第一,字符串拼接用list.append+"".join,不要用s += ch。后者在 CPython 里虽然有小字符串优化,但在长字符串上仍然是二次复杂度。

第二,结构符号用一个预建的字典查表,别用if/elif长链。上面主循环六个分支其实可以压成一个dict映射,实测在主循环密集调用时能快 10% 左右。

_SINGLE_CHAR_TOKENS = { "{": TokenType.LBRACE, "}": TokenType.RBRACE, "[": TokenType.LBRACKET, "]": TokenType.RBRACKET, ":": TokenType.COLON, ",": TokenType.COMMA, }

代价是可读性下降一点。如果你是用它来学习编译原理,我建议先用if/elif保证可读,等真觉得慢了再改。

6. 踩坑实录:那些文档里不写的问题

6.1 错误信息定位,比错误本身更重要

写解析器最容易犯的错是把错误信息写成给作者看的,而不是给使用者看的。我早期版本的报错长这样:KeyError: RBRACE、IndexError: list index out of range。调用方拿到之后完全不知道怎么办。

后来我给自己定了个规矩:每一处raise都必须回答三个问题——在哪、期望什么、实际得到什么。比如:

第 3 行第 14 列: 期望 COLON 但得到 RBRACE(值 '}')

这句话拿出来,使用者立刻能数到第 3 行看出少了冒号。为了实现这个,我给所有 raise 都强制带上line和col,哪怕麻烦一点。

还有一个隐蔽的坑:IndexError。只要 parser 里有一处self.tokens[self.i]没有前置检查,畸形输入就能让下标越界,抛出的异常类型是IndexError而不是JsonSyntaxError。解决办法是保证 token 列表最后一定有EOF,且_advance在遇到最后一个 token 之后不再前进(或者直接返回 EOF 本身)。这样所有越界都被扼杀在源头。

6.2 常见问题速查表

下面这张表是我在几次实际使用中整理出来的,基本都是靠 debug 才发现的:

现象根因排查方法修复
报错行号永远是 1跳过空白时没更新行号打印advance前后的 line/col把行号更新绑死在advance里
{}报错但{"a":1}正常没处理空对象分支单独测空容器进循环前先判断一次闭合符
[1,]被静默接受逗号后允许直接闭合构造尾逗号用例逗号后强制parse_value
emoji 解析成乱码代理对未合并测"\uD83D\uDE00"高代理后检查低代理并合并
大整数精度丢失数字 token 提前转了 float测9007199254740993存原始字符串,延迟到使用时转
深度嵌套崩进程递归超限抛RecursionError构造 500 层嵌套加深度计数,主动抛语法异常
前后空格导致失败尾部空白没跳过测'{"a":1} '空白跳过放在读取前而非读取后
解析大文件内存爆掉全量 token 列表常驻用tracemalloc看峰值改惰性词法,边解析边取

6.3 我个人认为最值得记住的两条经验

第一条:先写测试用例,再写解析器。我现在的习惯是先列十几条合法输入和十几条非法输入,把文件建好,然后开始写代码。每写完一个函数就跑一次,红绿反馈极快。反过来,先写几百行再测试,一旦挂了,你根本不知道是词法错了还是句法错了。想快速区分,有个笨办法:打印 token 流,眼睛扫一遍,如果 token 流是对的,问题一定在 parser;如果 token 流就不对,先修词法。

第二条:不要过早追求性能和通用性。JSON parser 这个东西,第一版就应该写得笨拙、直白、能跑通。我见过有人一上来就搞表驱动、搞零拷贝、搞 SIMD 风格的批量扫描,结果一个字符串转义没处理好,整个周末都在纠结为什么"\u00e9"变成两个字符。先把状态机写对,等它能稳定通过几百条用例,再去优化那 60 倍到 50 倍的性能差距,收益比是完全不同的。

7. 这套骨架还能往哪长

写完这个 parser 之后,你会发现它其实是一个通用的递归下降解析器模板,换成别的文法就能解析别的东西。我自己就在这个骨架上改过两次。

第一次是把文法从 JSON 换成一种内部用的配置格式,加了注释(#到行尾)、允许键不加引号、允许末尾逗号。改动量很小:词法层加一个跳过注释的分支,句法层的parse_object里对键的类型放开一点。这让我意识到一个事实——大部分“自定义配置格式”本质上就是加了几个糖的 JSON,不需要重新发明轮子,改个 parser 就行。

第二次是把它改成能解析简单表达式,带加减乘除和括号。这时候文法里出现了左递归(expr := expr '+' term),递归下降直接写会死循环,必须消除左递归改写成expr := term ('+' term)*,然后在循环里累积结果。这一步做完,你就能理解为什么教科书要花那么大篇幅讲左递归消除——它不是纸上谈兵,是真会卡住你的东西。

再往后就是优先级问题了。JSON 不需要考虑运算符优先级(它根本没有运算符),但表达式需要。这时候有两条路:给每个优先级写一个函数(parse_expr调parse_term调parse_factor),或者上 Pratt 解析器(也叫运算符优先级解析)。前者直观、模式固定,后者代码更短、扩展新运算符更容易。前一种写法更适合入门,因为它的结构和 EBNF 的层次是一一对应的,你能清楚地看到“乘法比加法优先级高”是怎么在代码里体现的——靠的是函数调用的嵌套深度,而不是某张表。

最后分享一个我最近才体会到的事。写这种东西最大的收获其实不是“我会写 parser 了”,而是你从此对“计算机怎么理解文本”这件事有了一手的感受。以后再遇到正则表达式写不对、模板引擎报错看不懂、某个 DSL 语法行为诡异的情况,你脑子里会自动浮现出“哦,这大概是它的词法层没处理好转义”,排查路径会短很多。这种底气不是看文章能看出来的,得自己敲一遍。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询