解析器这个东西,看着高深,其实干的事特别朴素:把一串字符按语法规则翻译成程序能直接操作的内存结构。我当年第一次被解析器难住,是在某公司写配置导入功能的时候——文件格式是自己定的,解析器写得不扎实,后面所有逻辑全部白搭。后来我把这个事拆开揉碎,发现拿JSON解析器当样本学习,是最快也最直观的路径。这篇就用手写一个极简版状态机实现的方式,把{"age": 18}是怎么变成内存里哈希表的全过程走一遍。适合正在学编译原理、刚接触parser、或者想补一补底层基本功的人,看完可以直接照着写,也能看懂其他语言里JSON解析库的核心逻辑。
1. 先搞清楚解析器到底在干嘛
1.1 字节流、字符串和内存结构的关系
你在文件里看到的{"age": 18},本质上是文件里的一串字节。JSON规范里默认UTF-8编码,所以这个字符串在存储层面其实是这些字节:7b 22 61 67 65 22 3a 20 31 38 7d。其中7b是{,22是双引号,61是a,67是g,65是e,3a是冒号,20是空格,31是1,38是8,7d是}。
解析器的工作,就是逐字节、逐字符扫过去,一边扫一边判断“我现在读到哪了”“这个字符是什么意思”“接下来该期待什么”。等扫完整个输入,它得在内存里构建出一个程序员真正想要的东西——一个键是字符串、值是各种数据类型、可以快速按键查找的结构。放到Python里就是dict(哈希表),放到JavaScript里就是Object,放到Java里可能就是HashMap。
这个从“字节序列”到“内存结构”的映射,就是解析器存在的全部意义。你可以把它理解成一个翻译官:机器收到的是按协议编码的字节流,业务代码需要的是能直接操作的数据结构,中间这层翻译,就是解析器干的活。
1.2 把{"age": 18}拆成语法零件
在写任何解析代码之前,得先搞清楚JSON到底由哪些语法零件构成。JSON的核心语法其实非常少,少到什么程度呢,一页纸能说完:
- 对象:
{}包裹,里面是键值对,键和值之间用:分隔,键值对之间用,分隔 - 数组:
[]包裹,里面是值,用,分隔 - 键:必须是带双引号的字符串
- 值:可以是字符串、数字、布尔值、null、对象、数组
所以{"age": 18}的语法分解是:一个对象,包含一个键值对。键是字符串age,值是数字18。
解析器的工作流程通常可以分成两个阶段。第一阶段叫词法分析,把输入拆成一个个token,比如{、"age"、:、18、}。第二阶段叫语法分析,把这些token按照语法规则组装成内存结构。很多手写解析器会把这两个阶段合在一起,边扫边搭结构,这就是我们这篇要写的状态机做法。
2. 状态机:你的解析器得有张状态地图
2.1 状态、转移条件和动作三件套
状态机这个词听着唬人,实际想法很简单。它就回答三个问题:我现在在哪个状态?我遇到了什么输入?我要做什么动作并且跳到哪个状态?
举一个生活例子。电梯门的状态机:门有“打开”和“关闭”两个状态。遇到“有人按开门键”这个输入,门从“关闭”跳到“打开”;遇到“门已开且超时”这个输入,门从“打开”跳回“关闭”。中间还可以有“正在开门”“正在关门”这种中间状态。电梯程序就是靠这套状态机逻辑在跑,它永远不会说“我同时是打开和关闭的”,因为状态是互斥的。
解析器也一样。一次解析过程中,同一个时刻一定处在某个确定的状态。状态决定了我看到下一个字符时该怎么反应。这比“想到哪写到哪”的解析代码要稳得多,因为逻辑是闭环的:每个状态都有明确的进入条件、退出条件和异常处理。
2.2 JSON对象解析的状态设计
针对{"age": 18}这种最简单对象,要设计哪些状态?我自己实践下来,最少需要这些:
| 状态 | 含义 | 合法输入 | 转移目标 |
|---|---|---|---|
| EXPECT_KEY | 期待对象键的开始 | " | READ_KEY |
| READ_KEY | 正在读取键名 | 任意字符、" | EXPECT_COLON |
| EXPECT_COLON | 期待冒号 | : | EXPECT_VALUE |
| EXPECT_VALUE | 期待值 | 数字、"、{、[、字母 | 对应的值读取状态 |
| EXPECT_COMMA_OR_END | 值读取完毕,期待逗号或右括号 | ,、} | EXPECT_KEY 或 完成 |
这五个状态,加上一个初始状态和一个结束状态,就是解析单个JSON对象所需的全部状态地图。你会发现,解析器之所以能不出错,关键在于“状态决定期待”:在EXPECT_KEY状态下看到:就是错误,在EXPECT_VALUE状态下看到{意味着进入嵌套对象。
设计状态的时候有一个经验:每个状态只负责一件特别小的事。比如不要设计一个叫“读取键值对”的大状态,它会让你在代码里到处塞条件分支。状态拆得越细,代码越好写,错误也越好定位。
2.3 为什么状态机特别适合JSON
JSON的设计恰好是状态机的好朋友,因为它的语法是“严格嵌套”的:一个{必须对应一个},一个[必须对应一个],一个值要么是原子(数字、字符串、布尔),要么是复合体(对象、数组)。这种递归结构天然可以用状态来描述。
对比一下用正则表达式解析JSON。正则能匹配字符串,但遇到嵌套结构就彻底没辙,因为正则本质上是有限状态机,无法处理无限嵌套的递归语法。而手写状态机或递归下降解析器,则可以轻松应对任意深度。这也是为什么造JSON解析器基本都用手写,而不是用正则库硬扛。
3. 手写极简状态机解析器
3.1 纯状态机代码:解析单个对象
下面这份代码,是“教科书级”的状态机写法——整个解析过程只有一个主循环,靠一个state变量驱动,没有任何递归调用。它只处理单层对象、字符串键、整数和浮点数值,但足够说明状态机的核心机制。
def parse_single_object(text): i = 0 n = len(text) state = "EXPECT_KEY" key = None result = {} while i < n: c = text[i] if c in ' \t\n\r' and state != "READ_KEY_STRING": i += 1 continue if state == "EXPECT_KEY": if c == '"': key = "" state = "READ_KEY_STRING" i += 1 else: raise ValueError(f"line 1: expected '\"' to start key, got '{c}'") elif state == "READ_KEY_STRING": if c == '"': state = "EXPECT_COLON" i += 1 else: key += c i += 1 elif state == "EXPECT_COLON": if c == ':': state = "EXPECT_VALUE" i += 1 else: raise ValueError("line 1: expected ':' after key") elif state == "EXPECT_VALUE": num_str = "" while i < n and (text[i].isdigit() or text[i] in '.-+eE'): num_str += text[i] i += 1 if num_str == "": raise ValueError(f"line 1: expected value, got '{c}'") if '.' in num_str or 'e' in num_str or 'E' in num_str: result[key] = float(num_str) else: result[key] = int(num_str) state = "EXPECT_COMMA_OR_END" elif state == "EXPECT_COMMA_OR_END": if c == ',': state = "EXPECT_KEY" i += 1 elif c == '}': return result else: raise ValueError(f"line 1: expected ',' or '}}', got '{c}'") else: raise RuntimeError(f"unreachable state: {state}") raise ValueError("line 1: unterminated JSON object, missing '}'")调用方式:
data = parse_single_object('{"age": 18}') print(data) # {'age': 18} print(type(data)) # <class 'dict'> print(data["age"]) # 18这段代码严格用状态机思想驱动:没有递归,没有回溯,就是一个while循环从第一个字符走到最后一个字符。所有状态转移都在循环体内完成,遇到非法输入直接抛异常。
3.2 逐步跟踪:一个字符一个字符看状态变化
现在拿{"age": 18}实际走一遍状态机的运转,每个关键步奏都展开:
| 步骤 | 当前位置 | 字符 | 当前状态 | 做了什么 | 下一个状态 |
|---|---|---|---|---|---|
| 1 | 0 | { | EXPECT_KEY | 检查是否引号?不是,报错?等等,这里出现了一个问题 |
等等,我上面的代码在EXPECT_KEY状态下遇到{会直接报错。因为我先跳过了对象开头的大括号。实际解析时,最外层的大括号需要被消费掉。修正一下:在进入主循环之前先消费掉开头的{。
其实严谨的写法是,主循环开始前先做一次初始状态判断:第一个非空字符必须是{。然后进入状态机。修正后的顺序是这样:
def parse_single_object(text): i = 0 n = len(text) while i < n and text[i] in ' \t\n\r': i += 1 if i >= n or text[i] != '{': raise ValueError("expected '{'") i += 1 state = "EXPECT_KEY" key = None result = {} while i < n: c = text[i] # ... 上面的状态机循环好,我们用修正后的代码来跟踪:
| 步骤 | 位置 | 字符 | 当前状态 | 关键动作 |
|---|---|---|---|---|
| 0 | 0 | { | 初始 | 消费开括号,建空dict{} |
| 1 | 1 | " | EXPECT_KEY | 键开始,key置空串 |
| 2 | 2 | a | READ_KEY_STRING | 拼进key,key="a" |
| 3 | 3 | g | READ_KEY_STRING | 拼进key,key="ag" |
| 4 | 4 | e | READ_KEY_STRING | 拼进key,key="age" |
| 5 | 5 | " | READ_KEY_STRING | 键结束,key定稿="age" |
| 6 | 6 | : | EXPECT_COLON | 消费冒号 |
| 7 | 7 | EXPECT_VALUE | 空格跳过 | |
| 8 | 8 | 1 | EXPECT_VALUE | 开始读数字,拼1 |
| 9 | 9 | 8 | EXPECT_VALUE | 拼数字,数字="18" |
| 10 | 10 | } | EXPECT_COMMA_OR_END | 写哈希表 result["age"]=18,返回 |
第8步比较关键。数字读取是状态机里的“子循环”,它在EXPECT_VALUE状态下出现,连续吞掉所有数字字符,直到遇到非数字字符(这里是})才停下来。这个过程中状态机不跳转,因为还在读同一个值。
第10步是点题的地方:当}到来并且状态是EXPECT_COMMA_OR_END,说明整个对象已经完整读完,把累计了半天的key和value真正写入哈希表。这里就是字节流变成内存结构的临门一脚。
3.3 哈希表到底是怎么被填出来的
我遇到不少初学者问:解析器是不是解析到一个{立刻就创建哈希表?答案是:对象开始就创建空哈希表,但键值对不是边读边写入的,而是等一个键值对完整读取完毕后一次性写入。
原因是,如果你在键读到一半就写入,那写入的键是残的。如果值没读完就写入,那写入的值是错的。所以状态机的设计选择是:key先缓存到一个变量里,value也缓存起来,等读到}或,时才算一个键值对“定型”了,此时才执行result[key] = value。这种“暂存、定型、提交”三步走的模式,在处理嵌套对象时同样适用,只是暂存的不再是单个变量,而是一个调用栈。
哈希表的最终状态,就是result这个dict变量。打印它,你能看到内存里确实是{'age': 18},可以data["age"]直接取值,也可以"age" in data快速判断键存在。这些操作全部基于哈希表,时间复杂度是O(1)级别。解析的终极目的,就在这一步达成。
4. 从玩具到真家伙:完整JSON支持要补什么
4.1 字符串值、转义和unicode
真实JSON里,字符串值不光在键的位置出现,也在值的位置出现。而且字符串里可能含有转义字符:\"、\\、\n、\t、\r、\b、\f、\/,还有unicode转义\uXXXX。
极简版状态机里,我把键的读取写死成了“字符拼到key直到引号”,这个逻辑扩展成通用字符串读取函数,再处理一下双引号内的转义即可。特别要说的是\uXXXX的处理,它需要把4位十六进制转成真正的unicode字符。例如"\u4f60"就是“你”。这里要小心代理对的问题,如果遇到\uD83D\uDE00这种emoji代理对,简单实现可以不做合并,直接当成两个独立字符处理;但如果你的下游业务需要真正的emoji,就得把两个代理项合并成一个完整字符。
字符串拼接我建议用list收集再join,直接key += c在Python里虽然方便,但字符串是不可变对象,每次拼接都会创建新字符串,解析大文件时性能会明显下降。list收集再join是手写解析器的常规优化。
4.2 数字解析:负数、小数、科学计数法
JSON的数字语法是:-?(0|[1-9]\d*)(\.\d+)?([eE][+-]?\d+)?。翻译成人话:可选的负号,整数部分不能以0开头(除非整节就是0),可以有小数部分,可以有指数部分。
这里有几个容易踩的坑。第一,01是非法JSON,整数部分不能有前导零。第二,1.是非法JSON,小数点后面必须跟数字。第三,.5是非法JSON,小数点前面必须有数字。第四,1e是非法JSON,指数后面必须跟数字。如果你用Python的float()去转换,它很宽容,会把1.转成1.0,但这不等于输入合法。所以解析器必须自己在语法层面校验格式。
我的经验是写一个独立函数处理数字,内部用一个小的状态机:整数部分状态、小数部分状态、指数部分状态。每一步检查当前字符是否属于该状态的合法字符集。遇到非法字符就报错,并带上位置信息。
4.3 嵌套对象和数组:状态机加一个栈
真实JSON没有我们上面那么乖,一定有嵌套:对象套数组,数组套对象,对象套对象,深度可能几十层。纯状态机的“单一状态变量”就不好使了,因为你需要记住“我从哪来的”“处理完这个子结构回到哪去”。
两种常见方案。第一种是递归下降解析:写一个parse_value函数,里面遇到{就调用parse_object,遇到[就调用parse_array。每个函数都负责解析一个完整结构。递归下降本质上是状态机的结构化解法,每一个函数对应一个非终结符。我之前极简版的状态机,其实就是parse_object这个函数的一种线性展开。
第二种是显式用栈:手工维护一个调用栈,遇到{[就入栈,遇到}]就出栈。栈里保存父对象的引用、父数组的引用,以及当前状态下key的暂存。很多性能敏感的手写解析器会用显式栈代替递归,因为递归有调用深度限制,碰到恶意构造的超深嵌套JSON可能爆栈。
完整版代码我就不全部贴了,但核心结构是:主循环里维护一个stack列表,栈顶是“当前正在构建的容器”和“回到父容器时需要执行的动作”。比如解析到{ "person": { "name": "A" } }时,遇到外层对象里的{就创建新dict,把新dict挂到person键上,然后把新dict和“目前状态”压栈,继续读里层对象。等读到内层对象的},弹栈,回到外层对象的状态,更新外层容器。
这里给出伪代码,帮你建立骨架感:
def parse_json(text): i = 0 stack = [] # 每个元素是 (container, action) current = None # 当前正在构建的容器 current_key = None state = "START" while i < len(text): c = text[i] if state == "START": if c == '{': current = {}; stack.append(current); state = "OBJECT" elif c == '[': current = []; stack.append(current); state = "ARRAY" elif state == "OBJECT": # 期待键 ... elif state == "ARRAY": # 期待值 ... # 处理嵌套、逗号、冒号、结束这个结构再加一个parse_value逻辑,就是完整JSON解析器的骨架。真的是骨架,因为细节都在状态处理里,但方向是对的。
4.4 布尔值和null支持
这仨是锦上添花的存在。遇到字母t,期望后面跟着true;遇到f,期望false;遇到n,期望null。实现方式是:截取当前位置往后的定长字符串,和关键字比对,不匹配就报错。注意必须整体匹配且后面紧跟非字母数字字符,防止把truex也当成合法true。
这块逻辑最简单,但反而容易出bug,因为比字符串需要多一个“越界”判断:如果JSON文件在true后面直接截断,text[i:i+4]可能只有两个字符,比较会失败,要正确处理。
5. 解析器调试与踩坑心得
5.1 常见的解析失败类型
手写解析器,最怕的不是写不出来,而是写出来之后遇到各种诡异输入。我把这几个月写JSON解析器过程中遇到的高频bug按“症状”分了几类,方便你排查时对照:
| 症状 | 可能原因 | 排查思路 |
|---|---|---|
解析到一半报错,说期望: | 键后面多了空格/换行没跳过 | 检查跳过空白逻辑是不是所有状态都覆盖了 |
| 解析大文件时特别慢 | 字符串拼接用了+=,或者递归函数反复拷贝子串 | 字符串拼接改用list,子串处理改用索引区间 |
| 嵌套很深时程序崩溃 | 递归深度超过语言限制 | 改用显式栈,或加一个最大深度限制提前报错 |
数字1e-5解析成1.0 | 指数部分解析逻辑有bug | 单独为数字写状态机,指数符号和指数位数都要处理 |
| 中文键解析成乱码 | 编码问题 | 输入先转成str,明确编码是UTF-8 |
我觉得最重要的一条排查经验是:先写一个小巧的“打印当前状态和当前字符”调试函数,解析每个测试输入时把每次状态转移都打出来。人眼看着打印结果,就能立刻发现在哪个状态少了转移分支。这个调试法虽然土,但比上来就用调试器逐步断点要快得多。
5.2 性能思考:解析器的瓶颈在内存而不在CPU
很多人以为解析器性能瓶颈在CPU运算,实际在内存分配。解析JSON时,每个字符串值都要分配内存,每个对象都要创建哈希表,每个数组都要创建列表。分配次数越多,GC压力越大,性能越差。
一个经典优化是:能用索引区间表示子串就别切片。比如数字18在源字符串里的区间是[8,10),如果你直接切片就会创建新字符串,消耗一次分配。极简版代码里我用的是逐字符拼接数字字符串,这个性能不好。正确做法是先记下起始位置,扫描结束再用text[start:end]切片只创建一次新字符串。对于大数字尤其有效。
另一个优化是“流式解析”概念:解析器不一定非得一次性把整个JSON加载进内存再解析。真正的生产级解析器支持增量解析,读一段流就解析一段,构建出来的结构逐步补全。这个复杂,但方向值得知道。对我们手写小解析器,核心的优化就是减少内存分配次数。
5.3 怎么给自己的解析器写测试
写解析器不写测试,等于在悬崖边开车。我的经验是准备三组测试数据:合法样例、非法样例、边界样例。合法样例覆盖所有JSON类型:字符串、数字、数组、对象、布尔、null、嵌套。非法样例覆盖最容易出错的点:缺括号、缺逗号、键没加引号、值后面多了逗号。边界样例覆盖:空对象{}、空数组[]、空字符串""、负数-1、小数0.5、科学计数法1e10、深层嵌套{"a":{"b":{"c":1}}}。
每次改动解析器逻辑,把这三组测试全跑一遍。手动造一个测试护城河,比什么都管用。我曾经因为改了转义字符串的解析逻辑,修好了\n却把\uXXXX搞坏了,如果没有边界测试,这种回归bug会在生产环境里炸得很惨。
6. 一点个人经验
写JSON解析器这事,很多人觉得无非是调库,但真正动手写一遍,对“字节流变成结构”的理解会完全不同。我第一次把状态机跑通、看到{"age": 18}变成{'age': 18}的时候,有点豁然开朗的感觉,原来程序里那些看似神奇的结构化读取,底层就是这么一步步搬砖搬出来的。
最后分享一个特别实用的建议:如果要在生产环境解析JSON,直接用成熟库就好,标准库的c实现性能高、坑少。手写解析器的意义不在“取代库”,而在学习。写一遍,你就具备了给自定义配置文件写解析器的能力,遇到项目里奇奇怪怪的文本格式也不用发怵。后续想继续深入,可以试着给解析器加上行号、列号的错误定位,或者把解析结果序列化回去,那个方向又是另一片天地了。