综合实战:实现一个简易的 JSON 解析器
终于到了这一章。说实话,指针、动态内存、链表、回调函数,这些概念单独拎出来讲,大家都能听懂。但一放到实际项目里,很多人就懵了。我当年也是这样,学完链表觉得挺简单,结果第一次写 JSON 解析器,指针指来指去,把自己绕进去了。
这一章,我们就来干一件实在的事:手写一个简易的 JSON 解析器。它不追求完整覆盖 JSON 标准,但能解析常见的 key-value 结构,支持嵌套对象和数组。你会看到前面学过的所有知识点,在这里如何拧成一股绳。
JSON 解析器的核心挑战
JSON 解析难在哪?说白了就两点:
- 嵌套结构:对象套对象,数组套数组,深度不确定
- 动态长度:字符串长度、数组元素个数,编译时都不知道
这两点,恰好是 C 语言指针和动态内存的用武之地。你想想看,如果不用链表和动态分配,你怎么表示一个不确定深度的嵌套结构?用固定数组?那上限设多少都不够用。
核心思路:用一棵树来表示 JSON 结构。每个节点可以是对象、数组、字符串、数字、布尔值或 null。对象和数组节点下面挂子节点,形成递归结构。
数据结构设计
先定义节点类型。我习惯用枚举,清晰明了:
typedef enum {
JSON_NULL,
JSON_BOOL,
JSON_NUMBER,
JSON_STRING,
JSON_ARRAY,
JSON_OBJECT
} JsonType;
然后定义节点结构体。这里有个关键设计:对象和数组用链表存储子节点。为什么不用动态数组?因为链表插入方便,而且内存碎片少。我在项目中吃过动态数组频繁 realloc 的亏,后来全改成链表了。
typedef struct JsonNode {
JsonType type;
union {
int bool_val;
double num_val;
char *str_val;
struct {
struct JsonNode *head; // 子节点链表头
int count; // 子节点数量
} container;
} value;
struct JsonNode *next; // 链表指针,用于兄弟节点
} JsonNode;
嗯,这里要注意:next 指针是用来把同一层的节点串成链表的。比如一个对象里有三个 key,这三个 key 对应的节点就通过 next 连起来。
解析流程:状态机 + 递归下降
解析 JSON 字符串,我推荐用递归下降解析器。它和 JSON 的递归结构天然匹配。每个解析函数只负责解析一种类型,遇到嵌套就递归调用。
先画个流程图,帮你理清整体脉络:
核心解析函数实现
先写一个解析入口函数。它接收 JSON 字符串和当前位置指针,返回解析出的节点:
JsonNode* json_parse(const char **pos) {
skip_whitespace(pos);
switch (**pos) {
case '{': return parse_object(pos);
case '[': return parse_array(pos);
case '"': return parse_string(pos);
case 't': case 'f': case 'n':
return parse_literal(pos);
default:
if (**pos == '-' || (**pos >= '0' && **pos <= '9'))
return parse_number(pos);
return NULL; // 解析失败
}
}
这里有个细节:pos 是二级指针。为什么?因为解析完一个值后,位置指针要向前移动。如果传一级指针,函数内部修改不了外部的指针值。这个坑我踩过,当时 debug 了半天才发现是指针没更新。
解析对象:链表的典型应用
解析对象是展示链表操作的最佳场景。每个 key-value 对就是一个节点,用 next 串起来:
JsonNode* parse_object(const char **pos) {
JsonNode *node = malloc(sizeof(JsonNode));
node->type = JSON_OBJECT;
node->value.container.head = NULL;
node->value.container.count = 0;
node->next = NULL;
(*pos)++; // 跳过 '{'
skip_whitespace(pos);
if (**pos == '}') {
(*pos)++;
return node;
}
JsonNode *tail = NULL;
while (1) {
skip_whitespace(pos);
// 解析 key(必须是字符串)
JsonNode *key_node = parse_string(pos);
if (!key_node) { /* 错误处理 */ }
skip_whitespace(pos);
if (**pos != ':') { /* 错误处理 */ }
(*pos)++;
skip_whitespace(pos);
// 解析 value(递归调用)
JsonNode *val_node = json_parse(pos);
if (!val_node) { /* 错误处理 */ }
// 把 key 和 value 组装成一个 kv 节点
JsonNode *kv = malloc(sizeof(JsonNode));
kv->type = JSON_STRING; // 这里简化处理,实际应保留 key
kv->value.str_val = key_node->value.str_val;
kv->next = val_node;
free(key_node); // 只保留字符串,释放外壳
// 插入链表尾部
if (!node->value.container.head) {
node->value.container.head = kv;
} else {
tail->next = kv;
}
tail = kv;
node->value.container.count++;
skip_whitespace(pos);
if (**pos == '}') {
(*pos)++;
return node;
}
if (**pos != ',') { /* 错误处理 */ }
(*pos)++;
}
}
这段代码里,链表尾插法是个经典操作。我建议你记住这个模式:维护一个 tail 指针,每次插入后更新它。这样插入效率是 O(1),而不是 O(n)。
回调函数:让解析器更灵活
解析器写好了,怎么用?最直接的方式是遍历树。但如果我们想在解析过程中实时处理数据呢?比如边解析边打印,或者边解析边存入数据库。
这时候回调函数就派上用场了。我们可以给解析器注册回调,每解析完一个节点就调用一次:
typedef void (*JsonCallback)(JsonNode *node, void *user_data);
void json_parse_with_callback(const char *json,
JsonCallback cb,
void *user_data) {
const char *pos = json;
JsonNode *root = json_parse(&pos);
// 遍历树,对每个节点调用回调
traverse_and_call(root, cb, user_data);
// 别忘了释放内存
json_free(root);
}
回调函数的 user_data 参数是个万能口袋。你可以传文件指针、数据库连接、或者一个统计结构体。我在一个嵌入式项目里,就用这个参数传了一个环形缓冲区指针,边解析边把数据塞进缓冲区,省掉了中间存储的开销。
内存管理:谁分配谁释放
JSON 解析器里动态内存满天飞,一不小心就泄漏。我总结了一个原则:谁分配,谁释放。解析函数分配的内存,由调用者负责释放。
释放函数也是递归的:
void json_free(JsonNode *node) {
if (!node) return;
// 先释放子节点
if (node->type == JSON_OBJECT || node->type == JSON_ARRAY) {
JsonNode *child = node->value.container.head;
while (child) {
JsonNode *next = child->next;
json_free(child);
child = next;
}
}
// 释放字符串
if (node->type == JSON_STRING) {
free(node->value.str_val);
}
// 释放自身
free(node);
}
注意:释放链表时,一定要先保存 next 指针,再释放当前节点。否则释放后 next 就变成野指针了。我曾经在压力测试时遇到段错误,查了两天才发现是释放顺序搞反了。
实战建议:从小处着手
如果你打算自己写一个 JSON 解析器,我建议你:
- 先支持最简单的类型:只解析字符串和数字,不处理嵌套
- 再加数组和对象:这时候链表就派上用场了
- 最后加错误处理:比如遇到非法字符时返回 NULL,并输出错误位置
每完成一步,都写个测试用例跑一遍。我在项目中就是这么干的,每加一个功能就跑一遍所有测试,确保没把之前的功能搞坏。
小技巧:调试指针问题时,可以在关键位置打印指针地址和节点类型。比如 printf("node %p, type %d\n", node, node->type);。这比单步调试快多了。
总结
这一章我们干了一件实事:用 C 语言实现了一个简易的 JSON 解析器。你看到了指针如何穿梭于递归调用之间,动态内存如何支撑起不确定深度的嵌套结构,链表如何优雅地管理变长数据,回调函数又如何让解析器变得灵活可扩展。
这些东西,单独拎出来都不难。但组合在一起,就构成了一个完整的、可工作的系统。这就是 C 语言的魅力——给你足够的控制力,也给你足够的责任。
下次你看到 JSON 解析库的源码时,不妨想想我们今天聊的这些。你会发现,那些看似高深的代码,底层逻辑其实和我们写的这个简易版如出一辙。
公众号:蓝海资料掘金营,微信deep3321