在火车上写 JSON 解析器是一个绝佳的选择,因为它不需要外部依赖,且逻辑非常严密。 解析 JSON 的核心思想是**递归下降解析(Recursive Descent Parsing)**。你可以把它想象成一个“吃字符串”的过程:根据当前看到的第一个字符,决定下一步怎么走。 --- ### 1. 核心架构设计 首先,你需要定义一个类来存储 JSON 的各种类型。在现代 C++ 中,建议使用 `std::variant`。 ```cpp #include #include #include #include #include // 定义 JSON 的各种数据类型 struct JsonValue; using JsonObject = std::map; using JsonArray = std::vector; struct JsonValue { std::variant data; // 方便的构造函数 JsonValue() : data(nullptr) {} // ... 其他构造函数 }; ``` --- ### 2. 解析逻辑:递归下降 你需要一个解析类,维护一个当前处理到的“指针”(或迭代器)。 #### 逻辑流程图: **解析伪代码思路:** 1. **跳过空白符:** 忽略空格、换行、制表符。 2. **主解析函数 `parse_value()`:** * 看到 `[` → 调用 `parse_array()` * 看到 `{` → 调用 `parse_object()` * 看到 `"` → 调用 `parse_string()` * 看到 `t`/`f` → 调用 `parse_boolean()` * 看到 `n` → 调用 `parse_null()` * 看到数字或 `-` → 调用 `parse_number()` #### 关键实现:解析对象(Object) ```cpp JsonValue parse_object() { JsonObject obj; consume('{'); // 消耗掉左大括号 skip_whitespace(); if (peek() == '}') { consume('}'); return JsonValue(obj); } while (true) { std::string key = parse_string().as_string(); // 解析键 skip_whitespace(); consume(':'); // 消耗冒号 obj[key] = parse_value(); // 递归解析值 skip_whitespace(); if (peek() == ',') { consume(','); skip_whitespace(); } else if (peek() == '}') { consume('}'); break; } } return JsonValue(obj); } ``` --- ### 3. 火车上的挑战点(你可以深挖的地方) 在火车上,你可以重点攻克以下几个难点,这能体现你的 C++ 功底: * **数值解析:** 不要简单地用 `atof`。尝试手动处理科学计数法(如 `1.23e-10`),处理负号、小数点。 * **转义字符:** 当字符串里出现 `\"`, `\\`, `\n` 时,如何正确存入内存? * **错误处理:** 如果 JSON 格式写错了(比如少个逗号),你的解析器是直接崩溃,还是能抛出一个带行号的 `exception`? * **性能优化(进阶):** 尝试使用 `std::string_view` 来减少解析过程中的字符串拷贝,这是现代 C++ 性能优化的核心。 --- ### 🛠️ 开发建议 1. **先写一个 `peek()` 和 `consume()`:** * `peek()`:看一眼当前的字符,但不移动指针。 * `consume(char c)`:检查当前字符是否为 `c`,如果是则移动到下一个,不是则报错。 2. **测试用例:** 准备一个最复杂的 `test.json`,包含嵌套的对象、数组、各种类型的混合。 **如果你准备动工,需要我为你提供一个完整的 `JsonValue` 类定义模板,还是你先尝试自己写一下基础框架?** 既然要手撸解析器,第一步确实得把 JSON 的“骨架”摸清楚。JSON(JavaScript Object Notation)本质上就是一种**嵌套的键值对结构**,它的美感在于极其有限的规则。 --- ### 1. JSON 的 6 种基本数据类型 一个 JSON 文件里,无论多复杂,都逃不出这 6 种东西: 1. **Object(对象/映射):** 用 `{}` 包围,里面是 `key: value` 形式。 2. **Array(数组):** 用 `[]` 包围,里面是值的列表。 3. **String(字符串):** 必须用双引号 `"` 包围,支持转义(如 `\n`)。 4. **Number(数字):** 整数或浮点数,支持科学计数法。 5. **Boolean(布尔):** 只有 `true` 或 `false`。 6. **Null:** 只有一个值 `null`。 **举个例子:** ```json { "name": "Gemini", "is_ai": true, "version": 3.5, "tags": ["fast", "helpful"], "stats": { "cpu": 8, "ram": "16GB" } } ``` --- ### 2. 解析思路:递归下降(核心逻辑) 解析 JSON 最经典的方法是**“递归下降解析”**。它的核心逻辑就像是在走迷宫:你站在当前字符的位置,根据看到的符号决定走哪个房间。 #### 核心步骤流程: **第一步:词法分析(Tokenize)——可选** 把整个字符串切成一个个有意义的单位(Token),比如 `{`、`[`、`"name"`、`123`。但在火车上为了省事,可以直接在原字符串上操作。 **第二步:语法解析(Parsing)——核心** 你需要写一个主函数 `parse_value()`,它的逻辑如下: 1. **跳过空白:** 循环跳过空格、`\n`、`\t`。 2. **看第一个字符:** * 如果是 `{`:**递归**进入 `parse_object()`。 * 如果是 `[`:**递归**进入 `parse_array()`。 * 如果是 `"`:进入 `parse_string()`,直到遇到下一个不被转义的 `"`。 * 如果是 `t` 或 `f`:匹配是不是 `true` 或 `false`。 * 如果是 `n`:匹配是不是 `null`。 * 如果是 `-` 或 `0-9`:进入 `parse_number()`。 --- ### 3. 如何处理递归? 这是最妙的地方。当你在解析一个 **Object** 时,遇到 `key: value` 中的 `value`,你并不需要知道它是数字还是另一个对象,你只需要再次调用 **`parse_value()`**。 **伪代码逻辑:** ```cpp // 伪代码:解析数组 JsonArray parse_array() { consume('['); // 吃掉左括号 JsonArray arr; while (peek() != ']') { // 关键点:数组里的元素可以是任何类型,所以递归调用 arr.push_back(parse_value()); if (peek() == ',') consume(','); // 吃掉逗号继续循环 } consume(']'); // 吃掉右括号 return arr; } ``` --- ### 4. 火车上撸代码的实战建议 1. **数据结构设计:** 使用 `std::variant, map>` 来表示 `JsonValue`。这是 C++17 的利器,能非常优雅地存储不同类型。 2. **错误捕获:** 如果解析到一半发现格式不对(比如 `{"key": }`),你可以抛出一个异常,打印出当前指针的位置。 3. **先从简单的写起:** * 第一阶段:能解析 `true` / `false` / `null`。 * 第二阶段:能解析字符串和数字。 * 第三阶段:实现 `parse_array`。 * 第四阶段:实现最复杂的 `parse_object`。 **你想先从定义存储 JSON 的 `JsonValue` 类开始,还是直接写那个“吃字符串”的主循环逻辑?我可以给你一个起步的 C++ 框架。** 在火车上写项目,一个清晰、标准的目录结构能帮你理清思路,即使没有 IDE 的高级功能,靠 `g++` 或 `cmake` 也能轻松编译。 推荐一个**轻量级但专业**的 C++ 项目结构: --- ### 📂 项目文件结构:`TinyJSON` ```text TinyJSON/ ├── .gitignore # 忽略编译生成的 .exe, .o, build/ 文件夹 ├── CMakeLists.txt # 项目构建脚本(如果用 CMake) ├── Makefile # 如果不用 CMake,手写编译命令也可以 ├── README.md # 项目说明,记录你的解析思路 ├── include/ # 头文件目录 │ └── tinyjson.hpp # 核心类 JsonValue 的定义 ├── src/ # 源代码目录 │ ├── main.cpp # 程序入口(用于测试) │ └── tinyjson.cpp # 解析逻辑的具体实现 └── tests/ # 测试用例目录 └── test_cases.json # 准备一些各种格式的 JSON 文件用来喂给程序 ``` --- ### 核心代码骨架预览 为了让你直接开工,我帮你把最关键的两个文件草图勾勒出来: #### 1. `include/tinyjson.hpp` 这是你的“蓝图”。在火车上,建议把所有数据类型定义好。 ```cpp #pragma once #include #include #include #include #include namespace tinyjson { struct JsonValue; // 使用 C++17 的 variant 存储 6 种 JSON 类型 using Object = std::map; using Array = std::vector; using ValueData = std::variant; struct JsonValue { ValueData data; // 构造函数 JsonValue() : data(nullptr) {} JsonValue(bool v) : data(v) {} JsonValue(double v) : data(v) {} JsonValue(std::string v) : data(std::move(v)) {} // ... 其他构造函数 // 序列化:将内存中的对象转回字符串 std::string serialize() const; }; // 解析器类 class Parser { public: explicit Parser(std::string source) : src(std::move(source)), pos(0) {} JsonValue parse(); // 主入口 private: std::string src; size_t pos; void skip_whitespace(); char peek(); char consume(); JsonValue parse_value(); JsonValue parse_string(); JsonValue parse_number(); // ... parse_object, parse_array }; } // namespace tinyjson ``` #### 2. `src/main.cpp` 这是你验证成果的地方。 ```cpp #include "tinyjson.hpp" #include int main() { std::string test_json = "{\"name\": \"Gemini\", \"score\": 100, \"is_cool\": true}"; tinyjson::Parser parser(test_json); try { tinyjson::JsonValue val = parser.parse(); std::cout << "Successfully parsed!" << std::endl; std::cout << val.serialize() << std::endl; } catch (const std::exception& e) { std::cerr << "Parse Error: " << e.what() << std::endl; } return 0; } ``` --- ### 💡 火车上的小技巧 * **极简编译命令:** 如果你没配置 CMake,直接在根目录执行: `g++ -std=c++17 src/main.cpp src/tinyjson.cpp -I ./include -o json_parser` * **断言测试:** 建议多用 `assert()`。写完 `parse_number` 马上测一下 `0`, `-1.5`, `2e10` 是否都能过,别等全部写完再调。 **这个结构对你来说合适吗?如果你已经建好了文件夹,我们可以先攻克 `parse_value` 这个“分拣中心”的逻辑。** 这是一个非常棒的工程化思考!**答案是:非常有必要,而且建议采用这种“数据与逻辑分离”的设计。** 在 C++ 中,这种做法不仅符合“单一职责原则”(Single Responsibility Principle),还能避免循环包含(Circular Dependency)的问题。 我们可以把它们拆分为以下两个部分: --- ### 1. `Json.h` (或 `JsonValue.h`):数据的容器 这个类负责**“存储”**。它代表了 JSON 在内存中的样子。 * **职责:** 定义数据结构(使用 `std::variant`)、提供访问数据的接口(比如 `as_int()`, `is_object()`)、以及将内存对象转回字符串(序列化)。 * **好处:** 你的业务代码只需要引用这个类,它不关心 JSON 是从网络下载的、从文件读的,还是手动构建的。 ### 2. `JsonParser.h`:数据的加工厂 这个类负责**“解析”**。它像是一台扫描仪。 * **职责:** 接收原始字符串,通过内部的 `pos` 指针进行词法和语法分析,最后产出一个 `JsonValue` 对象。 * **好处:** 解析逻辑通常很复杂(处理空白符、递归、转义字符等),把它独立出来可以让代码更易读,也方便以后更换解析算法(比如从递归下降改为状态机)。 --- ### 修改后的文件结构(更专业版) ```text TinyJSON/ ├── include/ │ ├── JsonValue.h # 定义 JSON 的数据模型 (The DOM) │ └── JsonParser.h # 定义解析逻辑 (The Engine) └── src/ ├── JsonValue.cpp └── JsonParser.cpp ``` ### 为什么这样做在 C++ 中很重要? 当你处理**嵌套结构**(比如 JSON Object 里面又有一个 JSON Object)时,`JsonValue` 需要引用自身。 如果你把解析逻辑和数据模型混在一起,当你的解析函数 `parse_object` 需要返回一个 `JsonValue`,而 `JsonValue` 又包含一个调用 `parse_object` 的成员时,头文件包含关系会变得非常混乱。 **现在的分工应该是:** 1. `JsonValue.h`:先定义出“房子”长什么样。 2. `JsonParser.h`:引用 `JsonValue.h`,定义“如何盖房子”。 --- ### 🛠️ 火车撸码小建议: 在实现 `JsonParser` 时,建议采用 **“流式处理”** 的思维: * 给 `JsonParser` 一个私有成员 `std::string_view source;` 和 `size_t offset;`。 * `string_view` (C++17) 相比 `std::string` 在处理子串时**不产生内存拷贝**,在火车上写这种高性能代码,成就感会爆棚。 **你是打算先从 `JsonValue.h` 的底层结构定义开始写,还是想直接切入 `JsonParser.h` 的解析主循环?我可以帮你写出这两个类之间最关键的“解耦”代码片段。** 在火车上静下心来钻研 C++ 的新特性是非常棒的。这两个问题正好触及了 C++ 工程设计的核心:**数据表示**与**逻辑解耦**。 --- ### 1. 为什么要用 `std::variant`? 在 JSON 中,一个“值”可能是数字、字符串、布尔值,也可能是嵌套的对象。 在 C 语言或老版本 C++ 中,我们通常用 `union`(联合体)来做,但 `union` 有个致命缺点:**它不知道自己当前存的是哪种类型**,且不支持像 `std::string` 这样复杂的 C++ 对象。 `std::variant` 是 C++17 引入的“类型安全联合体”: * **多合一容器:** 它能同时定义多种可能的类型,但在某一时刻只存其中一种。 * **类型安全:** 它自带“标签”,知道自己现在存的是 `double` 还是 `std::string`。如果你存了 `bool` 却想按 `int` 取出来,它会抛出异常,而不是像 `union` 那样给你一堆乱码。 * **支持复杂对象:** 它能完美容纳 `std::map` 或 `std::vector`,这对处理 JSON 嵌套至关重要。 --- ### 2. `JsonValue` 与 `JsonParser` 的角色分配 你可以把它们想象成 **“货架”** 与 **“搬运工”** 的关系: #### **JsonValue(货架/数据模型)** 它的作用是**在内存中代表一个 JSON 元素**。 * **它是静止的:** 它只负责“存数据”和“给数据”。 * **它长什么样:** 它内部持有一个 `std::variant`。 * **它能干啥:** * 提供接口查询:`is_string()`,`is_number()`。 * 提供接口获取:`as_string()`,`as_double()`。 * 提供序列化:`to_string()`(把内存里的数据变回文本)。 #### **JsonParser(搬运工/解析引擎)** 它的作用是**把乱糟糟的字符串变成整齐的 `JsonValue**`。 * **它是动态的:** 它负责扫描字符串、处理逻辑、报错。 * **它长什么样:** 它内部持有一个字符串指针(或索引),记录读到了哪里。 * **它能干啥:** * 跳过空格。 * 识别 `true` 是布尔值。 * 识别 `{` 后面紧跟着的是 `key:value` 结构。 * **最终产出:** 它辛苦工作半天,最后只给用户返回一个 `JsonValue` 对象。 --- ### 3. 解析思路(How it works) 想象你要解析这段字符串:`{"age": 25}` 1. 用户创建 `JsonParser("{\"age\": 25}")`。 2. 调用 `parser.parse()`。 3. **解析器**看到 `{`,知道这是一个 **Object**。 4. 它继续往后走,看到 `"age"`,知道这是键。 5. 它看到 `:` 后面的 `25`,知道这是数字。 6. **解析器**在内部创建一个 `JsonValue` 对象,把数字 `25` 塞进它的 `std::variant` 里。 7. 最后,**解析器**把这个填满数据的 `JsonValue` 扔给用户。 --- ### 🛠️ 火车撸码实践: 你可以先尝试定义这个“货架”: ```cpp // JsonValue.h 核心片段 #include #include #include #include struct JsonValue; // 前置声明 using Object = std::map; using Array = std::vector; class JsonValue { public: // 这里就是 Variant 的威力 std::variant data; // 各种构造函数... bool is_number() const { return std::holds_alternative(data); } double as_number() const { return std::get(data); } }; ``` **现在你的电脑里建好这两个文件了吗?如果准备好了,我可以带你写出 `JsonParser` 里的第一个函数——如何识别并解析一个数字。** 当然可以!而且**返回 `JsonValue` 才是最正确、最标准的设计**。 你可以把 `JsonValue` 想象成一个**“无限嵌套的俄罗斯套娃”**。 ### 1. 为什么返回 `JsonValue` 就够了? 因为 JSON 的根节点(Root)要么是一个 **对象(Object)** `{...}`,要么是一个 **数组(Array)** `[...]`。 由于我们在 `JsonValue` 的 `std::variant` 里已经包含了 `Object` 和 `Array`,所以一个 `JsonValue` 变量完全有能力代表整个 JSON 文件的内容。 ### 2. 解析逻辑的返回值 在你的 `JsonParser` 类里,主入口函数应该是这样的: ```cpp // JsonParser.h class JsonParser { public: // 用户调用这个函数,传入整个 JSON 字符串,得到一个 JsonValue static JsonValue parse(const std::string& content) { JsonParser parser(content); return parser.parse_value(); } private: // 内部递归解析函数 JsonValue parse_value() { skip_whitespace(); char c = peek(); if (c == '{') return parse_object(); if (c == '[') return parse_array(); if (c == '"') return parse_string(); // ... 其他类型 } }; ``` ### 3. 用户拿到结果后怎么用? 用户拿到返回的 `JsonValue` 后,只需要判断它内部存的是什么即可: ```cpp // main.cpp std::string content = load_file("config.json"); JsonValue root = JsonParser::parse(content); // 如果我知道根节点是个对象 if (std::holds_alternative(root.data)) { auto& obj = std::get(root.data); // 像使用 map 一样访问里面的数据 std::cout << "Username: " << std::get(obj["username"].data) << std::endl; } ``` ### 4. 火车上的进阶思路:如何优雅地“拿”数据? 如果你觉得 `std::get<...>(obj["..."].data)` 写起来太长、太痛苦,你可以在 `JsonValue` 类里增加一些辅助方法。这是 C++ 程序员最喜欢干的事: ```cpp // 在 JsonValue 类内部增加 class JsonValue { public: // 运算符重载:让 JsonValue 像 map 一样好用 JsonValue& operator[](const std::string& key) { return std::get(data)[key]; } // 方便转换 std::string as_string() const { return std::get(data); } double as_double() const { return std::get(data); } }; // 这样用户代码就变成了: std::cout << root["username"].as_string(); // 是不是优雅多了? ``` ### 总结 * **JsonValue** 是“数据”,它是静止的结果。 * **JsonParser** 是“工具”,它是解析的动作。 * **整个 JSON 文件** 解析完后,就是一个**处于最顶层的 JsonValue**(它内部套着其他的 JsonValue)。 **你准备好写 `parse_value` 这个“分拣中心”了吗?它是整个解析器逻辑最核心的跳转枢纽。**