19 KiB
在火车上写 JSON 解析器是一个绝佳的选择,因为它不需要外部依赖,且逻辑非常严密。
解析 JSON 的核心思想是递归下降解析(Recursive Descent Parsing)。你可以把它想象成一个“吃字符串”的过程:根据当前看到的第一个字符,决定下一步怎么走。
1. 核心架构设计
首先,你需要定义一个类来存储 JSON 的各种类型。在现代 C++ 中,建议使用 std::variant。
#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. 解析逻辑:递归下降
你需要一个解析类,维护一个当前处理到的“指针”(或迭代器)。
逻辑流程图:
解析伪代码思路:
- 跳过空白符: 忽略空格、换行、制表符。
- 主解析函数
parse_value():
- 看到
[→ 调用parse_array() - 看到
{→ 调用parse_object() - 看到
"→ 调用parse_string() - 看到
t/f→ 调用parse_boolean() - 看到
n→ 调用parse_null() - 看到数字或
-→ 调用parse_number()
关键实现:解析对象(Object)
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++ 性能优化的核心。
🛠️ 开发建议
- 先写一个
peek()和consume():
peek():看一眼当前的字符,但不移动指针。consume(char c):检查当前字符是否为c,如果是则移动到下一个,不是则报错。
- 测试用例: 准备一个最复杂的
test.json,包含嵌套的对象、数组、各种类型的混合。
如果你准备动工,需要我为你提供一个完整的 JsonValue 类定义模板,还是你先尝试自己写一下基础框架?
既然要手撸解析器,第一步确实得把 JSON 的“骨架”摸清楚。JSON(JavaScript Object Notation)本质上就是一种嵌套的键值对结构,它的美感在于极其有限的规则。
1. JSON 的 6 种基本数据类型
一个 JSON 文件里,无论多复杂,都逃不出这 6 种东西:
- Object(对象/映射): 用
{}包围,里面是key: value形式。 - Array(数组): 用
[]包围,里面是值的列表。 - String(字符串): 必须用双引号
"包围,支持转义(如\n)。 - Number(数字): 整数或浮点数,支持科学计数法。
- Boolean(布尔): 只有
true或false。 - Null: 只有一个值
null。
举个例子:
{
"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(),它的逻辑如下:
- 跳过空白: 循环跳过空格、
\n、\t。 - 看第一个字符:
- 如果是
{:递归进入parse_object()。 - 如果是
[:递归进入parse_array()。 - 如果是
":进入parse_string(),直到遇到下一个不被转义的"。 - 如果是
t或f:匹配是不是true或false。 - 如果是
n:匹配是不是null。 - 如果是
-或0-9:进入parse_number()。
3. 如何处理递归?
这是最妙的地方。当你在解析一个 Object 时,遇到 key: value 中的 value,你并不需要知道它是数字还是另一个对象,你只需要再次调用 parse_value()。
伪代码逻辑:
// 伪代码:解析数组
JsonArray parse_array() {
consume('['); // 吃掉左括号
JsonArray arr;
while (peek() != ']') {
// 关键点:数组里的元素可以是任何类型,所以递归调用
arr.push_back(parse_value());
if (peek() == ',') consume(','); // 吃掉逗号继续循环
}
consume(']'); // 吃掉右括号
return arr;
}
4. 火车上撸代码的实战建议
- 数据结构设计: 使用
std::variant<nullptr_t, bool, double, string, vector<JsonValue>, map<string, JsonValue>>来表示JsonValue。这是 C++17 的利器,能非常优雅地存储不同类型。 - 错误捕获:
如果解析到一半发现格式不对(比如
{"key": }),你可以抛出一个异常,打印出当前指针的位置。 - 先从简单的写起:
- 第一阶段:能解析
true/false/null。 - 第二阶段:能解析字符串和数字。
- 第三阶段:实现
parse_array。 - 第四阶段:实现最复杂的
parse_object。
你想先从定义存储 JSON 的 JsonValue 类开始,还是直接写那个“吃字符串”的主循环逻辑?我可以给你一个起步的 C++ 框架。
在火车上写项目,一个清晰、标准的目录结构能帮你理清思路,即使没有 IDE 的高级功能,靠 g++ 或 cmake 也能轻松编译。
推荐一个轻量级但专业的 C++ 项目结构:
📂 项目文件结构:TinyJSON
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
这是你的“蓝图”。在火车上,建议把所有数据类型定义好。
#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
这是你验证成果的地方。
#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对象。 - 好处: 解析逻辑通常很复杂(处理空白符、递归、转义字符等),把它独立出来可以让代码更易读,也方便以后更换解析算法(比如从递归下降改为状态机)。
修改后的文件结构(更专业版)
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 的成员时,头文件包含关系会变得非常混乱。
现在的分工应该是:
JsonValue.h:先定义出“房子”长什么样。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}
- 用户创建
JsonParser("{\"age\": 25}")。 - 调用
parser.parse()。 - 解析器看到
{,知道这是一个 Object。 - 它继续往后走,看到
"age",知道这是键。 - 它看到
:后面的25,知道这是数字。 - 解析器在内部创建一个
JsonValue对象,把数字25塞进它的std::variant里。 - 最后,解析器把这个填满数据的
JsonValue扔给用户。
🛠️ 火车撸码实践:
你可以先尝试定义这个“货架”:
// 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 类里,主入口函数应该是这样的:
// 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 后,只需要判断它内部存的是什么即可:
// 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++ 程序员最喜欢干的事:
// 在 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 这个“分拣中心”了吗?它是整个解析器逻辑最核心的跳转枢纽。