202509
systems

剖析 sj.h:150 行 C99 状态机实现零分配 JSON 解析

深入 sj.h 源码,解析其如何用极简状态机与回调设计实现零内存分配的 JSON 流式解析,提供可复用的工程参数与调试技巧。

在嵌入式系统、高性能服务或内存受限环境中,传统 JSON 解析器因预分配内存、构建对象树或频繁堆操作而成为性能瓶颈。sj.h 的出现,为开发者提供了一种极致轻量的解决方案:一个仅 150 行 C99 代码的单头文件库,通过精巧的状态机设计与惰性求值,实现了真正的零分配(Zero Allocation)JSON 解析。本文将深入其核心实现,剖析其状态转移逻辑与用户回调机制,为你在资源敏感场景下提供可直接落地的解析策略。

sj.h 的核心哲学是“不分配、不存储、只遍历”。它不构建任何内存中的对象模型(如 DOM 树),而是将 JSON 文本视为一个字符流,由一个状态机驱动,在遇到特定 token(如数字、字符串、对象开始)时,立即通过回调通知用户,并提供该 token 在原始字符串中的起止指针。用户决定如何处理这些原始数据——是直接使用、复制,还是忽略。这种“SAX 式”的流式处理,使得内存占用恒定,与 JSON 文本大小无关,完美契合零分配要求。

其状态机的核心,封装在 sj_read 函数中。该函数维护一个 sj_Reader 结构,包含当前读取位置 cur、数据末尾 end 以及当前嵌套深度 depth。状态转移完全由当前字符 *r->cur 驱动,通过一个巨大的 switch 语句实现。例如,当遇到 " 字符时,状态机进入 SJ_STRING 状态,它不会分配新内存存储解码后的字符串,而是记录起始位置 res.start = ++r->cur,然后逐字符遍历,处理转义字符(如 \\),直到遇到下一个未转义的 "。此时,它记录结束位置 res.end = r->cur++ 并返回,将原始字符串片段的指针交还给用户。对于数字、布尔值和 null,处理方式类似,仅验证格式并返回原始字符范围。对于对象 { 和数组 [,状态机递增深度计数器 r->depth 并返回 SJ_OBJECTSJ_ARRAY 类型,标志着一个新层级的开始。

为了方便用户遍历复杂结构,sj.h 提供了 sj_iter_arraysj_iter_object 两个高层迭代器。它们并非独立的状态机,而是对 sj_read 的封装。其核心是一个名为 sj__discard_until 的内部函数。当用户调用 sj_iter_array 时,该函数会持续调用 sj_read,丢弃所有 token,直到读取器的当前深度 r->depth 与传入的数组值 arr.depth 相等,这意味着我们已经“跳过”了前一个元素,回到了数组层级。接着,它读取下一个值并返回。如果遇到 SJ_END(即 ])或 SJ_ERROR,则迭代结束。对象的迭代逻辑相同,但在读取一个键之后,必须紧接着读取其对应的值,否则会报错。这种设计将状态管理的复杂性隐藏在库内部,为用户提供了简洁的 API,同时保持了底层零分配的特性。

在实际工程中应用 sj.h,关键在于理解其“指针即数据”的范式。用户必须自行管理从 sj_Value 结构中 startend 指针提取的数据。例如,要获取一个字符串的真实内容,你需要手动分配内存并复制 startend 之间的字符(记得处理转义)。对于数字,你可以使用 strtodstrtol 将其转换为你需要的类型。这种设计虽然增加了用户端的复杂度,但赋予了最大的灵活性和性能控制权。一个典型的使用模式是:初始化 sj_Reader,在一个循环中调用 sj_read,根据返回的 sj_Value.type 分发到不同的处理函数,在处理函数中按需复制或转换数据,最后在遇到 SJ_ENDSJ_ERROR 时退出循环。

尽管 sj.h 设计精妙,但其极简主义也带来了局限。它不进行语义验证,例如,它允许在对象中出现重复的键,或在数组中混合不同类型,这些都需由用户层处理。错误处理也相对基础,主要通过 r->error 字符串指示,配合 sj_location 函数获取行列号进行调试。此外,它不支持注释或非标准 JSON 扩展。对于需要完整对象模型或复杂查询的应用,它并非最佳选择。然而,对于日志解析、配置文件读取、网络协议载荷处理等场景,sj.h 提供的零开销、高吞吐量解析能力是无可替代的。其源码本身就是一个绝佳的学习范例,展示了如何用最朴素的 C 语言工具——指针、循环和 switch——构建出高效、优雅的系统级组件。