吾爱破解 - 52pojie.cn

 找回密码
 注册[Register]

QQ登录

只需一步,快速开始

查看: 283|回复: 4
上一主题 下一主题
收起左侧

[Python 原创] 从零实现一个 JSON 解析器

  [复制链接]
跳转到指定楼层
楼主
Iamx688110 发表于 2026-9-12 19:47 回帖奖励

从零实现一个 JSON 解析器

前言


这篇文章是我自己正儿八经写了快一个月了,但是写的7788的...So:让AI改写+降AI
because:

目标:用纯标准库代码,实现一个行为与 json.loads基本一致的 JSON 解析器,并配齐测试与扩展方向
前置要求:Python 3.8+,熟悉 dict /list、字符串转义和递归即可

不需要正则大师,也不需要编译原理学位
读完你将得到:代码、对拍测试和一张理解解析器原理的模型


为什么值得手写一个 JSON 解析器

JSON 无处不在:API 响应、配置文件、前端存储。我们每天都在用 json.loads,但很少有人知道它内部发生了什么。手写一遍,你会获得实实在在的收获:

  • 看清本质:所谓解析,就是把「一段文本」还原成「内存里的数据结构」,反向就是序列化。理解了这一点,任何格式(YAML、TOML、XML)对你都不再神秘

  • 掌握解析器骨架:几乎所有编程语言的解析器都由「词法分析 + 语法分析」两步构成,JSON 是练习这两步的最佳载体 —— 语法小、规则清晰、做完立刻能用

  • 理解边界:手写过程中你会被迫回答合法吗?合法吗?合法吗?这些 json.loads 替我们挡住了的问题

这也是面试中的高频手写题,当你能直接回答上时,你会发现与你之前的认知完全不一样{:301_971:}


前置知识与环境

  • 环境:Python 3.8 及以上,全程只用标准库,零依赖

  • 已掌握:dict /list 嵌套、字符串与转义、函数与递归

  • 兼容标准: RFC 8259

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规则:

  • 对象的键必须是字符串{1: 2} 非法);

  • 数字格式严格:不允许前导零(01 非法)、不允许裸小数点(1. 非法)、不允许 NaN / Infinity;

  • 字符串支持转义:\" \\ \/ \b \f \n \r \t\uXXXX(Unicode)。

整体思路:

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"]

整个解析器只有两步:

  1. 词法分析:逐字符扫描,把字符流切成一串有意义的 Token(符号、字符串、数字、字面量),同时跳过空白

  2. 语法分析:用递归下降的方式消费 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)))

第二个是数字:先贪婪地吃掉整数、小数、指数三部分,再用一个正则做「最终裁决」,把 011.- 这类非法格式挡在门外:

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}')

注意数字的转换技巧:值里只要出现过 .eE 就按 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,]011.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 + 递归下降这对组合,你就掌握了所有解析器的通用骨架,剩下的只是语法的细节。


源码.zip (4.58 KB, 下载次数: 6)

免费评分

参与人数 1吾爱币 +1 热心值 +1 收起 理由
qwe12345678 + 1 + 1 感谢发布原创作品,吾爱破解论坛因你更精彩!

查看全部评分

发帖前要善用论坛搜索功能,那里可能会有你要找的答案或者已经有人发布过相同内容了,请勿重复发帖。

推荐
kasytano 发表于 2026-9-13 02:25
还有手写asm解析()
这个明显没审核ai写的内容吧 什么是int正常解析在常见问题里面
3#
sydneyJing 发表于 2026-9-13 06:44
之前自己偷懒直接Ai搓个能用的就行,但是对于json里面嵌套的没法格式化的很好,用你这个试试
4#
忧郁之子 发表于 2026-9-13 10:17
我觉得还是带有UI使用起来更方便一点,毕竟不是所有人都知道代码的具体使用
5#
 楼主| Iamx688110 发表于 2026-9-13 14:45 |楼主
kasytano 发表于 2026-9-13 02:25
还有手写asm解析()
这个明显没审核ai写的内容吧 什么是int正常解析在常见问题里面

我没注意,这类表述不严谨
您需要登录后才可以回帖 登录 | 注册[Register]

本版积分规则

返回列表

RSS订阅|小黑屋|处罚记录|联系我们|吾爱破解 - 52pojie.cn ( 京ICP备16042023号 | 京公网安备 11010502030087号 )

GMT+8, 2026-9-15 13:02

Powered by Discuz!

Copyright © 2001-2020, Tencent Cloud.

快速回复 返回顶部 返回列表