综合实战:实现一个简易的 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 解析器核心流程 JSON 字符串输入 跳过空白字符 判断首字符 解析对象 解析数组 解析字符串 解析字面量 返回 JsonNode* 解析子元素时递归调用自身

核心解析函数实现

先写一个解析入口函数。它接收 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 解析器,我建议你:

  1. 先支持最简单的类型:只解析字符串和数字,不处理嵌套
  2. 再加数组和对象:这时候链表就派上用场了
  3. 最后加错误处理:比如遇到非法字符时返回 NULL,并输出错误位置

每完成一步,都写个测试用例跑一遍。我在项目中就是这么干的,每加一个功能就跑一遍所有测试,确保没把之前的功能搞坏。

小技巧:调试指针问题时,可以在关键位置打印指针地址和节点类型。比如 printf("node %p, type %d\n", node, node->type);。这比单步调试快多了。

总结

这一章我们干了一件实事:用 C 语言实现了一个简易的 JSON 解析器。你看到了指针如何穿梭于递归调用之间,动态内存如何支撑起不确定深度的嵌套结构,链表如何优雅地管理变长数据,回调函数又如何让解析器变得灵活可扩展。

这些东西,单独拎出来都不难。但组合在一起,就构成了一个完整的、可工作的系统。这就是 C 语言的魅力——给你足够的控制力,也给你足够的责任。

下次你看到 JSON 解析库的源码时,不妨想想我们今天聊的这些。你会发现,那些看似高深的代码,底层逻辑其实和我们写的这个简易版如出一辙。


公众号:蓝海资料掘金营,微信deep3321