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

19 KiB
Raw Permalink Blame History

在火车上写 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. 解析逻辑:递归下降

你需要一个解析类,维护一个当前处理到的“指针”(或迭代器)。

逻辑流程图:

解析伪代码思路:

  1. 跳过空白符: 忽略空格、换行、制表符。
  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++ 性能优化的核心。

🛠️ 开发建议

  1. 先写一个 peek()consume()
  • peek():看一眼当前的字符,但不移动指针。
  • consume(char c):检查当前字符是否为 c,如果是则移动到下一个,不是则报错。
  1. 测试用例: 准备一个最复杂的 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布尔 只有 truefalse
  6. 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(),它的逻辑如下:

  1. 跳过空白: 循环跳过空格、\n\t
  2. 看第一个字符:
  • 如果是 {递归进入 parse_object()
  • 如果是 [递归进入 parse_array()
  • 如果是 ":进入 parse_string(),直到遇到下一个不被转义的 "
  • 如果是 tf:匹配是不是 truefalse
  • 如果是 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. 火车上撸代码的实战建议

  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

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 ObjectJsonValue 需要引用自身。

如果你把解析逻辑和数据模型混在一起,当你的解析函数 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::mapstd::vector,这对处理 JSON 嵌套至关重要。

2. JsonValueJsonParser 的角色分配

你可以把它们想象成 “货架”“搬运工” 的关系:

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 扔给用户。

🛠️ 火车撸码实践:

你可以先尝试定义这个“货架”:

// 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 [...]

由于我们在 JsonValuestd::variant 里已经包含了 ObjectArray,所以一个 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 这个“分拣中心”了吗?它是整个解析器逻辑最核心的跳转枢纽。