30、综合项目:基于数组与字符串的 JSON 解析器雏形

好,终于到了最后一章。说实话,每次带项目课讲到这,我都挺感慨的。前面29章我们啃了数组、字符串、指针、结构体……现在,是时候把这些东西串起来了。

这个项目叫「JSON 解析器雏形」。别被名字吓到,我们不做完整的工业级解析器——那个太复杂了。我们要做的,是解析一个简化版 JSON,只支持对象、数组、字符串和数字。嗯,够用就行。

30.1 项目目标与核心思路

先说说我们要干什么。JSON 长什么样?你肯定见过:

{
  "name": "Alice",
  "age": 25,
  "scores": [88, 92, 76],
  "info": {
    "city": "Beijing",
    "job": "Engineer"
  }
}

我们的解析器,要能读入这样的字符串,然后把它拆成 C 语言里的数据结构。说白了,就是把文本变成内存里的数组、结构体、嵌套关系。

核心思路其实就四个字:逐字符扫描。遇到 { 就进对象,遇到 [ 就进数组,遇到 " 就读字符串,遇到数字就解析数字。整个过程,全靠数组和字符串操作撑起来。

项目核心依赖:字符数组(存储输入)、字符串函数(strcmp、strcpy、strchr)、指针数组(存储键值对)、二维数组(存储嵌套层级)。

30.2 数据结构设计

我个人的习惯是,写解析器之前先把数据结构定死。数据结构定了,代码怎么写都不会跑偏。

我们定义这么几个东西:

// 值的类型
typedef enum {
    JSON_NULL,
    JSON_BOOL,
    JSON_NUMBER,
    JSON_STRING,
    JSON_ARRAY,
    JSON_OBJECT
} JsonType;

// 值节点
typedef struct {
    JsonType type;
    union {
        int   bool_val;
        double num_val;
        char* str_val;
        struct {
            struct JsonNode** items;
            int count;
        } array;
        struct {
            struct JsonNode** members;
            int count;
        } object;
    } value;
} JsonNode;

// 键值对(用于对象)
typedef struct {
    char* key;
    JsonNode* value;
} JsonMember;

你看,这里用了联合体 union,就是为了节省内存。一个节点要么是数字,要么是字符串,不会同时是。我在项目中遇到过有人用结构体硬扛,结果内存翻了三倍……没必要。

30.3 解析器核心流程

解析器的骨架,其实就是一个状态机。我画了张图,你一看就明白:

JSON 解析器核心流程 开始解析 跳过空白字符 判断当前字符类型 " → 解析字符串 数字 → 解析数字 { → 解析对象 [ → 解析数组 返回节点

流程不复杂,对吧?关键是每个分支里怎么处理细节。

30.4 关键函数实现

30.4.1 解析字符串

解析字符串,说白了就是找到两个引号之间的内容。但要注意转义字符,比如 \"\\

char* parse_string(const char** p) {
    // 跳过开头的引号
    (*p)++;
    // 用数组暂存字符
    char buf[256];
    int idx = 0;
    while (**p != '"' && **p != '\0') {
        if (**p == '\\') {
            (*p)++;
            switch (**p) {
                case '"':  buf[idx++] = '"'; break;
                case '\\': buf[idx++] = '\\'; break;
                case 'n':  buf[idx++] = '\n'; break;
                case 't':  buf[idx++] = '\t'; break;
                default:   buf[idx++] = **p; break;
            }
        } else {
            buf[idx++] = **p;
        }
        (*p)++;
    }
    buf[idx] = '\0';
    (*p)++; // 跳过结尾引号
    // 动态分配内存并返回
    char* result = malloc(idx + 1);
    strcpy(result, buf);
    return result;
}

这里有个坑:buf 用了固定大小 256。如果 JSON 里有个超长字符串怎么办?我曾经在解析一个配置文件时遇到过,字符串长度超过 1024,直接缓冲区溢出。后来我改成了动态扩容——但作为教学项目,256 够用了。

30.4.2 解析数字

数字解析相对简单,但要注意负号和小数点。

double parse_number(const char** p) {
    double result = 0.0;
    int sign = 1;
    if (**p == '-') {
        sign = -1;
        (*p)++;
    }
    // 整数部分
    while (**p >= '0' && **p <= '9') {
        result = result * 10 + (**p - '0');
        (*p)++;
    }
    // 小数部分
    if (**p == '.') {
        (*p)++;
        double frac = 0.0;
        double div = 10.0;
        while (**p >= '0' && **p <= '9') {
            frac += (**p - '0') / div;
            div *= 10;
            (*p)++;
        }
        result += frac;
    }
    return sign * result;
}

你想想看,如果输入是 "3.14",这个函数会先解析出 3,然后遇到小数点,再解析出 0.14,加起来就是 3.14。嗯,逻辑很直白。

30.4.3 解析数组

数组解析要用到动态数组。我们先用一个固定大小的数组暂存,满了再扩容。

JsonNode* parse_array(const char** p) {
    (*p)++; // 跳过 '['
    JsonNode* arr = malloc(sizeof(JsonNode));
    arr->type = JSON_ARRAY;
    arr->value.array.items = NULL;
    arr->value.array.count = 0;
    int capacity = 0;

    skip_whitespace(p);
    if (**p == ']') {
        (*p)++;
        return arr; // 空数组
    }

    while (1) {
        skip_whitespace(p);
        // 扩容
        if (arr->value.array.count >= capacity) {
            capacity = capacity == 0 ? 4 : capacity * 2;
            arr->value.array.items = realloc(arr->value.array.items,
                                              capacity * sizeof(JsonNode*));
        }
        // 递归解析元素
        arr->value.array.items[arr->value.array.count++] = parse_value(p);
        skip_whitespace(p);
        if (**p == ',') {
            (*p)++;
        } else if (**p == ']') {
            (*p)++;
            break;
        }
    }
    return arr;
}

注意这里调用了 parse_value——这是一个总入口函数,它会根据当前字符决定调用哪个子解析器。这就是递归下降解析的精髓。

30.5 完整解析流程演示

假设我们要解析这个 JSON:

{"name": "Bob", "age": 30}

解析过程是这样的:

  1. parse_value 遇到 {,调用 parse_object
  2. parse_object 读键 "name",调用 parse_string
  3. 遇到 :,读值 "Bob",再次调用 parse_string
  4. 遇到 ,,继续读下一个键值对
  5. 读键 "age",遇到 :,读值 30,调用 parse_number
  6. 遇到 },解析结束

整个过程,数组和字符串操作贯穿始终。你想想看,如果没有数组来暂存字符,没有字符串函数来比较键名,这个解析器根本写不出来。

调试小技巧:在解析过程中,可以打印当前解析到的位置和字符。我习惯在每个解析函数入口加一句 printf("parse: %c\n", **p);,这样一眼就能看出解析到哪里卡住了。

30.6 避坑指南

我曾经踩过的坑:

  • 内存泄漏:解析完 JSON 后,一定要递归释放所有节点。我早期写解析器时忘了释放字符串,跑一次内存涨几 KB,跑一晚上服务器直接 OOM。
  • 递归深度:JSON 嵌套太深会导致栈溢出。C 语言的函数调用栈一般只有 1-8 MB。如果 JSON 有 10000 层嵌套,你的程序会直接崩掉。解决方案是用迭代代替递归,或者限制最大深度。
  • 字符串边界:解析字符串时,一定要检查 \0 终止符。如果 JSON 字符串不完整(比如缺少结尾引号),你的解析器可能会读到非法内存。

30.7 项目扩展思路

这个解析器虽然叫「雏形」,但骨架已经搭好了。如果你想继续完善,可以从这几个方向入手:

扩展方向 实现思路 涉及知识点
支持布尔值和 null 遇到 true/false/null 时直接匹配字符串 strncmp、字符串匹配
支持 Unicode 转义 解析 \uXXXX 为 UTF-8 编码 位运算、字符编码
错误报告 记录解析位置和错误类型,返回给调用者 结构体、枚举、错误码
序列化(输出 JSON) 遍历节点树,递归输出字符串 递归、字符串拼接

我个人建议,先把这个雏形跑通,再考虑扩展。代码能跑起来,比什么都重要。

30.8 小结

这个项目,说白了就是把我们前面学的所有数组和字符串知识,揉在一起用了一次。你可能会觉得代码量有点大,但拆开看,每个函数都不复杂。

嗯,到这里,C 语言数组与字符串处理的内容就全部结束了。从最基础的数组声明,到指针、字符串函数,再到最后的 JSON 解析器——这条路我走了很多年,今天把它完整地讲给你听。

希望你在未来的项目中,能用好这些工具。遇到字符串处理的问题,别慌,回到数组和指针的本质上去想,答案往往就在那里。


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