news 2026/10/27 13:01:41

手写JSON解析器:词法分析、递归下降与错误定位

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
手写JSON解析器:词法分析、递归下降与错误定位

写 JSON parser 这件事,我见过两种极端的反应。一种人觉得"这不就是把 stdlib 的 json 抄一遍吗,有什么好写的";另一种人真的动手写了,然后卡在字符串转义和数字字面量上整整一个下午。我属于后者,而且卡了不止一次。用 Python 实现一个完整的 JSON parser,把词法分析和句法分析这两层拆开亲手写一遍,是理解编译原理里"字符流 → Token 流 → 语法树"这条主线最省力的路径:JSON 的文法足够小,小到你一天就能写出能跑的第一版;但它又足够完整,完整到能逼你面对真实编译器里 80% 的琐碎问题——位置追踪、错误恢复、递归深度、字面量边界。这篇内容适合三类人:正在学编译原理但被龙书那套大例子劝退的;需要用 Python 手写一个小解析器来处理自定义配置格式的;以及单纯想把 json 模块当黑盒用了很多年、想看一眼里面到底发生了什么的。下面我按自己实际写下来的顺序,从文法定义一路讲到和标准库对拍,中间该踩的坑一个不落。

1. 从字符串到对象:一次解析到底发生了什么

先给结论:json.loads('{"a": [1, 2]}')这一行背后是三个彼此独立、可以单独测试的阶段。第一个阶段是词法分析,输入是一串 Unicode 字符,输出是一串带类型和位置的 Token,比如"这是一个左花括号,位于第 1 行第 1 列"。第二个阶段是句法分析,输入是 Token 序列,输出是一棵抽象语法树,或者直接产出 Python 的 dict 和 list。第三个阶段是可选的语义动作,在 JSON 这个场景里,基本就是"把树上的节点映射成原生对象"。

把这三段拆开写,最大的好处是调试边界清晰。当你拿到一段诡异的 JSON 报错时,你可以先只跑词法层,把 Token 序列打出来看一眼——很多时候问题在第 3 个 Token 就已经出现偏差了,根本轮不到句法层出手。如果全塞在一个函数里,你就只能靠 print 硬猜。

1.1 为什么值得自己写一遍,而不是直接用标准库

标准库当然该用,生产环境里没有任何理由手搓一个 json 解析器替代它。但自己写一遍的收益不在产出物,在于理解成本的反转。你在用json.loads的时候,遇到Expecting property name enclosed in double quotes: line 1 column 2 (char 1)这种报错,只能凭经验猜;写完之后你知道这句话是解析器在parse_object里读到一个非字符串 Token 时抛的,你能立刻定位是哪个字符出了问题。

另一个实际场景是扩展。标准库的 JSON 不支持注释,不支持尾逗号,不支持单引号。如果你需要解析一份人写的配置文件,往往要放宽一点语法。这时候你只有两个选择:用正则预处理(脆、容易误伤字符串里的内容),或者自己写一个解析器,把文法改宽松一点。后者才是干净的做法。

提醒:自己写的解析器用来处理可信输入是合适的,比如项目内的配置文件、自己生成的中间数据。处理外部不可信输入时,安全边界(最大深度、最大长度、超时)必须自己补齐,标准库这些年在这块做过不少加固,别轻易替换掉。

1.2 三个阶段的输入输出契约

我习惯在动手之前先把每个阶段的数据契约写清楚,写在注释里,后面写代码就不容易跑偏。

阶段输入输出失败时的行为
词法分析str全文List[Token]或逐次next_token()抛LexError,带行列号
句法分析Token 序列dict / list / 标量抛ParseError,带 Token 位置
求值转换语法树Python 原生对象一般不会失败

这里有个设计选择值得说清楚:Token 是一次性全部切完,还是按需流式产出。切成列表的好处是写解析器的时候可以随便回看、随便peek(2),代码简单;流式的好处是内存占用小、能处理超大文件。JSON 这种格式绝大多数情况下整体都能装进内存,所以我选了一次性切完,换来解析器代码的简洁。如果你的输入是几百 MB 的日志流,那确实应该做成流式的,但那种场景通常也不会用 JSON。

1.3 先把文法写出来,再写代码

这一步很多人会跳过,直接开始写while循环,然后写到一半发现不知道自己写到哪儿了。JSON 的文法小到可以完整写在纸上,我用的是一套简化的 EBNF:

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

盯着这九行看,你会发现两件事。第一,value那一行是互斥的六选一,每一条分支的首字符集合都不重叠({、[、"、-或数字、t、f、n),这意味着只看一个 Token 就能决定走哪条分支,不需要回溯。第二,object和array的递归是右递归结构,天然适合递归下降。这两点加起来,等于告诉你:这个文法可以写成一串互相调用的函数,每个非终结符对应一个函数,看一眼当前 Token 就能分发。

int那一条里的'0' | [1-9] [0-9]*是很多人忽略的地方。JSON 明确禁止前导零,01、-01都是非法的,而 Python 的int("01")会老老实实返回 1。如果你词法层不用心,直接拿int()兜底,就会把一个非法文档解析成合法结果,这种"宽容"是 bug 的温床。

2. 词法分析器:把一堆字符切成有意义的碎片

词法层的本质是一个有限状态机。你手里有一个游标,每次看当前字符是什么,决定是继续吃字符、还是结束当前 Token、还是报错。整个过程中你只需要记住极少量状态(比如"我现在在字符串里"),这正好对应编译原理里的正则文法——正则语言用有限状态就能识别,不需要栈。

2.1 Token 的数据结构与位置信息

先说结构。我用的是一个极简的类,配合__slots__减少内存开销:

class Token: __slots__ = ("kind", "value", "line", "col") def __init__(self, kind, value, line, col): self.kind = kind # 'LBRACE' / 'STRING' / 'NUMBER' / ... self.value = value self.line = line self.col = col def __repr__(self): return f"Token({self.kind}, {self.value!r}, {self.line}:{self.col})"

line和col不是装饰品,是刚需。没有位置信息,用户拿到报错只知道"解析失败",完全无法定位。JSON 文件动辄上千行,你让人一行行找?标准库那句line 1 column 2 (char 1)之所以好用,就是因为位置信息贯穿了整条链路。

代价是每切一个 Token 就要维护行列号。我的做法是在游标推进的唯一入口处统一更新,不要让任何地方绕过它:

def _advance(self): ch = self.s[self.i] self.i += 1 if ch == "\n": self.line += 1 self.col = 1 else: self.col += 1 return ch

只要所有前进都走这个函数,行列号就不会错乱。我第一版为了"性能"在几个热点路径上直接写了self.i += 1,结果位置信息全线崩了,调了半天才发现是某处跳过了更新。

2.2 手写扫描器 vs 正则预切分:我为什么选前者

最诱人的写法是用re.finditer配合一个大正则,把所有 Token 一把切出来:

TOKEN_RE = re.compile(r''' \s+ | (?P<STRING>"(?:[^"\\]|\\.)*") | (?P<NUMBER>-?(?:0|[1-9]\d*)(?:\.\d+)?(?:[eE][+-]?\d+)?) | (?P<PUNCT>[{}\[\]:,]) | (?P<LITERAL>true|false|null) ''', re.VERBOSE)

这段能跑,而且在合法输入上跑得不慢。但它有三个问题。第一,字符串的正则(?:[^"\\]|\\.)*在遇到未闭合的字符串时会退化成灾难性回溯,一个几百 KB 的残缺文件就能让你的进程卡死几秒。第二,正则没法记录行列号,你得额外遍历每个匹配的文本去数换行。第三,报错信息极其糟糕——正则匹配失败时你只知道"这里没匹配上",说不出到底期待什么。

手写状态机就没这些毛病。游标每前进一格都是显式的,遇到什么字符、期待什么字符,全在代码里写着。而且手写版本天然支持边切边报错:读到"abc突然遇到文件结尾,你可以精确地说"字符串从第 3 行第 5 列开始,到文件末尾未闭合"。这种错误信息质量,是正则方案给不了的。

2.3 字符串与转义:整个词法层最容易翻车的地方

JSON 字符串的规则看起来简单,实际坑最多。合法的转义只有八个:\" \\ \/ \b \f \n \r \t,加上\uXXXX。除此之外任何反斜杠组合都是非法的。注意\/是合法的,虽然 Python 字符串里没人这么写,但 JSON 规范允许它,你的解析器必须接受。

我的实现方式是在字符串状态里遇到反斜杠就单独处理:

ESCAPES = {'"': '"', "\\": "\\", "/": "/", "b": "\b", "f": "\f", "n": "\n", "r": "\r", "t": "\t"} def _scan_string(self): start_line, start_col = self.line, self.col self._advance() # 吃掉开头的引号 buf = [] while True: if self.i >= len(self.s): raise LexError("字符串未闭合", start_line, start_col) ch = self.s[self.i] if ch == '"': self._advance() break if ch == "\\": self._advance() esc = self._advance() if esc == "u": buf.append(self._scan_unicode_escape()) elif esc in ESCAPES: buf.append(ESCAPES[esc]) else: raise LexError(f"非法转义 \\{esc}", self.line, self.col) elif ord(ch) < 0x20: raise LexError("字符串内不允许裸控制字符", self.line, self.col) else: buf.append(self._advance()) return Token("STRING", "".join(buf), start_line, start_col)

这里有两处值得单独拎出来说。

第一处:裸控制字符。JSON 规范要求字符串里所有 U+0000 到 U+001F 的字符都必须转义。也就是说,一个里面直接嵌了制表符的字符串是非法的,尽管很多实现会宽容放行。如果你的解析器要跟标准库对齐,就必须拒绝。我一开始漏了这条,对拍的时候才被揪出来。

第二处:\u的代理对。这是最阴的一个坑。\uD83D\uDE00表示一个 emoji,两个 4 位十六进制必须合并成一个码点,否则你会得到两个孤立代理,后续encode()直接报错。但反过来,如果只有一个孤零零的\uD83D后面跟的不是低代理,按规范它是合法的(规范不强制检查),只是这个码点无法编码成 UTF-8。我采取的策略是:遇到高代理就向后看两位,如果是\u且是低代理就合并,否则原样保留该码点,交给后续环节处理。

def _scan_unicode_escape(self): hi = self._read_hex4() if 0xD800 <= hi <= 0xDBFF: # 尝试合并低代理 if self.s.startswith("\\u", self.i): save = self.i self._advance(); self._advance() lo = self._read_hex4() if 0xDC00 <= lo <= 0xDFFF: return chr(0x10000 + ((hi - 0xD800) << 10) + (lo - 0xDC00)) self.i = save # 不是低代理,回退 return chr(hi) return chr(hi)

注意上面回退时我直接改了self.i,这违反了"位置更新走统一入口"的原则。实际写的时候要么把行列号一起回退,要么改成先探测再消费的两段式。我后来改成了先peek后消费的写法,避免了自己给自己挖坑。

2.4 数字字面量:JSON 的数字文法比想象中窄

很多人以为 JSON 数字就是"能被 float() 吃进去的东西",差了十万八千里。以下是明确非法的:

写法是否合法原因
01否不允许前导零
+1否不允许正号
.5否整数部分必须有
1.否小数点后必须有数字
1e否指数部分必须有数字
Infinity/NaN否不是 JSON 的合法字面量

写词法的时候,数字扫描必须严格按状态机走,不能偷懒用float()兜底:

def _scan_number(self): start = self.i line, col = self.line, self.col if self._peek() == "-": self._advance() # 整数部分 if self._peek() == "0": self._advance() elif self._peek() and self._peek() in "123456789": while self._peek() and self._peek().isdigit(): self._advance() else: raise LexError("数字格式非法", line, col) # 小数部分 if self._peek() == ".": self._advance() if not (self._peek() and self._peek().isdigit()): raise LexError("小数点后缺少数字", self.line, self.col) while self._peek() and self._peek().isdigit(): self._advance() # 指数部分 if self._peek() in ("e", "E"): self._advance() if self._peek() in ("+", "-"): self._advance() if not (self._peek() and self._peek().isdigit()): raise LexError("指数部分缺少数字", self.line, self.col) while self._peek() and self._peek().isdigit(): self._advance() return Token("NUMBER", self.s[start:self.i], line, col)

这里有个取舍问题:Token 的value存字符串还是存int/float?我倾向于存字符串,把类型转换推迟到句法层。理由有两个。一是精度:1E400转 float 会变成inf,但你还没决定要不要支持,存字符串就保留了这个选择的余地。二是大整数:Python 的 int 是任意精度的,123456789012345678901234567890直接转没问题,但如果是要跟其他语言的实现对齐,你可能想控制这一点。存字符串让词法层保持纯粹。

顺带一提,判断"这个 Token 是整数还是浮点"其实也要在转换时做:字符串里出现过.或e就是 float,否则是 int。标准库就是这么干的,所以1.0解析出来是1.0(float),1是1(int)。

2.5 空白、注释与非法字符的边界

JSON 的空白只有四个:空格、制表符、换行、回车(0x20 0x09 0x0A 0x0D)。其他 Unicode 空白比如不间断空格、全角空格,都不算空白,出现在 Token 之间就是语法错误。这个规则很严格,很多手写实现都放得太宽。

如果你是想扩展成支持注释的变体(比如配置文件场景),可以在词法层加两条规则://到行尾、/* */成对。实现上不难,但要注意两点:一是//只能出现在 Token 之外,字符串里的//不能当注释;二是块注释嵌套的问题,建议直接不支持嵌套,遇到嵌套就当遇到第一个*/结束,规则简单不容易出意外。

非法字符的处理要干脆:_peek()返回一个不认识的东西,立刻抛LexError,并且把那个字符原样带进错误信息里,加个码点的十六进制表示。用户看到"第 5 行第 12 列出现非法字符'(U+0027)",一眼就知道自己用了单引号。

3. 句法分析器:递归下降如何把 Token 拼成树

词法层的产出是一条扁平的 Token 流,句法层的任务是根据文法把它折叠成嵌套结构。递归下降是这里最自然的选择,因为它的写法几乎就是把 EBNF 抄一遍——每个非终结符写一个函数,函数体就是文法右部的展开。这种"文法即代码"的对应关系,是递归下降最大的优势,改文法的时候改代码非常直观。

3.1 递归下降的直觉:一个非终结符一个函数

先看骨架。解析器持有一个 Token 列表和一个游标,最核心的两个操作是peek()(看当前 Token 但不消费)和eat(kind)(断言当前是某类型并消费):

class Parser: def __init__(self, tokens): self.tokens = tokens self.pos = 0 self.depth = 0 self.max_depth = 512 def _peek(self): return self.tokens[self.pos] if self.pos < len(self.tokens) else None def _eat(self, kind): tok = self._peek() if tok is None: raise ParseError(f"意外结束,期待 {kind}", None) if tok.kind != kind: raise ParseError(f"期待 {kind},实际是 {tok.kind}", tok) self.pos += 1 return tok

有了这两个原语,parse_value就是一个纯粹的分发函数:

def parse_value(self): tok = self._peek() if tok is None: raise ParseError("期待一个值,实际已到末尾", None) if tok.kind == "LBRACE": return self.parse_object() if tok.kind == "LBRACKET": return self.parse_array() if tok.kind == "STRING": self.pos += 1 return tok.value if tok.kind == "NUMBER": self.pos += 1 return self._convert_number(tok.value) if tok.kind == "LITERAL": self.pos += 1 return {"true": True, "false": False, "null": None}[tok.value] raise ParseError(f"期待一个值,实际是 {tok.kind}", tok)

这段代码的阅读体验应该有:任何懂一点 Python 的人,看完就知道 JSON 的值有哪六种。这就是"文法即代码"的威力,也是递归下降适合教学和手写的原因。

3.2 对象与数组:循环结构怎么写才不出错

object和array是唯二带循环的结构,也是最容易写出 bug 的地方。它们的模式其实完全一致:左括号、可选的元素列表、右括号;元素之间用逗号分隔,且第一个元素前和最后一个元素后都不能有逗号。

一个常见的错误写法是"读一个元素,然后只要看到逗号就继续读",这样写会放行尾逗号:

# 错误示范:会接受 {"a":1,} def parse_object_bad(self): result = {} self._eat("LBRACE") while self._peek() and self._peek().kind != "RBRACE": result.update(self.parse_pair()) if self._peek() and self._peek().kind == "COMMA": self._eat("COMMA") # 这里没检查后面是不是 RBRACE self._eat("RBRACE") return result

正确写法是把"逗号在元素之间"这个语义显式表达出来:

def parse_object(self): self._eat("LBRACE") result = {} if self._peek() and self._peek().kind == "RBRACE": self._eat("RBRACE") return result while True: key_tok = self._eat("STRING") # 键必须也是字符串 if key_tok.value in result: raise ParseError(f"重复的键 {key_tok.value!r}", key_tok) self._eat("COLON") result[key_tok.value] = self.parse_value() nxt = self._peek() if nxt and nxt.kind == "COMMA": self._eat("COMMA") continue break self._eat("RBRACE") return result

注意循环体里continue之后会回到开头去读下一个键,而不是简单地把逗号吃掉就完事。这样如果写成{"a":1,},在continue之后_eat("STRING")会撞上RBRACE,直接报"期待 STRING,实际是 RBRACE",错误位置也准。

关于重复键,JSON 规范说"名字应当唯一,但行为未定义"。不同实现的选择不一样:Python 的json默认后者覆盖前者,不做检查。我在这里选择抛错,是因为在配置文件的场景下,重复键几乎一定是手误,静默覆盖会让错误藏得很深。但如果你是要做通用的 JSON 解析,那就应该跟标准库保持一致,用覆盖策略。这是一个没有对错、只有场景的选择,关键是你要意识到自己在做选择。

数组的写法完全同构,这里就不重复了。值得一提的是空数组[]和空对象{},必须在进循环之前单独判断掉,否则第一轮就会撞上右括号。

3.3 键必须是字符串:一条容易漏的约束

pair := string ':' value,注意左边是string,不是value。这意味着{1: "a"}是非法的,{true: 1}也是非法的。上面代码里用_eat("STRING")就是这个约束的直接体现。我见过不少手写的解析器在这里写成parse_value(),结果接受了一堆奇怪的东西,然后在转 dict 的时候崩了——因为 Python 的 dict 要求键可哈希,而 list 不可哈希。

3.4 要不要建 AST:一次真实的取舍

教科书上的解析器总要建一棵抽象语法树,然后写个 visitor 去遍历求值。但在 JSON 这个场景,我建议跳过 AST,直接在解析函数里产出 Python 对象。

理由很实际:JSON 的语义几乎就是"结构本身",没有任何需要二次计算的语义动作,除了类型映射(number → int/float,object → dict)之外,遍历 AST 那一步是纯开销。而且 JSON 没有变量、没有表达式求值、没有作用域,AST 能带来的分析价值是零。

什么时候该建 AST?如果你的目标是做一个支持引用、表达式或者模板的配置语言,那必须建,因为你需要在求值之前做一遍校验和展开。JSON 不需要。这是一个很好的例子:同样的编译原理框架,用不用某一步取决于目标语言的语义复杂度,不是越完整越好。

4. 出错的时候才是见真章:错误定位与信息设计

解析器写得顺不顺利,看代码行数;好不好用,看报错。一个只会说 "Invalid JSON" 的解析器在生产环境里是废的。我在实际项目里给解析器定的标准是:任何一条报错都必须是"位置 + 期待 + 实际"三要素齐全,缺一不可。

4.1 报错信息的三要素

对比一下这几种写法:

Invalid JSON Parse error at line 3 期待 , 或 },实际是 STRING(第 3 行第 15 列)

只有第三种能让人立刻定位。它告诉你三件事:这里本该出现什么、实际出现了什么、位置在哪。用户扫一眼源文件第 3 行第 15 列,看到写字板光标停在那里,问题往往一目了然。

实现上,所有错误都从一个基类派生,带上line和col:

class JSONError(Exception): def __init__(self, msg, line=None, col=None): self.msg, self.line, self.col = msg, line, col super().__init__(str(self)) def __str__(self): if self.line is None: return self.msg return f"{self.msg}(第 {self.line} 行第 {self.col} 列)"

然后在异常信息里统一拼装,不要在抛出的地方各自为政。我见过一个项目,一半的报错带位置,一半不带,用户体感极差。

4.2 行列号的换算坑:从字符索引到行列的那一步

如果你在做流式解析,或者想给出行号之外的字符偏移,就得做一次索引换算。这里有个容易被忽略的细节:str的索引是按码点算的,而很多人(尤其从其他语言转过来的)会习惯性按字节算。Python 3 里len("😀")是 1,不是 4。所以只要你全程在str上操作,索引和列号是自洽的,不需要额外处理。

真正会出问题的是制表符。制表符在视觉上占好几个字符宽,但在字符序列里只占一格。如果你想让报错里的列号跟编辑器显示对齐,要么把制表符展开成空格数一数(通常按 4 或 8),要么干脆在报错里给出字符偏移量而不是列号。我的做法是两者都给:(第 3 行第 15 列,偏移 42)。这样无论用户用什么工具,总能对上。

另一个坑是\r\n换行。如果你的换行计数只认\n,那没问题,因为\r会被当成一个普通字符计进列号,只是看起来列号偏大一位。但如果你的游标逻辑里把\r也当成换行,就会出现一次换行计两次,行号翻倍。我的规则是:只认\n为换行,\r在 JSON 里只可能出现在转义序列中或作为非法裸字符被拒绝,所以这个简化是安全的。

4.3 快速失败还是错误恢复

编译器的错误恢复机制(比如跳过 Token 直到遇到分号再继续)在 JSON 里意义不大。因为 JSON 的顶层只有一个值,一旦出错,后面所有内容都没有上下文可依附。所以我选择快速失败:遇到第一个错误就抛异常,不尝试继续。

但快速失败有一个变体值得考虑:尽量多报几个错。做法是在对象和数组的循环里做局部捕获,遇到单个元素解析失败时记录错误、跳到下一个逗号或右括号继续,最后把所有错误一起抛出。这在处理人工编辑的大配置文件时体验很好——用户改一次能看到全部问题,而不是改一个跑一次。

实现上有一定复杂度,主要是"怎么判断该跳到哪儿"。一个粗糙但够用的策略是:记录当前的结构嵌套深度,在出错后消费 Token 直到深度回到进入这个结构时的深度,或者遇到顶层右括号。我建议第一版别做,等真的被"改一个错跑一次"折磨到了再加。

5. 跑通之后:性能、深度和兼容性的三场硬仗

第一版能在正常输入上跑起来,只是结束了热身。真正让这个解析器"能用"的,是后面这三件事:递归深度让你在正常数据上崩溃、性能差距让你怀疑人生、和标准库对拍让你发现一堆只在畸形输入上暴露的 bug。

5.1 递归深度:一千层嵌套数组会直接爆栈

递归下降的代价是调用栈深度和嵌套深度成正比。JSON 里嵌套是合法的,[[[[...]]]]嵌套一千层完全符合文法。Python 默认递归上限是 1000(sys.getrecursionlimit()),而每一层parse_value到parse_array至少占两帧,所以实际能承载的嵌套深度只有 400 左右。超过就RecursionError。

解决方式有三条,我按推荐顺序排:

第一,显式深度计数。在parse_value入口处self.depth += 1,检查是否超过max_depth(我设成 512),超过就抛一个清晰的ParseError("嵌套深度超过 512")。这至少让失败是可控的、有信息的,而不是一个玄乎的RecursionError。

第二,提高递归上限。sys.setrecursionlimit(10000)能顶一阵,但这是危险操作,因为 C 栈可能先于 Python 栈溢出,导致进程直接段错误。如果一定要调,最好配合threading.stack_size()把线程栈调大,在独立线程里跑解析。

第三,改成迭代。把递归下降改写成显式的栈机:用一个stack记录未完成的容器,用状态机记录"我是在等键、等值、还是等逗号"。这个改动量不小,但能彻底解决深度问题。我的建议是:如果你的输入嵌套深度已知可控(配置文件通常不超过 10 层),前两种就够了;如果输入不可信,必须做第三种。

def parse_value(self): self.depth += 1 if self.depth > self.max_depth: tok = self._peek() raise ParseError(f"嵌套深度超过限制 {self.max_depth}", tok.line if tok else None, tok.col if tok else None) try: return self._parse_value_inner() finally: self.depth -= 1

注意这里的try/finally,保证异常路径上深度也能正确回退。少了这个,一个内部的错误会让深度计数器永久偏移,后面所有解析都受影响。

5.2 字符串拼接:别用+=攒字符

词法层切字符串时,最朴素的写法是result = "",然后逐字符result += ch。在 CPython 里,短字符串的+=因为有就地扩容的优化,实际没那么慢,但当字符串很长或者中间穿插了其他操作时,这个优化会失效,退化成 O(n²)。

正确做法是收集到一个list,最后"".join(parts)。上面的代码里我就是这么写的。这个改动看起来微不足道,但我实测过一个 200KB 的字符串字面量,+=版本比join版本慢 30 倍以上。

另一个容易忽略的点是字符串解码后的内存拷贝。如果你的Token.value存的是解码后的字符串,那么每个字符串 Token 都有一份完整拷贝。量大了内存翻倍。可以考虑让STRING类型的 Token 存原文的起止索引,需要时再切片解码。但这是典型的过早优化,除非你确实在处理超大文档,否则别做。

5.3 和标准库对拍:用随机生成器找差异

写完解析器,最重要的验证手段不是手写测试用例,而是和标准库对拍。手写用例只能覆盖你想得到的情况,对拍能覆盖你想不到的情况。

我的做法是写一个随机 JSON 生成器,随机生成合法文档,然后对比两边loads的结果:

import random, json def rand_value(depth=0): choices = ["int", "float", "str", "bool", "null"] if depth < 4: choices += ["list", "dict"] kind = random.choice(choices) if kind == "int": return random.randint(-10**9, 10**9) if kind == "float": return random.uniform(-1e6, 1e6) if kind == "str": return "".join(random.choice('ab"\\\n\t\u00e9\u4e2d') for _ in range(random.randint(0, 8))) if kind == "bool": return random.choice([True, False]) if kind == "null": return None if kind == "list": return [rand_value(depth + 1) for _ in range(random.randint(0, 4))] return {f"k{i}": rand_value(depth + 1) for i in range(random.randint(0, 4))}

这里的关键是字符串生成器里要放重口味字符:引号、反斜杠、换行、制表符、非 ASCII。这些字符在序列化时会触发转义,正好考验你两边的转义处理是否一致。我第一次跑对拍就发现了两个差异:一是标准库会把\u00e9序列化成é(保留原字符),我的实现也是这样;但标准库对/不转义,我一开始转义了,虽然解析结果一样,但序列化输出不同。二是我一开始把-0.0解析成了0.0,标准库保留了符号位。

合法输入对拍完了,还要畸形输入对拍。做法是把合法文档随机破坏——删一个字符、插一个字符、改一个字符,然后看两边是不是都拒绝。如果这边接受、那边拒绝,说明你的解析器太宽;反过来就是太严。这一步非常有效,我抓到了好几个宽松过头的问题,比如默认接受尾逗号、接受前导零、接受单个孤立的低代理。

6. 把编译原理的知识点对号入座

写到这里,回头看整个实现,会发现它跟编译原理课上的那张经典流程图几乎完全重合,只是每一块都被压缩到了最小尺寸。这个对应关系值得单独理一遍,因为它能帮你把这套骨架迁移到别的格式上。

6.1 词法层:正则文法与有限状态机

词法层识别的每种 Token 都可以写成一个正则表达式:数字、字符串、关键字、标点。正则语言的能力边界就是"不需要记忆历史"——你只要知道"我现在在字符串内部"这一个比特的状态,就足够决定下一步。这个"有限状态"的特性,正是为什么词法层可以写成状态机,也是为什么它不能处理嵌套结构。

这个边界有个很实际的推论:任何需要计数的事情都不能放词法层。JSON 的括号配对需要计数,所以必须放在句法层。如果你在词法层试图统计括号数量,说明你已经在越界了。

6.2 句法层:上下文无关文法与 LL(1)

JSON 的文法是上下文无关的,而且是 LL(1) 的——只看一个 Token 就能唯一确定产生式。这体现在parse_value那个六路分发上:看首 Token 就选分支,绝不回头。递归下降天然适合这种文法,不需要回溯,也不需要预测分析表。

对比一下就会明白:如果 JSON 允许{既能开始对象又能开始集合推导式(像 Python 那样),那它就不是 LL(1) 了,你需要看更多 Token 或者用更复杂的分析算法。这就是为什么很多语言的解析器要用 LALR 或者手写的回溯下降——文法复杂度上去了,简单的递归下降就不够用了。

编译原理概念在 JSON parser 中的位置具体表现
词法单元Token类kind / value / 位置
正则文法字符串、数字、标点的识别有限状态扫描
上下文无关文法EBNF 里的 value / object / array递归结构
LL(1) 分析parse_value的单 Token 分发无需回溯
递归下降每个非终结符一个函数文法即代码
语法错误ParseError位置 + 期待 + 实际

6.3 这套骨架还能改成什么

最后说点延展性。这套 Token 类 + 手写词法器 + 递归下降解析器的组合,稍微改改就能支持一批"JSON 的超集"或者同构格式,我在实际项目里至少做过三次类似的改造。

第一次是支持注释和尾逗号的配置文件格式。改动很小:词法层加两条注释规则,句法层在循环里允许逗号后直接跟右括号。整个过程不到 20 行。

第二次是加变量引用,允许值的位置写$ref: some.path,解析完之后再做一遍路径展开。这需要建 AST 了,因为展开必须在完整树上做,不能边解析边展开。

第三次是改成带类型注解的配置格式,允许1.5s、500ms这种带单位的数字。改动在词法层:数字后面允许跟一组字母作为单位,Token 里多存一个 unit 字段。句法层完全不用动。

这三次改造的共性是:改动的范围是可预测的,因为每一层的职责边界很清楚。词法层只管字符到 Token,句法层只管 Token 到结构,谁也不越界。这也是我坚持分层实现的最大理由——它不是为了"符合教科书",而是为了让你在三个月后回来改的时候,能准确知道该改哪个文件。

最后一个实操经验:把解析器的入口和标准库的入口做成同签名同返回类型的包装函数,这样你可以在业务代码里通过一个开关切换实现,线上跑标准库,本地跑自己的版本做对比。这种"A/B 对拍"的部署方式,比任何单元测试都更能暴露问题,因为它跑的是真实数据。我自己的项目里,这个开关保留了大半年,期间抓到过两次只在特定用户数据上才出现的边界问题。

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/10/27 13:01:30

K8S持久化存储实战:PV/PVC、StorageClass与故障排查

1. 先把三个概念掰开&#xff1a;Volume、PV、PVC 到底谁管谁K8S 持久化存储这块&#xff0c;我见过太多人第一次上手时的困惑&#xff1a;写了半天 YAML&#xff0c;Pod 起来了&#xff0c;kubectl exec进去df -h一看&#xff0c;挂载点空空如也&#xff1b;或者 PVC 卡在Pend…

作者头像 李华
网站建设 2026/10/27 13:01:29

网络安全入门:漏洞挖掘实战方法与工具指南

1. 漏洞挖掘入门指南&#xff1a;从零开始的实战路径刚接触网络安全的新手常会陷入迷茫——漏洞挖掘听起来高大上&#xff0c;但具体从哪入手&#xff1f;需要哪些前置知识&#xff1f;工具怎么选&#xff1f;这些问题不解决就容易半途而废。我以五年SRC漏洞挖掘经验告诉你&…

作者头像 李华
网站建设 2026/10/27 13:00:33

市场能力操作系统:用户认知、触点设计、归因建模与迭代校准

1. 这不是“营销话术速成班”&#xff0c;而是一套可验证、可复盘、可迁移的市场能力操作系统“marketingskills”——这个词最近在招聘平台、自由职业接单群、甚至咖啡馆里创业者闲聊时高频出现&#xff0c;但它绝不是“发朋友圈配文案”“写个爆款标题”“学点短视频剪辑”就…

作者头像 李华
网站建设 2026/10/27 12:59:59

Spark面试卷四:Shuffle、数据倾斜与SQL内核调优

1. 卷四的整体定位与题目分布思路拆解整理这套 Spark 面试真题卷四&#xff0c;起因很简单&#xff1a;前几卷把 RDD 血缘、算子分类、宽窄依赖这些"基础题"扫完之后&#xff0c;很多读者反馈真正卡人的并不是会不会背概念&#xff0c;而是面试官把问题往深水区一推&…

作者头像 李华
网站建设 2026/10/27 12:59:13

YOLOv10在水果分类中的应用与优化实践

1. 项目概述&#xff1a;当YOLOv10遇上水果分类去年夏天在农批市场调研时&#xff0c;我发现水果质检员需要同时处理品种鉴别和品质分级两项任务。传统人工分拣不仅效率低下&#xff08;平均每小时仅能处理200-300个水果&#xff09;&#xff0c;而且不同质检员的标准差异会导致…

作者头像 李华
网站建设 2026/10/27 12:59:08

PHP志愿服务管理系统开发指南:ThinkPHP与MySQL实践

1. 项目概述与核心功能定位 这个PHP志愿服务管理系统作为计算机相关专业的毕业设计选题&#xff0c;本质上是一个基于B/S架构的Web应用。从工程实践角度看&#xff0c;这类系统通常需要实现志愿者注册管理、活动发布报名、服务时长统计三大核心模块。我在实际开发类似系统时发现…

作者头像 李华