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),行列号用来在报错时精确定位。
| 字段 | 类型 | 作用 | 是否可省 |
|---|---|---|---|
| type | TokenType 枚举 | 驱动 parser 的分支判断 | 不可省 |
| value | object | 承载字面量的实际值 | 不可省 |
| line | int | 错误定位、IDE 高亮 | 强烈建议保留 |
| col | int | 同上,列级精度 | 强烈建议保留 |
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 非终结符到函数的映射关系
我把每个产生式对应成一个方法,映射关系列成表,写代码的时候照着填:
| 产生式 | 方法 | 输入起点 | 返回类型 |
|---|---|---|---|
| value | parse_value | 任意 token | Python 对象 |
| object | parse_object | { | dict |
| array | parse_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 objparse_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 value4.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 语法行为诡异的情况,你脑子里会自动浮现出“哦,这大概是它的词法层没处理好转义”,排查路径会短很多。这种底气不是看文章能看出来的,得自己敲一遍。