Files
workspace/cpp/JsonParse/Structure.md
e2hang ebcee63b7c New
2026-01-09 00:05:37 +08:00

571 lines
19 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
在火车上写 JSON 解析器是一个绝佳的选择,因为它不需要外部依赖,且逻辑非常严密。
解析 JSON 的核心思想是**递归下降解析Recursive Descent Parsing**。你可以把它想象成一个“吃字符串”的过程:根据当前看到的第一个字符,决定下一步怎么走。
---
### 1. 核心架构设计
首先,你需要定义一个类来存储 JSON 的各种类型。在现代 C++ 中,建议使用 `std::variant`
```cpp
#include <iostream>
#include <variant>
#include <vector>
#include <map>
#include <string>
// 定义 JSON 的各种数据类型
struct JsonValue;
using JsonObject = std::map<std::string, JsonValue>;
using JsonArray = std::vector<JsonValue>;
struct JsonValue {
std::variant<std::nullptr_t, bool, double, std::string, JsonArray, JsonObject> 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 的“骨架”摸清楚。JSONJavaScript 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<nullptr_t, bool, double, string, vector<JsonValue>, map<string, JsonValue>>` 来表示 `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 <string>
#include <vector>
#include <map>
#include <variant>
#include <memory>
namespace tinyjson {
struct JsonValue;
// 使用 C++17 的 variant 存储 6 种 JSON 类型
using Object = std::map<std::string, JsonValue>;
using Array = std::vector<JsonValue>;
using ValueData = std::variant<std::nullptr_t, bool, double, std::string, Array, Object>;
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 <iostream>
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 <variant>
#include <string>
#include <vector>
#include <map>
struct JsonValue; // 前置声明
using Object = std::map<std::string, JsonValue>;
using Array = std::vector<JsonValue>;
class JsonValue {
public:
// 这里就是 Variant 的威力
std::variant<std::nullptr_t, bool, double, std::string, Array, Object> data;
// 各种构造函数...
bool is_number() const { return std::holds_alternative<double>(data); }
double as_number() const { return std::get<double>(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<Object>(root.data)) {
auto& obj = std::get<Object>(root.data);
// 像使用 map 一样访问里面的数据
std::cout << "Username: " << std::get<std::string>(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<Object>(data)[key];
}
// 方便转换
std::string as_string() const { return std::get<std::string>(data); }
double as_double() const { return std::get<double>(data); }
};
// 这样用户代码就变成了:
std::cout << root["username"].as_string(); // 是不是优雅多了?
```
### 总结
* **JsonValue** 是“数据”,它是静止的结果。
* **JsonParser** 是“工具”,它是解析的动作。
* **整个 JSON 文件** 解析完后,就是一个**处于最顶层的 JsonValue**(它内部套着其他的 JsonValue
**你准备好写 `parse_value` 这个“分拣中心”了吗?它是整个解析器逻辑最核心的跳转枢纽。**