目标:用纯标准库代码,实现一个行为与 json.loads基本一致的 JSON 解析器,并配齐测试与扩展方向
前置要求:Python 3.8+,熟悉 dict /list、字符串转义和递归即可
不需要正则大师,也不需要编译原理学位
读完你将得到:代码、对拍测试和一张理解解析器原理的模型
为什么值得手写一个 JSON 解析器
JSON 无处不在:API 响应、配置文件、前端存储。我们每天都在用 json.loads,但很少有人知道它内部发生了什么。手写一遍,你会获得实实在在的收获:
-
看清本质:所谓解析,就是把「一段文本」还原成「内存里的数据结构」,反向就是序列化。理解了这一点,任何格式(YAML、TOML、XML)对你都不再神秘
-
掌握解析器骨架:几乎所有编程语言的解析器都由「词法分析 + 语法分析」两步构成,JSON 是练习这两步的最佳载体 —— 语法小、规则清晰、做完立刻能用
-
理解边界:手写过程中你会被迫回答合法吗?合法吗?合法吗?这些 json.loads 替我们挡住了的问题
这也是面试中的高频手写题,当你能直接回答上时,你会发现与你之前的认知完全不一样{:301_971:}
前置知识与环境
JSON 语法速览
JSON 只有 6 种值,其他一切都是这 6 种的嵌套组合:
| JSON 类型 |
Python 类型 |
示例 |
| object(对象) |
dict |
{"name": "a"} |
| array(数组) |
list |
[1, 2, 3] |
| string(字符串) |
str |
"hello" |
| number(数字) |
int / float |
-3.14e2 |
| true / false |
bool |
true |
| null |
None |
null |
必须记住De规则:
整体思路:
graph LR
A["输入 JSON 字符串"] --> B["词法分析<br/>tokenize()"]
B --> C["Token 流"]
C --> D["语法分析<br/>Parser(递归下降)"]
D --> E["Python 对象<br/>dict / list / str / int / float / bool / None"]
整个解析器只有两步:
-
词法分析:逐字符扫描,把字符流切成一串有意义的 Token(符号、字符串、数字、字面量),同时跳过空白
-
语法分析:用递归下降的方式消费 Token 流,按照 JSON 语法逐层构建出嵌套的 Python 对象
为什么要拆两步?一是职责分离:词法层管「怎么切」,语法层管「怎么拼」,各自的错误提示更精准;二是可复用:同一个 Token 化思路稍作改动就能服务 YAML、TOML 等其他格式
One:词法分析
Token 类型
我们的 Token 是一个二元组 (类型,值)。类型只有 6 种:
| Token 类型 |
含义 |
示例 |
| LBRACE / RBRACE |
左 / 右花括号 |
{ } |
| LBRACKET / RBRACKET |
左 / 右方括号 |
[ ] |
| COLON / COMMA |
冒号 / 逗号 |
: , |
| STRING |
字符串(值已去引号、已解码转义) |
("STRING", "hi") |
| NUMBER |
数字(保留原始文本,稍后转 int /float) |
("NUMBER", "3.14") |
| LITERAL |
字面量 true /false/null |
("LITERAL", "true") |
实现与讲解
tokenize 的核心是一个主循环:按当前字符的类型分发到不同分支。先看骨架:
def tokenize(text):
tokens, i, n = \[], 0, len(text)
while i < n:
ch = text\[i]
if ch in ' \t\r\n': # ① 空白:直接跳过
i += 1
continue
if ch in TOKEN\_TYPES: # ② 结构符号:{ } \[ ] : ,
tokens.append((TOKEN\_TYPES\[ch], ch))
i += 1
continue
if ch == '"': # ③ 字符串:见下
...
if ch == '-' or ch.isdigit(): # ④ 数字:见下
...
# ⑤ 字面量 true / false / null
# ⑥ 其他字符:报错
return tokens
两个最容易出错的分支值得展开。第一个是字符串:要处理结束引号、单字符转义和 \uXXXX 四字节 Unicode 转义,并返回解码后的真实内容:
if ch == '"':
i += 1
buf = \[]
while i < n:
c = text\[i]
if c == '"': # 结束引号
i += 1
break
if c == '\\\\': # 转义序列
i += 1
if i >= n:
raise ValueError('字符串中反斜杠后缺少字符')
esc = text\[i]
if esc in ESCAPES: # \\" \\\ \\/ \b \f \n \r \t
buf.append(ESCAPES\[esc])
elif esc == 'u': # \uXXXX
hex4 = text\[i + 1:i + 5]
if len(hex4) < 4 or not re.fullmatch(r'\[0-9a-fA-F]{4}', hex4):
raise ValueError(f'非法 \\\u 转义: {hex4!r}')
buf.append(chr(int(hex4, 16)))
i += 4
else:
raise ValueError(f'非法转义: \\\\{esc}')
i += 1
else:
buf.append(c)
i += 1
else:
raise ValueError('字符串未闭合')
tokens.append(('STRING', ''.join(buf)))
第二个是数字:先贪婪地吃掉整数、小数、指数三部分,再用一个正则做「最终裁决」,把 01、1.、- 这类非法格式挡在门外:
if ch == '-' or ch.isdigit():
start = i
i += 1
while i < n and text\[i].isdigit(): # 整数部分
i += 1
if i < n and text\[i] == '.': # 小数部分
i += 1
while i < n and text\[i].isdigit():
i += 1
if i < n and text\[i] in 'eE': # 指数部分
i += 1
if i < n and text\[i] in '+-':
i += 1
while i < n and text\[i].isdigit():
i += 1
token = text\[start:i]
NUMBER\_RE = re.compile(r'-?(?:0|\[1-9]\d\*)(?:\\.\d+)?(?:\[eE]\[+-]?\d+)?\Z')
if not NUMBER\_RE.fullmatch(token):
raise ValueError(f'非法数字: {token!r}')
tokens.append(('NUMBER', token))
continue
可观察结果:调用 tokenize('{"a": 1}') 会得到 [('LBRACE', '{'), ('STRING', 'a'), ('COLON', ':'), ('NUMBER', '1'), ('RBRACE', '}')]—— 空白已被丢弃,字符串已脱掉引号,这就是词法层的全部职责。
Two:递归下降解析器(Parser)
parse_value:一切的分发入口
递归下降的精髓一句话:每条语法规则对应一个方法,方法之间互相调用,就像语法规则互相嵌套。JSON 的顶层规则是:
value := object | array | string | number | true | false | null
对应的 parse_value 按当前 Token 类型分发:
def parse\_value(self):
kind, value = self.peek()
if kind == 'LBRACE':
return self.parse\_object()
if kind == 'LBRACKET':
return self.parse\_array()
if kind == 'STRING':
self.advance()
return value
if kind == 'NUMBER':
self.advance()
return float(value) if any(c in value for c in '.eE') else int(value)
if kind == 'LITERAL':
self.advance()
return {'true': True, 'false': False, 'null': None}\[value]
raise ValueError(f'意外的 Token: {value!r}')
注意数字的转换技巧:值里只要出现过 .、e、E 就按 float 转,否则按 int 转,正好覆盖 JSON 的全部数字写法。
parse_object 与 parse_array
对象和数组是一对「镜像」结构,模式完全一样:先消费左括号,再循环「值 + 分隔符」,直到遇见右括号。区别只在对象的键必须是字符串、且键值之间有冒号:
def parse\_object(self):
self.expect('LBRACE')
obj = {}
if self.peek()\[0] == 'RBRACE': # 空对象 {}
self.advance()
return obj
while True:
if self.peek()\[0] != 'STRING': # 键必须是字符串
raise ValueError('对象的键必须是字符串')
key = self.advance()\[1]
self.expect('COLON')
obj\[key] = self.parse\_value() # 递归:值可以是任意类型
kind, \_ = self.peek()
if kind == 'COMMA':
self.advance()
continue
if kind == 'RBRACE':
self.advance()
return obj
raise ValueError('对象中期望 , 或 }')
def parse\_array(self):
self.expect('LBRACKET')
arr = \[]
if self.peek()\[0] == 'RBRACKET': # 空数组 \[]
self.advance()
return arr
while True:
arr.append(self.parse\_value()) # 递归:元素可以是任意类型
kind, \_ = self.peek()
if kind == 'COMMA':
self.advance()
continue
if kind == 'RBRACKET':
self.advance()
return arr
raise ValueError('数组中期望 , 或 ]')
两个方法的关键都在 self.parse_value() 这一行递归调用:它让 {"a": [1, {"b": null}]} 这种任意深度的嵌套被自动展开 —— 这就是「递归下降」名字的由来。解析完顶层 value 后,还要检查 Token 流必须恰好耗尽,否则像 {"a":1} extra 这种带尾巴的输入就会在最后一步被抓住。
完整代码
把上面所有部分合并,就是一个可以直接保存运行的文件(请见最下方)
测试验证
光写出来不算数,得证明它真的和标准库一致。测试分两组:
-
合法输入对拍:19 个用例(从 null、数字边界到深层嵌套、Unicode 转义、中文键)逐一与 json.loads 的结果比对,全部相等;
-
非法输入拒绝:13 个用例(空串、[1,]、01、1.、tru、未闭合字符串、带尾巴输入等)必须全部抛出 ValueError。
import json, json\_parser
cases = \[
'null', 'true', 'false',
'0', '-1', '3.14', '-2.5e3', '1E+10',
'"hello"', '"\\\u4f60\\\u597d"', '"a\\\nb\\\tc"',
'\[]', '\[1, 2, 3]', '\[\[1], \[2, \[3]]]',
'{}', '{"a": 1}', '{"a": {"b": \[true, null, "x"]}}',
' {"name": "doubao", "age": 3, "tags": \["AI", "assistant"]} ',
]
for s in cases:
assert json\_parser.loads(s) == json.loads(s), s
print(f'{len(cases)} 个合法用例全部通过 ✔')
bad\_cases = \['', '{', '\[1,]', '{"a":}', '01', '1.', '-', 'tru', '"abc', '{1: 2}']
for s in bad\_cases:
try:
json\_parser.loads(s)
except ValueError:
pass
else:
raise AssertionError(f'非法输入未被拒绝: {s!r}')
print(f'{len(bad\_cases)} 个非法用例全部被拒绝 ✔')
常见问题
| 场景 |
原因 |
本实现的行为 |
| 深层嵌套(如 1000 层数组) |
Python 递归深度限制 |
抛出 RecursionError,与 json.loads 行为一致 |
| 超长数字 / 大整数 |
Python int 无上限 |
正常解析为 int(JSON 规范无位数限制) |
重复键 {"a":1,"a":2} |
JSON 规范允许,键去重 |
后值覆盖前值,与 json.loads 一致 |
| 错误信息风格 |
实现不同 |
我们的报错更友好:带 Token、位置和原因;json.loads 报行列号 |
非字符串键 {1: 2} |
语法违规 |
抛出「对象的键必须是字符串」 |
复盘
这个小实现已经具备完整解析器的骨架,往任何方向扩展都有明确路径:
-
更精准的错误定位:给每个 Token 记上行列号,报错时输出「第 3 行第 7 列」而不是位置偏移;
-
反方向实现 dumps:写一个序列化器把 Python 对象变回 JSON 字符串,和 loads 组成完整闭环;
-
支持 JSON5 / 超集语法:放开单引号、注释、尾随逗号、NaN—— 只要修改 tokenize 的规则和几个解析分支;
-
性能优化:合并转义查找表、避免逐字符 append、或把热路径用 C 重写(标准库就是这么干的)。
复盘
解析器没有魔法 —— 词法分析把「字符」变成「词」,语法分析把「词」拼成「结构」。掌握了 tokenize + 递归下降这对组合,你就掌握了所有解析器的通用骨架,剩下的只是语法的细节。