Json与内存数据库
# 第四章:C++ Json与内存数据库
本章是综合案例的第四关·现代 C++ 升级战——在 03.校园身份预约 (opens new window) 把 STL 三件套吃透之后,我们正式告别裸指针 + 手动 delete + 错误码的 C 风格写法,把 C++17/20 的核心利器全部用上。本案例会做四件升级:
1.std::variant<...>:用类型安全的"和类型"表达 JSON 的 6 种节点(null/bool/number/string/array/object),告别 void* + 类型 tag 的脏写法。
2.std::unique_ptr 全面替代裸指针:JSON 的递归节点用 unique_ptr<JsonNode>,对象/数组的子节点也是——整个解析过程零裸 new、零手写 delete。
3.完整异常体系:从 std::runtime_error 派生 JsonParseError、KeyNotFoundError、TypeMismatchError,精确表达"哪一种错"。
4.std::optional + std::string_view:表达"可能没有结果" + "零拷贝传字符串"——这两个特性是现代 C++ 接口设计的标配。
学习方式:本案例按"阶段拆解 → 写代码 → 跑编译 → 看输出 → 避陷阱"五步法循环。总共 8 个阶段、约 12 小时,建议分 3 天完成(异常+节点骨架 · 解析器三阶段 · KvDB+CLI)。每个阶段都遵循"写一点 → 编译 → 看输出 → 再写下一点"的节奏。
# 📚 渐进学习节奏
💡 先读这段,再开始敲代码!本案例严格按照真实工程师的开发节奏推进,不会一上来甩你 1100 行代码让你抄。我们的节奏是这样的:
阶段 ① 异常底座(02 节) · 30 min · 2 Step
└ Step 1.1: JsonError 基类骨架 → 编译过 + throw/catch 测试
└ Step 1.2: 4 个子异常类 → 父类多态捕获验证
阶段 ② JsonNode:variant 容器(03 节) · 120 min · 6 Step
└ Step 2.1: variant 6 路 + Type 枚举 → 编译过
└ Step 2.2: 9 个构造函数 → 创建 4 种节点
└ Step 2.3: isXxx() 类型查询 → 打印类型名
└ Step 2.4: 裸 std::get → 体验 bad_variant_access
└ Step 2.5: 升级为 TypeMismatchError 友好异常 ⭐
└ Step 2.6: operator[] / at("a.b") + 工厂函数
阶段 ③ Parser 字面量与数字(04 节 4.1-4.3) · 60 min · 3 Step
└ Step 3.1: 游标工具 peek/consume/match
└ Step 3.2: parseLiteral(null/true/false)
└ Step 3.3: parseNumber(整数/小数/科学计数法)
阶段 ④ Parser 字符串与递归(04 节 4.4-4.6) · 90 min · 3 Step 【高峰:递归】
└ Step 4.1: parseString 无转义版 → 解析 "hello"
└ Step 4.2: 补全 \n \t \" 7 种转义
└ Step 4.3: parseArray + parseObject 递归 ⭐
阶段 ⑤ Writer 序列化(05 节) · 60 min · 2 Step
└ Step 5.1: 紧凑模式 + 字符串反向转义
└ Step 5.2: pretty 缩进美化 + 回环测试
阶段 ⑥ KvDatabase 持久化(06 节) · 90 min · 3 Step
└ Step 6.1: 空骨架 + load/save + 析构 noexcept
└ Step 6.2: get / set 单层版 + optional
└ Step 6.3: 升级 a.b.c 多级路径 + del
阶段 ⑦ CLI REPL(07 节) · 30 min · 1 Step
└ Step 7.1: 5 命令循环 + 顶层异常兜底 → 重启验证持久化
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
🎯 每个 Step 必须做的三件事:
- 看 🎯 阶段目标卡片:明确这一阶段做什么、不做什么、验收标准
- 写一小段代码就编译运行一次(看到 ✅ 标志立刻动手)
- 看到预期输出再写下一个 Step(绝不一口气抄完整段代码)
⚠️ 本案例独有的"现代 C++ 反复横跳":阶段 ② 你刚学会 variant,阶段 ③ 立刻就要用 std::get;阶段 ⑤ 你又要回头改 ② 的访问器配合 Writer。这种"学一点 → 立刻反复用 → 强化记忆"的设计,是新手区分 C 风格 C++ 与现代 C++ 的关键时刻。
✅ 每个阶段的结构(你在正文里会反复看到):
┌─ 🎯 阶段目标 ────────────────┐ ← 阶段开头:明确做什么/不做什么 │ 完成什么、不做什么、验收标准 │ └──────────────────────────────┘ Step X.1:先写最小可编译版(5-20 行) Step X.2:编译 → 运行 → 看到输出 ✅ Step X.3:再加一个小功能(10-30 行) Step X.4:编译 → 运行 → 看到新输出 ✅ ... ┌─ 🧪 运行验证 ─────────────┐ ← 阶段结尾:完整命令 + 预期输出 + 排错 │ 编译命令 / 预期输出 / 排错指南 │ └──────────────────────────┘ ┌─ 📌 阶段小结 ─────────────┐ ← 阶段结尾:今天学到了什么 │ ✅ 已掌握 / ⏸ 暂未涉及 │ └──────────────────────────┘1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
# 00.案例元信息
| 项目 | 说明 |
|---|---|
| 难度 | ★★★★☆ |
| 预估时长 | 12 小时(建议分 3 天) |
| 前置章节 | 卷一第 8 章(指针引用)、第 11 章(内存模型)、第 12 章(动态内存 RAII)、第 14 章(异常)、第 18 章(现代特性) |
| 覆盖知识点 | std::variant / std::optional / std::string_view / std::unique_ptr + make_unique / 移动语义 / 自定义异常类 / 递归下降解析器 / 类型擦除 |
| 设计亮点 | JSON 解析器(450 行)+ 基于 JSON 的 KV 数据库(300 行)= 自洽的"小而美"工程 |
| 最终产物 | 静态库 libjsonkv.a + 命令行工具 jsonkv,支持加载 JSON 文件并按路径查询 |
| 代码规模 | 约 1100 行 / 8 个文件 |
# 项目结构
jsonkv/
├── include/
│ ├── JsonNode.h # 核心:JSON 节点类(基于 variant)
│ ├── JsonParser.h # 解析器
│ ├── JsonWriter.h # 序列化器
│ ├── KvDatabase.h # 内存数据库
│ └── JsonExceptions.h # 异常体系
├── src/
│ ├── JsonNode.cpp
│ ├── JsonParser.cpp
│ ├── JsonWriter.cpp
│ └── KvDatabase.cpp
├── main.cpp # CLI 工具
├── tests/ # 单测(可选)
│ └── test_basic.cpp
└── data/sample.json
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
# 一条命令编译运行
cd jsonkv
g++ -std=c++17 -Iinclude src/*.cpp main.cpp -o jsonkv
./jsonkv data/sample.json
2
3
# 目录快速导航
点击以下条目即可跳转到对应节。【🔑 重点节】推荐优先阅读。
- 01.项目需求和功能
- 02.异常体系设计 【阶段①】
- 03.JsonNode 类型表达 【阶段②·OOP现代版】
- 04.递归下降解析器 【阶段③④·递归高峰⭐】
- 05.序列化器 【阶段⑤】
- 06.KvDatabase 内存数据库 【阶段⑥】
- 07.CLI 工具与 REPL 【阶段⑦】
- 08.项目总结盘点
- 09.项目技术思考
- 10.衔接与延伸
# 01.项目需求和功能
# 1.1 业务背景
JSON 是当今互联网最通用的数据格式。无论是 HTTP API、配置文件、还是 NoSQL 数据库,JSON 几乎无处不在。本案例从零实现一个最小但完整的 JSON 解析器,并基于它搭建一个内存 KV 数据库——这是案例 06 KV 存储引擎的"前置版本"。
为什么不直接用 nlohmann/json 这种成熟库?
工业项目当然要用成熟库(卷四会教如何集成)。但本卷的目的是学习 C++ 现代特性——没有什么比"自己手写一个 JSON 解析器"更能让你领悟 variant、unique_ptr、异常的价值。
# 1.2 为什么用 variant
JSON 节点有 6 种可能类型。三种 C++ 实现方式对比:
// ❌ 方案 A:基类 + void*(C 风格)
struct Node { int type; void* data; };
// ❌ 方案 B:union(C++ 弱化版)
union Value { bool b; double n; std::string s; }; // string 不支持,需要 placement new 还无法自动析构
// ✅ 方案 C:std::variant(C++17)
using JsonValue = std::variant<
std::nullptr_t, // null
bool, // true/false
double, // number
std::string, // string
std::vector<std::unique_ptr<JsonNode>>, // array
std::map<std::string, std::unique_ptr<JsonNode>> // object
>;
2
3
4
5
6
7
8
9
10
11
12
13
14
15
variant 的好处:
- 类型安全:
std::get<bool>(v)类型不匹配会抛std::bad_variant_access - 析构自动:variant 切换状态时自动调当前持有类型的析构
- 模式匹配:
std::visit(visitor, v)一次处理所有类型
# 1.3 涉及知识点
| 卷一章节 | 知识点 | 在本案例中的位置 |
|---|---|---|
| 第 11 章 内存模型 | 栈/堆/全局区分布 / 对象生命周期 | 全章 |
| 第 12 章 动态内存 RAII | unique_ptr / make_unique / 移动语义 | 03、04、06 节 |
| 第 14 章 异常处理 | 自定义异常类 / try/catch / noexcept | 02 节 |
| 第 18 章 现代特性 | variant / optional / string_view / std::visit | 03 节 |
| 第 16 章 STL | vector / map / unordered_map | 03、06 节 |
| 第 13 章 IO | ifstream / 字符串流 | 04、07 节 |
# 02.异常体系设计
┌─ 🎯 阶段 ① 目标 ────────────────────────────────────────┐
│ 完成什么:搭起 JsonExceptions.h 异常底座(5 个类) │
│ 不做什么:不写解析器、不抛真实异常 │
│ 验收标准:写一段 throw / catch 测试代码,看到异常 what() │
│ 预计耗时:30 min │
└──────────────────────────────────────────────────────────┘
2
3
4
5
6
# 灵魂三问:异常类要不要现在就写?
❓ 能不能不写自定义异常类,直接 throw std::runtime_error("xxx") 就完事?
来看反例:
// ❌ 全用 std::runtime_error
void Parser::parseNumber() { throw std::runtime_error("bad number"); }
void Node::asString() { throw std::runtime_error("not string"); }
void KvDb::load() { throw std::runtime_error("file open fail"); }
// 调用方怎么区分?
try { /* ... */ }
catch (const std::runtime_error& e) {
// ⚠️ 三种错全混在一起,e.what() 字符串是唯一线索
if (std::string(e.what()).find("bad number") != std::string::npos) { /* ... */ }
// 这种"靠 what() 字符串猜异常种类"的代码 = 业务逻辑灾难
}
2
3
4
5
6
7
8
9
10
11
12
问题暴露:
- 无法按异常类型分别捕获——都是 runtime_error
- 错误信息不结构化——拼接字符串,行号列号难提取
- catch 写法脆弱——靠子串匹配判断错误种类,重构 what() 文案就崩
✅ 正确做法:自定义异常类层级——每种"出错原因"对应一个独立类。
❓ 为什么要在阶段 ① 第一个写它? 答:地基性决定时机。Parser/Node/Db 都会 throw,先有异常类型再写业务,可以避免"中途回头补"的反复改头文件。这和 03 案例 §5.2 先写最小 CampusSystem 骨架是同一个思想——先打通最底层的依赖项。
❓ 现在第一步要先做哪一个? 答:先写 JsonError 基类——4 个子异常都从它派生,没有它子类无从下手。
🔑 教学要点:异常类本身没有任何业务逻辑,但它决定了后续每一个模块的错误处理风格。这是为什么很多大型 C++ 项目第一周都在"定异常类型"。
# Step 1.1 异常基类骨架
新建 JsonExceptions.h:
// JsonExceptions.h
#pragma once
#include <stdexcept>
#include <string>
namespace jsonkv {
// class JsonError : public std::runtime_error {
// public:
// // 手动写每个构造函数。即使基类有多个构造函数,你也需要在派生类中手动重写每个构造函数。
// JsonError() : std::runtime_error("") {}
// JsonError(const std::string& msg) : std::runtime_error(msg) {}
// JsonError(const char* msg) : std::runtime_error(msg) {}
// // ... 还有其他重载吗?需要去查 std::runtime_error 的文档
// };
// 顶层基类:所有 jsonkv 自定义异常的根
// 定义了一个自定义异常类 JsonError,它继承自标准库的 std::runtime_error。
class JsonError : public std::runtime_error {
public:
// ⭐ using 继承构造函数的作用,C++11 引入了继承构造函数特性,允许派生类直接"继承"基类的所有构造函数。
using std::runtime_error::runtime_error;
};
} // namespace jsonkv
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
📚 两个关键点:
- 从
std::runtime_error派生——它已经实现了what()返回字符串。如果直接派生std::exception,要自己管 const char* 缓冲区。using std::runtime_error::runtime_error;是 C++11 "继承构造函数",等价于"复制基类所有构造函数到当前类"。这一行让你能写JsonError("hello")。
新建临时测试文件 test_exception.cpp 验证基类能用:
#include "JsonExceptions.h"
#include <iostream>
int main() {
try {
throw jsonkv::JsonError("base class works!");
} catch (const std::exception& e) { // 用基类捕获也行
std::cout << "caught: " << e.what() << "\n";
}
}
2
3
4
5
6
7
8
9
10
🧪 编译运行:
$ g++ -std=c++17 test_exception.cpp -o test_exc && ./test_exc
caught: base class works!
2
3
✅ 看到 "caught: base class works!" 就说明基类骨架OK。如果编译报 runtime_error 不是构造函数 —— 检查是否 #include <stdexcept>。
# Step 1.2 子异常多态捕获
继续在 JsonExceptions.h 基类下方添加 4 个子类:
namespace jsonkv {
class JsonParseError: public JsonError {
public:
JsonParseError(const std::string& msg, size_t line, size_t col)
: JsonError("[Parse] line " + std::to_string(line) +
" col " + std::to_string(col) + ": " + msg) {}
};
// 类型不匹配(如对 number 调 asString())
class TypeMismatchError : public JsonError {
public:
TypeMismatchError(const std::string& expected, const std::string& actual)
: JsonError("[Type] expected " + expected + ", got " + actual) {}
};
// Key 不存在
class KeyNotFoundError : public JsonError {
public:
explicit KeyNotFoundError(const std::string& key)
: JsonError("[Key] not found: " + key) {}
};
// IO 错误(文件读写)
class JsonIoError : public JsonError {
public:
using JsonError::JsonError;
};
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
升级 test_exception.cpp,分别 throw 4 种异常:
#include "JsonExceptions.h"
#include <iostream>
using namespace jsonkv;
void demo(int which) {
switch (which) {
case 1: throw jsonkv::JsonParseError("bad token", 3, 12);
case 2: throw jsonkv::TypeMismatchError("string", "number");
case 3: throw jsonkv::KeyNotFoundError("user.name");
case 4: throw jsonkv::JsonIoError("cannot open kv.json");
default: throw jsonkv::JsonError("base class works!");
}
}
int main() {
for (int i = 1; i <= 5; ++i) {
try { demo(i); }
catch (const JsonError& e) { // ⭐ 父类一次接住
std::cout << "[" << i << "] " << e.what() << "\n";
}
}
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
🧪 编译运行:
$ g++ -std=c++17 test_exception.cpp -o test_exc && ./test_exc
[1] [Parse] line 3 col 12: bad token
[2] [Type] expected string, got number
[3] [Key] not found: user.name
[4] cannot open kv.json
[5] base class works!
2
3
4
5
6
7
✅ 4 行各有前缀([Parse]/[Type]/[Key]/无),证明:4 个子异常都能被 JsonError 父类一并捕获——这就是异常多态。
┌─ 📌 阶段 ① 小结 ────────────────────────────────────────┐
│ ✅ 已完成 │
│ • JsonError 基类(继承 runtime_error) │
│ • 4 个子异常类(Parse/Type/Key/Io) │
│ • 用父类捕获测试通过 │
│ 🔜 下一阶段 │
│ • 进入阶段 ②:写 JsonNode 类(variant 容器) │
│ 💡 commit 建议:feat(jsonkv): add exception hierarchy │
└──────────────────────────────────────────────────────────┘
2
3
4
5
6
7
8
9
💼 试调结束后清理:test_exception.cpp 是临时验证文件,可以删除——但建议保留它作为单元测试的雏形(卷四会教 GoogleTest 框架)。
# 03.JsonNode 类型表达
┌─ 🎯 阶段 ② 目标 ────────────────────────────────────────┐
│ 完成什么:JsonNode 数据结构 + 类型查询 + 类型安全访问器 │
│ 不做什么:不写 Parser/Writer/Database,只玩单一节点 │
│ 验收标准:能在 main 里手工创建 6 种类型节点 + 触发类型异常 │
│ 预计耗时:120 min(拆 6 个 Step,每步都能编译运行) │
└──────────────────────────────────────────────────────────┘
2
3
4
5
6
本阶段的灵魂三问💡
- JSON 节点有 6 种可能类型,C++ 怎么用一个类同时表达?→
std::variant- 6 种类型混在一起,怎么知道当前是哪种?→
v.index()或std::holds_alternative<T>(v)- 类型不匹配怎么报错?→ 上一阶段做好的
TypeMismatchError
# Step 2.1 variant 与枚举
新建 JsonNode.h——只放数据结构,不写方法:
// JsonNode.h(v1:仅类型骨架)
#pragma once
#include <variant>
#include <vector>
#include <map>
#include <string>
#include <memory>
#include "JsonExceptions.h"
namespace jsonkv {
// 前置声明
class JsonNode;
// 别名:节点智能指针
using JsonNodePtr = std::unique_ptr<JsonNode>;
class JsonNode {
public:
// JSON 数组
using Array = std::vector<JsonNodePtr>;
// JSON 对象
using Object = std::map<std::string, JsonNodePtr>;
// ⭐ 核心:6 路 variant。6 种 JSON 类型
// 使用 std::variant 替代传统 union 或继承体系
using Value = std::variant<
std::nullptr_t, // 0: null
bool, // 1: true/false
double, // 2: number
std::string, // 3: string
Array, // 4: array
Object // 5: object
>;
// 用 enum class 让"类型枚举"和 variant 索引一一对应
// variant 索引: 0 1 2 3 4 5
enum class Type { Null = 0, Bool, Number, String, Array, Object };
private:
Value v; // 持有 6 种之一。实际存储的值
};
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
写一个 1 行 main 验证骨架可编译:
// test_node.cpp
#include "JsonNode.h"
int main() {
jsonkv::JsonNode n; // 默认构造(编译器会自动 default-init variant 为第一种 = nullptr_t)
return 0;
}
2
3
4
5
6
🧪 编译:
$ g++ -std=c++17 test_node.cpp -o test_node
$ echo $?
0
2
3
4
✅ 退出码 0 就是成功。注意:variant 默认初始化为第一个候选类型(即 nullptr_t = null),这就是为什么我们把 nullptr_t 放在 variant 第一位。
# Step 2.2 六种构造函数
继续在 JsonNode public 区域追加构造函数:
public:
// ====== 构造函数:每种 JSON 类型一个 ======
JsonNode() : v(nullptr) {} // null
explicit JsonNode(bool b) : v(b) {}
explicit JsonNode(double n) : v(n) {}
explicit JsonNode(int n) : v(static_cast<double>(n)) {} // int 转 double
explicit JsonNode(const std::string& s) : v(s) {}
explicit JsonNode(std::string&& s) : v(std::move(s)) {} // 移动版
explicit JsonNode(const char* s) : v(std::string(s)) {} // 字面量便利
explicit JsonNode(Array&& a) : v(std::move(a)) {}
explicit JsonNode(Object&& o) : v(std::move(o)) {}
2
3
4
5
6
7
8
9
10
11
📚 explicit 的意义:禁止隐式转换。例如不写 explicit 时,JsonNode n = 3.14; 会偷偷把 double 转成 JsonNode;加上 explicit 强制写 JsonNode n{3.14} 或 JsonNode n(3.14),避免误用。
升级 test_node.cpp:手工创建 4 种基础类型节点:
#include "JsonNode.h"
#include <iostream>
int main() {
using namespace jsonkv;
JsonNode n_null;
JsonNode n_bool(true);
JsonNode n_num(3.14);
JsonNode n_str(std::string("hello"));
std::cout << "create 4 nodes OK\n";
return 0;
}
2
3
4
5
6
7
8
9
10
11
12
13
14
🧪 编译运行:
$ g++ -std=c++17 test_node.cpp -o test_node && ./test_node
create 4 nodes OK
2
3
⚠️ 如果你写 JsonNode n_str("hello"); 报 ambiguous,是因为 "hello" 既能匹配 const char* 也能匹配 const std::string&——这是 explicit 没拦住的边缘情况,加 std::string("hello") 显式声明即可。
# Step 2.3 类型查询接口
继续追加:
// ====== 类型查询 ======
Type type() const { return static_cast<Type>(v.index()); } // ⭐ variant 的 index() 返回当前持有的索引
bool isNull() const { return type() == Type::Null; }
bool isBool() const { return type() == Type::Bool; }
bool isNumber() const { return type() == Type::Number; }
bool isString() const { return type() == Type::String; }
bool isArray() const { return type() == Type::Array; }
bool isObject() const { return type() == Type::Object; }
2
3
4
5
6
7
8
升级测试——打印每个节点的真实类型:
#include "JsonNode.h"
#include <iostream>
const char* typeStr(jsonkv::JsonNode::Type t) {
switch (t) {
case jsonkv::JsonNode::Type::Null: return "null";
case jsonkv::JsonNode::Type::Bool: return "bool";
case jsonkv::JsonNode::Type::Number: return "number";
case jsonkv::JsonNode::Type::String: return "string";
case jsonkv::JsonNode::Type::Array: return "array";
case jsonkv::JsonNode::Type::Object: return "object";
}
return "?";
}
int main() {
using namespace jsonkv;
JsonNode nodes[] = { JsonNode{}, JsonNode{true}, JsonNode{3.14}, JsonNode{std::string("hi")} };
for (auto& n : nodes) {
std::cout << typeStr(n.type()) << "\n";
}
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
🧪 预期输出:
null
bool
number
string
2
3
4
5
✅ 4 种类型查询全部正确。v.index() 是 variant 提供的"我现在装的是第几种"接口,强转成 Type 枚举就能拿到友好名字。
# Step 2.4 裸 get 访问器
在 JsonNode.h 类内声明:
// ====== 访问器(先裸版,下一步再升级)======
bool asBool() const;
double asNumber() const;
const std::string& asString() const;
2
3
4
新建 JsonNode.cpp——先写最朴素的访问器,故意不做类型检查:
// JsonNode.cpp(v1:裸版)
#include "JsonNode.h"
namespace jsonkv {
bool JsonNode::asBool() const {
return std::get<bool>(v); // ⚠️ 类型错时抛 std::bad_variant_access
}
double JsonNode::asNumber() const {
return std::get<double>(v);
}
const std::string& JsonNode::asString() const {
return std::get<std::string>(v);
}
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
升级 test_node.cpp:故意对 bool 节点调 asNumber():
JsonNode b(true);
std::cout << b.asBool() << "\n"; // OK:1
try {
double x = b.asNumber(); // ⚠️ 类型不对
std::cout << x << "\n";
} catch (const std::exception& e) {
std::cout << "caught: " << e.what() << "\n";
}
2
3
4
5
6
7
8
🧪 编译运行:
$ g++ -std=c++17 test_node.cpp JsonNode.cpp -o test_node && ./test_node
1
caught: std::bad_variant_access
2
3
4
⚠️ 看到 std::bad_variant_access —— 这是 STL 的原生异常,对用户极不友好:你只知道"类型错了",不知道"期望什么实际是什么"。下一步我们把它包装成更人性化的 TypeMismatchError。
# Step 2.5 升级友好访问器
在 JsonNode.h 中声明 Array/Object 访问器:
const Array& asArray() const;
const Object& asObject() const;
Array& asArray(); // 可变版本
Object& asObject();
2
3
4
把 JsonNode.cpp 整体替换为带类型检查的版本,并新增数组/对象访问器:
// JsonNode.cpp(v2:友好异常版)
#include "JsonNode.h"
namespace jsonkv {
// 工具:枚举转字符串(用于错误消息)
static std::string typeName(JsonNode::Type t) {
switch (t) {
case JsonNode::Type::Null: return "null";
case JsonNode::Type::Bool: return "bool";
case JsonNode::Type::Number: return "number";
case JsonNode::Type::String: return "string";
case JsonNode::Type::Array: return "array";
case JsonNode::Type::Object: return "object";
}
return "unknown";
}
bool JsonNode::asBool() const {
if (!isBool()) throw TypeMismatchError("bool", typeName(type())); // ⭐ 先校验
return std::get<bool>(v);
}
double JsonNode::asNumber() const {
if (!isNumber()) throw TypeMismatchError("number", typeName(type()));
return std::get<double>(v);
}
const std::string& JsonNode::asString() const {
if (!isString()) throw TypeMismatchError("string", typeName(type()));
return std::get<std::string>(v);
}
const JsonNode::Array& JsonNode::asArray() const {
if (!isArray()) throw TypeMismatchError("array", typeName(type()));
return std::get<Array>(v);
}
const JsonNode::Object& JsonNode::asObject() const {
if (!isObject()) throw TypeMismatchError("object", typeName(type()));
return std::get<Object>(v);
}
// 可变版本:复用 const 版本的检查逻辑
JsonNode::Array& JsonNode::asArray() {
return const_cast<Array&>(static_cast<const JsonNode*>(this)->asArray());
}
JsonNode::Object& JsonNode::asObject() {
return const_cast<Object&>(static_cast<const JsonNode*>(this)->asObject());
}
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
测试:再次故意触发类型错误:
JsonNode c(true);
try {
c.asString();
} catch (const TypeMismatchError& e) {
std::cout << "friendly: " << e.what() << "\n";
}
2
3
4
5
6
🧪 预期输出:
friendly: [Type] expected string, got bool
2
✅ 现在错误消息一眼明白:期望 string,实际 bool。这就是"原生异常 → 业务异常"的包装价值。
📚 const_cast 复用模式:asArray() 可变版调用了 const 版做检查,再去掉 const 返回——这是 Effective C++ 推荐的"用 const 函数实现 non-const 函数"避免代码重复。
# Step 2.6 容器与工厂函数
最后追加 operator[]、size()、contains()、at() 路径访问,以及工厂函数。
JsonNode.h 类内声明:
// ====== 容器便捷操作 ======
JsonNode& operator[](size_t idx); // arr[0]
JsonNode& operator[](const std::string& key); // obj["name"]
size_t size() const;
bool contains(const std::string& key) const;
// 路径查询:obj.at("user.address.city")
JsonNode& at(const std::string& path);
const JsonNode& at(const std::string& path) const;
}; // class JsonNode 闭合
// ====== 工厂函数(类外)======
inline JsonNodePtr makeNull() { return std::make_unique<JsonNode>(); }
inline JsonNodePtr makeBool(bool b) { return std::make_unique<JsonNode>(b); }
inline JsonNodePtr makeNumber(double n) { return std::make_unique<JsonNode>(n); }
inline JsonNodePtr makeString(std::string s) { return std::make_unique<JsonNode>(std::move(s)); }
inline JsonNodePtr makeArray() { return std::make_unique<JsonNode>(JsonNode::Array{}); }
inline JsonNodePtr makeObject() { return std::make_unique<JsonNode>(JsonNode::Object{}); }
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
JsonNode.cpp 追加实现:
JsonNode& JsonNode::operator[](size_t idx) {
if (!isArray()) {
throw TypeMismatchError("array", typeName(type()));
}
auto& arr = std::get<Array>(v);
if (idx >= arr.size()) {
throw std::out_of_range("array index out of range");
}
return *arr[idx];
}
JsonNode& JsonNode::operator[](const std::string& key) {
if (!isObject()) {
throw TypeMismatchError("object", typeName(type()));
}
auto& obj = std::get<Object>(v);
auto it = obj.find(key);
if (it == obj.end()) {
// 方案A:抛异常
throw KeyNotFoundError(key);
// 方案B:自动插入(类似 nlohmann/json 的行为)
// auto [newIt, _] = obj.emplace(key, makeNull());
// return *newIt->second;
}
return *it->second;
}
size_t JsonNode::size() const {
if (isArray()) return std::get<Array>(v).size();
if (isObject()) return std::get<Object>(v).size();
if (isBool()) return 1;
if (isString()) return std::get<std::string>(v).size();
return 0;
}
bool JsonNode::contains(const std::string& key) const {
return isObject() && std::get<Object>(v).count(key) > 0;
}
// 路径解析:递归查找 a.b.c
JsonNode& JsonNode::at(const std::string& path) {
if (path.empty() || !isObject()) return *this;
size_t dot = path.find('.');
std::string head = (dot == std::string::npos) ? path : path.substr(0, dot);
std::string rest = (dot == std::string::npos) ? "" : path.substr(dot + 1);
auto& obj = std::get<Object>(v);
auto it = obj.find(head);
if (it == obj.end()) throw KeyNotFoundError(head);
if (rest.empty()) return *it->second;
return it->second->at(rest); // ⭐ 递归
}
const JsonNode& JsonNode::at(const std::string& path) const {
return const_cast<JsonNode*>(this)->at(path);
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
最终验证——手工搭一棵小树:
// test_node.cpp(最终版)
#include "JsonNode.h"
#include <iostream>
int main() {
using namespace jsonkv;
// 手搭:{ "user": { "name": "Alice", "age": 30 } }
auto user = makeObject();
user->asObject()["name"] = makeString("Alice");
user->asObject()["age"] = makeNumber(30);
auto root = makeObject();
root->asObject()["user"] = std::move(user);
// 用 at 路径查询
std::cout << "name = " << root->at("user.name").asString() << "\n";
std::cout << "age = " << root->at("user.age").asNumber() << "\n";
// 故意查不存在的路径
try { root->at("user.email"); }
catch (const KeyNotFoundError& e) { std::cout << "miss: " << e.what() << "\n"; }
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
编译运行一下:
🧪 预期输出:
$ g++ -std=c++17 test_node.cpp JsonNode.cpp -o test_node && ./test_node
name = Alice
age = 30
miss: [Key] not found: email
2
3
4
5
✅ 一棵两层 JSON 树用 at("a.b") 路径查询全跑通,类型错和 Key 不存在都有友好异常。
┌─ 📌 阶段 ② 小结 ────────────────────────────────────────┐
│ ✅ 已掌握 │
│ • Step 2.1 variant 6 路类型 + Type 枚举对应 │
│ • Step 2.2 9 个构造函数(一种 JSON 类型一个) │
│ • Step 2.3 v.index() + isXxx() 类型查询 │
│ • Step 2.4 裸 std::get → bad_variant_access 体验 │
│ • Step 2.5 包装为 TypeMismatchError 友好异常 ⭐ │
│ • Step 2.6 operator[]/size/contains/at + 工厂函数 │
│ 🔜 下一阶段 ③:JsonParser 递归下降(要把字符串解析成 Node)│
│ 💡 commit 建议:feat(jsonkv): JsonNode with variant types │
└──────────────────────────────────────────────────────────┘
2
3
4
5
6
7
8
9
10
11
💼 make_unique 而不是 new —— 工厂函数全部用 std::make_unique<JsonNode>(...),杜绝裸 new。这是 C++14 起的最佳实践:异常安全 + 一行更清晰 + 编译器优化空间更大。
# 04.递归下降解析器
┌─ 🎯 阶段 ③ 目标 ────────────────────────────────────────┐
│ 完成什么:JsonParser 骨架 + 字面量(null/true/false) + 数字 │
│ 不做什么:不解析字符串、数组、对象(下个阶段) │
│ 验收标准:parser.parse("true") 能得到一个 bool=true 的节点 │
│ 预计耗时:60 min · 3 Step │
└──────────────────────────────────────────────────────────┘
2
3
4
5
6
💡 递归下降是什么? 每种语法结构对应一个 parseXxx() 函数,遇到嵌套就递归调用。它是最容易手写的解析器架构,你将在阶段 ④ 看到 parseArray 调 parseValue、parseValue 又调 parseArray 这样自然的递归循环。
# 灵魂三问:为什么手写解析器?
❓ 为什么不用正则表达式?
// ❌ 想用正则匹配 JSON
std::regex jsonRe(R"(\{.*\}|\[.*\]|".*"|...)"); // 完全写不出来
2
根本问题:JSON 是上下文无关文法,可以无限嵌套——[[[[...]]]]。正则表达式(正则文法)连"括号配对"都做不到——这是计算理论的"乔姆斯基层级"硬伤。所以 JSON / XML / 编程语言的语法分析永远不能只靠正则。
❓为什么不用 yacc/bison/ANTLR 这些"解析器生成器"?
| 维度 | 工具生成 | 手写递归下降 |
|---|---|---|
| 学习曲线 | 要学专门的语法(如 expr ::= NUM '+' expr;) | 一个 cpp 写完 |
| 错误消息 | 自动生成的,难以定制 | 想说什么说什么(行/列/上下文) |
| 调试 | 生成的代码无法 step into | 自己代码可以一行一行调 |
| 性能 | 通用算法(LALR)慢一些 | 业务定制可极致优化 |
结论:JSON 这种简单文法 + 教学场景,手写递归下降是最佳选择。工业级语言(如 Rust 的 syn / Go 的 go/parser)也是手写的。
❓ 递归下降的"递归"在哪?
parseValue看到[→ 调parseArrayparseArray在循环里 → 又调parseValue(解析每个元素)- 元素如果是
[...]→ 再调parseArray - 这就是互递归——本节阶段 ④ 你会亲眼看到这个循环
🔑 教学要点:递归下降的精髓是 "语法规则一条 = parseXxx 函数一个"。RFC 8259 JSON 文法只有 6 条规则——所以解析器恰好 6 个 parseXxx 函数。写起来就像翻译文档。
# Step 3.1 游标工具实现
先打地基:解析器需要"扫描器"——能往前看一个字符、消费当前字符、按需匹配特定字符。
新建 JsonParser.h:
// JsonParser.h(v1:只放工具方法和成员)
#pragma once
#include "JsonNode.h"
#include <string>
#include <string_view>
namespace jsonkv {
class JsonParser {
private:
std::string_view src; // 待解析的 JSON 源字符串(轻量引用,不拷贝)
size_t pos = 0; // 当前解析到的位置(索引)
size_t line = 1, col = 1; // 当前行号和列号(用于报错时定位)
// ===== 游标工具 =====
char peek() const; // 看当前字符(不前进)
char consume(); // 吃掉当前字符并前进
bool match(char c); // 当前字符如果是 c 就吃掉
void skipWhitespace();
[[noreturn]] void error(const std::string& msg);
public:
explicit JsonParser(std::string_view s) : src(s) {}
};
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
思考一下:为什么这里表示索引,行号等用size_t而不是int?因为 size_t 是"不会为负数"的整数,而 int 可能会变成负数。
size_t 是 C++ 标准库中用来表示大小和索引的无符号类型。索引、行号、列号天然是非负的,用 size_t 更符合语义。
新建 JsonParser.cpp:
// JsonParser.cpp(v1)
#include "JsonParser.h"
#include <cctype>
namespace jsonkv {
char JsonParser::peek() const {
return pos < src.size() ? src[pos] : '\0'; // 越界返回 \0
}
char JsonParser::consume() {
char c = src[pos++];
if (c == '\n') { line++; col = 1; } // 跨行就更新行号
else { col++; }
return c;
}
bool JsonParser::match(char c) {
if (peek() == c) { consume(); return true; }
return false;
}
void JsonParser::skipWhitespace() {
while (pos < src.size() &&
std::isspace(static_cast<unsigned char>(src[pos]))) {
consume();
}
}
void JsonParser::error(const std::string& msg) {
throw JsonParseError(msg, line, col);
}
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
写一个 main 玩转游标——但因为这些是私有方法,临时把它们改成 public: 测试,或者写一个友元测试函数。最简单的做法是在类里加一个公开的"调试钩子":
// JsonParser.h public 临时加:
public:
// 调试用:打印源串信息
void debugDump() const {
std::cout << "src len=" << src.size()
<< " pos=" << pos
<< " line=" << line
<< " col=" << col << "\n";
}
2
3
4
5
6
7
8
9
然后在做一下测试:
// test_parser.cpp
#include "JsonParser.h"
#include <iostream>
int main() {
jsonkv::JsonParser p("hello");
p.debugDump();
}
2
3
4
5
6
7
🧪 编译运行:
$ g++ -std=c++17 test_parser.cpp JsonParser.cpp JsonNode.cpp -o test_parser && ./test_parser
src len=5 pos=0 line=1 col=1
2
3
✅ 看到游标信息正确。下一步把 debugDump 删掉,进入正式解析逻辑。
📚 std::string_view:是"指向字符串的视图"——内部就是一个 const char* + 长度,不拥有数据。这就是它"零拷贝"的来源——传 1 GB 的 JSON 也只复制 16 字节的视图。⚠️ 注意它的危险:原字符串先析构 view 就成野指针,这就是 9.3 节 要专门讨论的话题。
# Step 3.2 字面量解析
给 JsonParser 加 parse() + parseValue() + parseLiteral() 三个方法。
JsonParser.h 类内追加:
private:
JsonNodePtr parseValue();
JsonNodePtr parseLiteral(); // null / true / false
public:
JsonNodePtr parse(); // 入口
2
3
4
5
6
JsonParser.cpp 追加:
JsonNodePtr JsonParser::parse() {
skipWhitespace();
auto node = parseValue();
skipWhitespace();
if (pos < src.size()) error("trailing characters after value");
return node;
}
JsonNodePtr JsonParser::parseValue() {
skipWhitespace();
char c = peek();
if (c == 'n' || c == 't' || c == 'f') return parseLiteral();
error(std::string("unexpected char: ") + c);
}
JsonNodePtr JsonParser::parseLiteral() {
if (src.compare(pos, 4, "null") == 0) { pos += 4; col += 4; return makeNull(); }
if (src.compare(pos, 4, "true") == 0) { pos += 4; col += 4; return makeBool(true); }
if (src.compare(pos, 5, "false") == 0) { pos += 5; col += 5; return makeBool(false); }
error("invalid literal");
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
升级 test_parser.cpp——分别解析 3 个字面量:
#include "JsonParser.h"
#include <iostream>
int main() {
using namespace jsonkv;
for (const char* input : {"null", "true", "false"}) {
JsonParser p(input);
auto node = p.parse();
std::cout << input << " => isNull=" << node->isNull()
<< " isBool=" << node->isBool();
if (node->isBool()) std::cout << " val=" << node->asBool();
std::cout << "\n";
}
}
2
3
4
5
6
7
8
9
10
11
12
13
14
🧪 预期输出:
null => isNull=1 isBool=0
true => isNull=0 isBool=1 val=1
false => isNull=0 isBool=1 val=0
2
3
✅ 三个字面量都正确解析。故意输入错的试试:
JsonParser p("nul"); // 缺一个 l
try { p.parse(); }
catch (const JsonParseError& e) { std::cout << e.what() << "\n"; }
2
3
🧪 预期输出:
[Parse] line 1 col 1: invalid literal
✅ 错误带行列号——这就是阶段 ① 异常底座的回报。
# Step 3.3 解析数字
继续追加:
JsonParser.h 类内:
JsonNodePtr parseNumber();
parseValue() 末尾追加分支:
JsonNodePtr JsonParser::parseValue() {
skipWhitespace();
char c = peek();
if (c == 'n' || c == 't' || c == 'f') return parseLiteral();
if (c == '-' || std::isdigit(static_cast<unsigned char>(c))) return parseNumber(); // ⭐ 新增
error(std::string("unexpected char: ") + c);
}
2
3
4
5
6
7
JsonParser.cpp 追加:
JsonNodePtr JsonParser::parseNumber() {
size_t start = pos;
if (match('-')) {} // 可能有负号
while (std::isdigit(static_cast<unsigned char>(peek()))) consume(); // 整数部分
if (peek() == '.') { // 小数部分
consume();
while (std::isdigit(static_cast<unsigned char>(peek()))) consume();
}
if (peek() == 'e' || peek() == 'E') { // 科学计数法
consume();
if (peek() == '+' || peek() == '-') consume();
while (std::isdigit(static_cast<unsigned char>(peek()))) consume();
}
try {
double v = std::stod(std::string(src.substr(start, pos - start)));
return makeNumber(v);
} catch (...) {
error("invalid number");
}
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
测试 4 种数字:
for (const char* s : {"42", "-3.14", "1e3", "-2.5e-2"}) {
JsonParser p(s);
std::cout << s << " => " << p.parse()->asNumber() << "\n";
}
2
3
4
🧪 预期输出:
42 => 42
-3.14 => -3.14
1e3 => 1000
-2.5e-2 => -0.025
2
3
4
5
✅ 整数、负小数、正指数、负指数全部正确。注意我们不用 atoi(只能识别整数),用 std::stod(支持完整 IEEE 754 浮点格式)。
┌─ 📌 阶段 ③ 小结 ────────────────────────────────────────┐
│ ✅ 已掌握 │
│ • Step 3.1 游标工具 peek/consume/match + 行列追踪 │
│ • Step 3.2 字面量 null/true/false 三选一 │
│ • Step 3.3 数字(整数/小数/科学计数法) │
│ 🔜 下一阶段 ④:字符串转义 + 数组对象递归 │
│ 💡 commit 建议:feat(jsonkv): parser literals & numbers │
└──────────────────────────────────────────────────────────┘
2
3
4
5
6
7
8
# Step 4.1 字符串无转义
┌─ 🎯 阶段 ④ 目标 ────────────────────────────────────────┐
│ 完成什么:parseString + parseArray + parseObject │
│ 不做什么:不做 \uXXXX Unicode 转义(留作挑战) │
│ 验收标准:能解析 [{"name":"Alice","age":30}] 这种嵌套结构 │
│ 预计耗时:90 min · 3 Step │
└──────────────────────────────────────────────────────────┘
2
3
4
5
6
先写最朴素的版本——只识别 "hello" 这种无转义字符串:
JsonParser.h 类内:
JsonNodePtr parseString();
parseValue() 追加:
if (c == '"') return parseString(); // 在数字分支之前加
JsonParser.cpp 追加(v1:暂不处理转义):
JsonNodePtr JsonParser::parseString() {
if (!match('"')) error("expected '\"'");
std::string result;
while (pos < src.size() && peek() != '"') {
result += consume();
}
if (!match('"')) error("unterminated string");
return makeString(std::move(result));
}
2
3
4
5
6
7
8
9
测试:
for (const char* s : {"\"hello\"", "\"\"", "\"abc 123\""}) {
JsonParser p(s);
std::cout << "[" << p.parse()->asString() << "]\n";
}
2
3
4
🧪 预期输出:
[hello]
[]
[abc 123]
2
3
4
✅ 三个普通字符串解析正确,包括空串。但遇到转义字符就翻车——下一步处理。
# Step 4.2 字符串转义补全
把 parseString 整体替换为带转义版本:
JsonNodePtr JsonParser::parseString() {
if (!match('"')) error("expected '\"'");
std::string result;
while (pos < src.size() && peek() != '"') {
char c = consume();
if (c == '\\') { // ⭐ 转义起始
char esc = consume();
switch (esc) {
case '"': result += '"'; break;
case '\\': result += '\\'; break;
case '/': result += '/'; break;
case 'n': result += '\n'; break;
case 't': result += '\t'; break;
case 'r': result += '\r'; break;
case 'b': result += '\b'; break;
case 'f': result += '\f'; break;
// \uXXXX Unicode 转义留作挑战 B
default: error(std::string("bad escape \\") + esc);
}
} else {
result += c;
}
}
if (!match('"')) error("unterminated string");
return makeString(std::move(result));
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
测试:
JsonParser p(R"("a\nb\t\"c\"")"); // 原始字符串字面量更直观
std::string s = p.parse()->asString();
std::cout << "len=" << s.size() << "\n";
std::cout << s << "\n";
2
3
4
🧪 预期输出:
len=7
a
b "c"
2
3
4
✅ 7 个字符(a, \n, b, \t, ", c, "),换行和制表符都正确转换。
📚 C++ 原始字符串 R"(...)":内层不需要再二次转义。R"("a\nb")" 表示字面量 "a\nb"(共 5 个字符)。教学时强烈推荐——避免被反斜杠绕晕。
# Step 4.3 数组与对象递归
这是整个解析器的高潮——递归下降的本质就是 parseValue → parseArray → parseValue → parseObject → ... 这样的相互调用。
JsonParser.h 类内:
JsonNodePtr parseArray();
JsonNodePtr parseObject();
2
parseValue() 补全为最终版:
JsonNodePtr JsonParser::parseValue() {
skipWhitespace();
char c = peek();
if (c == 'n' || c == 't' || c == 'f') return parseLiteral();
if (c == '"') return parseString();
if (c == '[') return parseArray(); // ⭐ 新增
if (c == '{') return parseObject(); // ⭐ 新增
if (c == '-' || std::isdigit(static_cast<unsigned char>(c))) return parseNumber();
error(std::string("unexpected char: ") + c);
}
2
3
4
5
6
7
8
9
10
JsonParser.cpp 追加:
JsonNodePtr JsonParser::parseArray() {
if (!match('[')) error("expected '['");
auto arr = JsonNode::Array{};
skipWhitespace();
if (match(']')) return std::make_unique<JsonNode>(std::move(arr)); // 空数组
while (true) {
arr.push_back(parseValue()); // ⭐ 递归
skipWhitespace();
if (match(']')) break;
if (!match(',')) error("expected ',' or ']' in array");
}
return std::make_unique<JsonNode>(std::move(arr));
}
JsonNodePtr JsonParser::parseObject() {
if (!match('{')) error("expected '{'");
auto obj = JsonNode::Object{};
skipWhitespace();
if (match('}')) return std::make_unique<JsonNode>(std::move(obj)); // 空对象
while (true) {
skipWhitespace();
auto keyNode = parseString(); // key 必须是字符串
std::string key = keyNode->asString();
skipWhitespace();
if (!match(':')) error("expected ':'");
auto value = parseValue(); // ⭐ 递归
obj[key] = std::move(value);
skipWhitespace();
if (match('}')) break;
if (!match(',')) error("expected ',' or '}' in object");
}
return std::make_unique<JsonNode>(std::move(obj));
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
最终一战——解析一个嵌套 JSON:
const char* json = R"([
{"name":"Alice","age":30,"vip":true},
{"name":"Bob","age":25,"vip":false}
])";
JsonParser p(json);
auto root = p.parse();
std::cout << "size = " << root->size() << "\n"; // 2
for (size_t i = 0; i < root->size(); ++i) {
auto& item = (*root)[i];
std::cout << item.at("name").asString()
<< " / " << item.at("age").asNumber()
<< " / vip=" << item.at("vip").asBool() << "\n";
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
🧪 预期输出:
size = 2
Alice / 30 / vip=1
Bob / 25 / vip=0
2
3
4
✅ 一个 200 行的解析器就能搞定嵌套数组+对象+布尔+数字+字符串 5 种类型混合。
┌─ 📌 阶段 ④ 小结 ────────────────────────────────────────┐
│ ✅ 已掌握 │
│ • Step 4.1 字符串无转义版本 │
│ • Step 4.2 7 种 \X 转义字符 │
│ • Step 4.3 数组+对象递归 ⭐ —— 整章高潮 │
│ 🔜 下一阶段 ⑤:JsonWriter 反向把节点序列化成字符串 │
│ 💡 commit 建议:feat(jsonkv): full json parser │
└──────────────────────────────────────────────────────────┘
2
3
4
5
6
7
8
📚 回头看:解析器只有约 200 行,能完整解析 RFC 8259 的 95% 语法(差 \uXXXX 和数字边缘 case)。这就是递归下降的威力——文法越规整、写起来越像翻译文档。完整支持留作 挑战 B。
# 05.序列化器(缩进美化输出)
┌─ 🎯 阶段 ⑤ 目标 ────────────────────────────────────────┐
│ 完成什么:JsonWriter——把 JsonNode 树反向写回字符串 │
│ 不做什么:暂不接 KvDatabase,专心调试格式化输出 │
│ 验收标准:parser → writer 来回转一遍,输出与输入语义一致 │
│ 预计耗时:60 min · 2 Step │
└──────────────────────────────────────────────────────────┘
2
3
4
5
6
💡 为什么要 Writer? —— 不光是"美化输出"。整个 KvDatabase 的 save() 落盘、get() 返回字符串值、CLI 打印结果,全靠它。可以把 Writer 看作 Parser 的反函数。
# 灵魂三问:Writer 设计前先想清楚
❓ 为什么 Writer 要支持"紧凑"和"pretty"两种模式?
两种模式的真实场景:
- 紧凑:保存到文件 / 网络传输 / 嵌入到日志——省字节
- pretty:人工查看 / 调试输出 / 配置文件——省眼睛
// 紧凑模式:1 行
{"users":[{"name":"Alice","age":30}]}
// pretty 模式:缩进美化
{
"users": [
{
"name": "Alice",
"age": 30
}
]
}
2
3
4
5
6
7
8
9
10
11
12
如果只支持其中一种就要写两遍 Writer 类——所以一个 step 参数(缩进空格数)控制两种模式是最优雅的设计。
❓ 为什么用 std::ostringstream 而不是 std::string + +=?
// ❌ 反例:直接 string 拼接
std::string result;
result += '"';
result += s;
result += '"';
result += std::to_string(3.14); // ⚠️ to_string 不支持精确 double 格式化(会丢精度)
2
3
4
5
6
问题:
std::to_string(double)固定 6 位小数——3.14159265会变3.141593- 大量 += 会触发多次 realloc——每次扩容拷贝整个串
✅ ostringstream 的优势:
oss << 3.14调用operator<<(double)——按 IEEE 754 默认精度- 内部缓冲指数扩容,比手动 += 快得多
oss.str()一次性取出最终字符串——零额外拷贝
❓ Writer 是 Parser 的反函数,那 parse(write(node)) == node 必须成立吗?
- 答:逻辑上必须——这叫"序列化往返一致性"(roundtrip)。这是 §5.2 末尾要做的回环测试。
🔑 教学要点:写一个序列化器看似简单,但"紧凑/pretty 双模式 + 字符串反向转义 + roundtrip 一致性"这三件事缺一不可——这是工业级 JSON 库必备能力。
# Step 5.1 紧凑序列化模式
新建 JsonWriter.h:
// JsonWriter.h
#pragma once
#include "JsonNode.h"
#include <string>
#include <sstream>
namespace jsonkv {
class JsonWriter {
private:
std::ostringstream oss;
int indent = 0; // 当前缩进深度
int step; // 每级缩进多少空格(0 = 紧凑)
bool pretty; // step > 0 即 pretty=true
void writeIndent() { for (int i = 0; i < indent; ++i) oss << ' '; }
void writeNode(const JsonNode& n);
void writeString(const std::string& s);
public:
explicit JsonWriter(int spaces = 2) : step(spaces), pretty(spaces > 0) {}
std::string write(const JsonNode& root);
};
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
新建 JsonWriter.cpp——先实现简单类型 + 字符串转义:
// JsonWriter.cpp
#include "JsonWriter.h"
namespace jsonkv {
std::string JsonWriter::write(const JsonNode& root) {
oss.str(""); // 重置缓冲区
indent = 0;
writeNode(root);
return oss.str();
}
void JsonWriter::writeString(const std::string& s) {
oss << '"';
for (char c : s) {
switch (c) {
case '"': oss << "\\\""; break;
case '\\': oss << "\\\\"; break;
case '\n': oss << "\\n"; break;
case '\t': oss << "\\t"; break;
case '\r': oss << "\\r"; break;
case '\b': oss << "\\b"; break;
case '\f': oss << "\\f"; break;
default: oss << c; break;
}
}
oss << '"';
}
void JsonWriter::writeNode(const JsonNode& n) {
switch (n.type()) {
case JsonNode::Type::Null: oss << "null"; break;
case JsonNode::Type::Bool: oss << (n.asBool() ? "true" : "false"); break;
case JsonNode::Type::Number: oss << n.asNumber(); break;
case JsonNode::Type::String: writeString(n.asString()); break;
case JsonNode::Type::Array: oss << "[/* TODO */]"; break; // 先占位
case JsonNode::Type::Object: oss << "{/* TODO */}"; break; // 先占位
}
}
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
测试 4 种简单类型——构造一棵小树喂给 Writer:
// test_writer.cpp
#include "JsonParser.h"
#include "JsonWriter.h"
#include <iostream>
int main() {
using namespace jsonkv;
JsonParser p(R"({"a":null,"b":true,"c":3.14,"d":"hi\nyou"})");
auto root = p.parse();
JsonWriter w(0); // 紧凑模式
std::cout << w.write(*root) << "\n";
}
2
3
4
5
6
7
8
9
10
11
12
13
🧪 预期输出:
{/* TODO */}
⚠️ 因为还没实现 Array/Object,输出是占位符。但字符串转义已经能通过测试——把 *root 改成 *root->at("d").something 之类的能直接打印 string 类型的话,会看到输出 "hi\nyou"(注意里面的换行被转义)。
# Step 5.2 Array 缩进美化
把 writeNode 中的两个占位分支换成完整版:
case JsonNode::Type::Array: {
const auto& arr = n.asArray();
if (arr.empty()) { oss << "[]"; break; }
oss << "[";
indent += step;
for (size_t i = 0; i < arr.size(); ++i) {
if (pretty) { oss << "\n"; writeIndent(); }
writeNode(*arr[i]); // ⭐ 递归
if (i + 1 < arr.size()) oss << ",";
}
indent -= step;
if (pretty) { oss << "\n"; writeIndent(); }
oss << "]";
break;
}
case JsonNode::Type::Object: {
const auto& obj = n.asObject();
if (obj.empty()) { oss << "{}"; break; }
oss << "{";
indent += step;
size_t i = 0;
for (const auto& [k, v] : obj) { // ⭐ 结构化绑定
if (pretty) { oss << "\n"; writeIndent(); }
writeString(k);
oss << (pretty ? ": " : ":");
writeNode(*v);
if (++i < obj.size()) oss << ",";
}
indent -= step;
if (pretty) { oss << "\n"; writeIndent(); }
oss << "}";
break;
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
写一个回环测试——用 Parser 解析,再用 Writer 写回:
const char* json = R"({"users":[{"name":"Alice","age":30},{"name":"Bob","age":25}]})";
JsonParser p(json);
auto root = p.parse();
std::cout << "--- 紧凑模式 ---\n";
JsonWriter w0(0);
std::cout << w0.write(*root) << "\n\n";
std::cout << "--- 缩进 2 空格 ---\n";
JsonWriter w2(2);
std::cout << w2.write(*root) << "\n";
2
3
4
5
6
7
8
9
10
11
12
🧪 预期输出:
--- 紧凑模式 ---
{"users":[{"age":30,"name":"Alice"},{"age":25,"name":"Bob"}]}
--- 缩进 2 空格 ---
{
"users": [
{
"age": 30,
"name": "Alice"
},
{
"age": 25,
"name": "Bob"
}
]
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
✅ Parser→Writer 回环通过,缩进版完美格式化。
📚 观察:
std::map<string, ...>是有序的,所以name和age输出顺序变成了字母序(age 排在 name 前面)。如果想保持插入顺序,要换std::vector<std::pair<...>>——这是教学版的简化。
┌─ 📌 阶段 ⑤ 小结 ────────────────────────────────────────┐
│ ✅ 已掌握 │
│ • Step 5.1 紧凑输出 + 字符串反向转义 │
│ • Step 5.2 Array/Object 递归 + pretty 缩进 │
│ 🔜 下一阶段 ⑥:把 Parser+Writer 串起来做 KvDatabase │
│ 💡 commit 建议:feat(jsonkv): json writer with pretty │
└──────────────────────────────────────────────────────────┘
2
3
4
5
6
7
# 06.KvDatabase 内存数据库
┌─ 🎯 阶段 ⑥ 目标 ────────────────────────────────────────┐
│ 完成什么:KvDatabase(启动加载、get/set/del、退出保存) │
│ 不做什么:暂不写 CLI,先用 main 直接调 API │
│ 验收标准:set 后退出再启动,能 get 到原值(即真正落了盘) │
│ 预计耗时:90 min · 3 Step │
└──────────────────────────────────────────────────────────┘
2
3
4
5
6
💡 本阶段的灵魂三问
- 数据存哪?→ 内存中一棵 JsonNode 树(顶层 object),文件里一份 JSON 文本
- 启动如何恢复?→ 析构时
save,构造后load- 没找到怎么表达?→
std::optional(C++17 标准答案)
# Step 6.1 空骨架与加载
新建 KvDatabase.h:
// KvDatabase.h
#pragma once
#include "JsonNode.h"
#include <optional>
#include <string>
namespace jsonkv {
class KvDatabase {
private:
JsonNodePtr root; // 顶层永远是 object
std::string dataFile;
bool dirty = false; // 有未保存的修改?
public:
explicit KvDatabase(std::string file = "kv.json");
~KvDatabase();
// 后续 Step 逐步添加:get/set/del
void load();
void save();
size_t size() const { return root->size(); }
};
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
新建 KvDatabase.cpp:
// KvDatabase.cpp
#include "KvDatabase.h"
#include "JsonParser.h"
#include "JsonWriter.h"
#include <fstream>
#include <sstream>
namespace jsonkv {
KvDatabase::KvDatabase(std::string file)
: root(makeObject()), dataFile(std::move(file)) {}
KvDatabase::~KvDatabase() {
if (dirty) {
try { save(); }
catch (...) { /* ⚠️ 析构不能让异常逃出 */ }
}
}
void KvDatabase::load() {
std::ifstream ifs(dataFile);
if (!ifs) return; // 首次运行无文件,OK
std::stringstream ss;
ss << ifs.rdbuf();
JsonParser p(ss.str());
root = p.parse();
if (!root->isObject()) { // 兜底:必须是 object
root = makeObject();
}
dirty = false;
}
void KvDatabase::save() {
JsonWriter w(2); // 美化输出
std::ofstream ofs(dataFile);
if (!ofs) throw JsonIoError("cannot open " + dataFile);
ofs << w.write(*root);
dirty = false;
}
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
测试 —— 空数据库的加载/保存:
// test_db.cpp
#include "KvDatabase.h"
#include <iostream>
int main() {
using namespace jsonkv;
KvDatabase db("test.json");
db.load();
std::cout << "size = " << db.size() << "\n"; // 0(空)
}
2
3
4
5
6
7
8
9
10
🧪 编译运行:
$ g++ -std=c++17 *.cpp -o test_db && ./test_db
size = 0
2
3
✅ 空数据库加载不报错。手工创建一个 test.json 试试:
$ echo '{"hello":"world","num":42}' > test.json
$ ./test_db
size = 2
2
3
✅ 文件存在时 size=2,证明 load() 接通了 Parser。
📚
if (dirty) try { save(); } catch (...)是析构函数的标准写法。析构时若有异常逃出,遇到正在异常展开的栈会触发std::terminate直接崩溃——所以析构里所有可能抛异常的操作都必须 try/catch 兜住。
# Step 6.2 get/set 单层
KvDatabase.h 类内追加:
public:
// ⭐ optional 表达"可能没有"
std::optional<std::string> get(const std::string& path) const;
void set(const std::string& path, JsonNodePtr value);
2
3
4
KvDatabase.cpp 追加(先做单层版,下一步再升级多级):
std::optional<std::string> KvDatabase::get(const std::string& path) const {
try {
const JsonNode& node = root->at(path);
JsonWriter w(0); // 紧凑序列化
return w.write(node);
} catch (const KeyNotFoundError&) {
return std::nullopt; // ⭐ 找不到返回 nullopt
}
}
void KvDatabase::set(const std::string& path, JsonNodePtr value) {
// ⚠️ v1:暂只支持单层 key,不带点号
root->asObject()[path] = std::move(value);
dirty = true;
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
测试:
KvDatabase db("test.json");
db.load();
db.set("name", makeString("Alice"));
db.set("age", makeNumber(30));
std::cout << "name = " << db.get("name").value_or("(nil)") << "\n";
std::cout << "age = " << db.get("age").value_or("(nil)") << "\n";
std::cout << "miss = " << db.get("city").value_or("(nil)") << "\n";
db.save(); // 落盘
2
3
4
5
6
7
8
9
10
11
🧪 预期输出:
name = "Alice"
age = 30
miss = (nil)
🧪 检查文件:
$ cat test.json
{
"age": 30,
"name": "Alice"
}
2
3
4
5
6
7
8
9
10
11
✅ 三件事一起验证通过:set 能改、get 能查、save 真落盘。
📚
std::optional<T>::value_or(default)—— 没有值时返回默认。比 "返回 bool 同时通过出参传值" 清晰得多。
# Step 6.3 多级路径与 del
把 set 整体替换为支持 a.b.c 路径的版本:
void KvDatabase::set(const std::string& path, JsonNodePtr value) {
JsonNode* cur = root.get();
size_t start = 0;
while (true) {
size_t dot = path.find('.', start);
std::string seg = (dot == std::string::npos)
? path.substr(start)
: path.substr(start, dot - start);
if (dot == std::string::npos) { // 终点:直接赋值
cur->asObject()[seg] = std::move(value);
dirty = true;
return;
}
// 中间路径:不存在或不是 object 就建一个空 object
auto& obj = cur->asObject();
if (obj.find(seg) == obj.end() || !obj[seg]->isObject()) {
obj[seg] = makeObject();
}
cur = obj[seg].get();
start = dot + 1;
}
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
并新增 del:
KvDatabase.h 类内追加:
bool del(const std::string& path);
KvDatabase.cpp 追加:
bool KvDatabase::del(const std::string& path) {
size_t dot = path.rfind('.');
if (dot == std::string::npos) {
// 单层:直接 erase
bool ok = root->asObject().erase(path) > 0;
if (ok) dirty = true;
return ok;
}
// 多层:先找到父节点
try {
JsonNode& parent = root->at(path.substr(0, dot));
if (!parent.isObject()) return false;
bool ok = parent.asObject().erase(path.substr(dot + 1)) > 0;
if (ok) dirty = true;
return ok;
} catch (const KeyNotFoundError&) {
return false;
}
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
最终测试 —— 多级路径 + 删除 + 持久化:
KvDatabase db("test.json");
db.load();
db.set("user.name", makeString("Alice"));
db.set("user.address.city", makeString("Beijing"));
db.set("user.age", makeNumber(30));
std::cout << *db.get("user.name") << "\n"; // "Alice"
std::cout << *db.get("user.address.city") << "\n"; // "Beijing"
std::cout << *db.get("user") << "\n"; // 整个 user 对象
bool deleted = db.del("user.age");
std::cout << "del user.age: " << deleted << "\n"; // 1
std::cout << db.get("user.age").value_or("(nil)") << "\n"; // (nil)
db.save();
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
🧪 预期输出:
"Alice"
"Beijing"
{"address":{"city":"Beijing"},"age":30,"name":"Alice"}
del user.age: 1
(nil)
🧪 检查持久化:
$ cat test.json
{
"user": {
"address": {
"city": "Beijing"
},
"name": "Alice"
}
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
✅ 多级路径自动建中间 object、del 真删除并 dirty、save 真落盘——这就是一个"Mini Redis"已经长好的样子。
# 🚨 故障演示:dirty 标记的"半成品"陷阱
阶段 ⑥ 已经能跑了——但埋了一个真实工程师必踩的坑。看这段会出问题的代码:
// ⚠️ 假设我们这样调用(连续 set 但中间一个会抛异常)
db.set("a.b", makeNumber(1)); // OK
db.set("a", makeNumber(99)); // 把 a 直接覆盖成 number
db.set("a.x", makeString("hi")); // ⚠️ a 已经不是 object 了!
2
3
4
会发生什么? 来看 set 的多级路径循环:
// 中间路径:不存在或不是 object 就建一个空 object
auto& obj = cur->asObject(); // ⚠️ cur 是 a,但 a 是 number,asObject() 抛 TypeMismatchError
2
执行流:第 1 次 set 成功 → dirty=true → 第 2 次 set 成功 → 第 3 次 set 中途 throw → dirty 还是 true,但 a.x 没真正写入。
# 🧪 真的跑一遍看看
KvDatabase db("test.json");
db.load();
db.set("a.b", makeNumber(1));
db.set("a", makeNumber(99)); // a 变成 number
try {
db.set("a.x", makeString("hi")); // ⚠️ 这里抛
} catch (const TypeMismatchError& e) {
std::cout << "caught: " << e.what() << "\n";
}
std::cout << *db.get("a") << "\n"; // 99,没受影响
db.save(); // dirty 是 true,会落盘——**但落盘的是部分修改后的状态**
2
3
4
5
6
7
8
9
10
11
12
13
14
预期输出:
caught: [Type] expected object, got number
99
2
💡 这不算 bug 但是教训:set 内部应保证"要么全成功、要么全不改"——这就是数据库的**ACID 之 A(原子性)**的微观体现。本案例为了简洁不做事务回滚,但你在挑战 D(下面追加)会回头加上。
# 🛠 防御方法:先校验路径再修改
void KvDatabase::set(const std::string& path, JsonNodePtr value) {
// ⭐ 第一遍:只走读路径,确保中间节点要么不存在要么是 object
JsonNode* check = root.get();
size_t s = 0;
while (true) {
size_t d = path.find('.', s);
if (d == std::string::npos) break;
std::string seg = path.substr(s, d - s);
if (!check->isObject()) throw TypeMismatchError("object", "non-object on path");
auto& obj = check->asObject();
if (obj.find(seg) != obj.end() && !obj[seg]->isObject()) {
throw TypeMismatchError("object", "non-object at " + seg);
}
if (obj.find(seg) == obj.end()) break; // 后面要建空 object,无需继续校验
check = obj[seg].get();
s = d + 1;
}
// ⭐ 第二遍:真正修改(这一遍不会再抛 TypeMismatch)
// ...(原 set 实现)
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
🎯 这正是 03 案例 §9.6 "外键校验" 的同款思路——先全部校验通过,再开始修改状态。这就是真实数据库系统每天都在做的事。
┌─ 📌 阶段 ⑥ 小结 ────────────────────────────────────────┐
│ ✅ 已掌握 │
│ • Step 6.1 空骨架 + load/save + 析构 noexcept │
│ • Step 6.2 get(optional)+ set 单层版 │
│ • Step 6.3 多级路径 + del + dirty 管理 │
│ 🔜 下一阶段 ⑦:包一层 CLI REPL 让人能交互 │
│ 💡 commit 建议:feat(jsonkv): kv database with persist │
└──────────────────────────────────────────────────────────┘
2
3
4
5
6
7
8
# 07.CLI 工具与 REPL
┌─ 🎯 阶段 ⑦ 目标 ────────────────────────────────────────┐
│ 完成什么:5 命令 REPL(GET/SET/DEL/SAVE/EXIT) │
│ 不做什么:不做命令历史、不做 readline 自动补全 │
│ 验收标准:交互式 SET 用户数据 → EXIT 重启 → GET 还在 │
│ 预计耗时:30 min · 1 Step │
└──────────────────────────────────────────────────────────┘
2
3
4
5
6
# 灵魂三问:CLI 该如何设计?
❓ 为什么 main 函数本身不直接处理命令,要包一层 repl() 函数?
来看反例:
// ❌ 全部塞进 main
int main(int argc, char** argv) {
KvDatabase db(argv[1]);
db.load();
while (...) {
// 命令解析、try/catch、输出格式化全堆在 main
}
}
2
3
4
5
6
7
8
问题:
- 职责混乱——main 应该只做"组装 + 启动",命令循环是业务逻辑
- 测试不友好——想单测 REPL 就必须启动整个 main
- 无法复用——将来想加 TCP 模式时,只能复制这段循环
✅ 正确做法:把 REPL 抽成独立函数,main 仅做依赖装配(这是依赖注入的最简形式)。
❓ 顶层那个 try { ... } catch (const JsonError& e) 兜底真的有必要吗?
绝对必要!这是 §02 阶段 ① 写的异常底座的最终大结算:
| 异常种类 | 在哪 throw | 在哪 catch |
|---|---|---|
JsonParseError | Parser 在用户 SET 错误 JSON 时 | ⭐ REPL 顶层 |
TypeMismatchError | Node 在 GET 类型不符时 | ⭐ REPL 顶层 |
KeyNotFoundError | Node 在 GET 不存在的路径时 | (转 optional 化为 (nil)) |
JsonIoError | KvDb 在 save 文件失败时 | ⭐ REPL 顶层 |
没有这个 catch 会怎样:用户随便输 SET k {{{ → 异常向上传播 → main 函数没有 catch → std::terminate 直接崩溃——一个错命令搞挂整个 REPL。这是工业级 CLI 必须的"防御层"。
❓ 为什么 SET 命令支持任意 JSON 而不是 SET path string-only?
- 答:因为这是 Mini Redis 不是 Mini-Mini Redis。
SET user.tags ["c++", "json"]这种业务必须支持。所以 SET 后面剩下的整行都喂给 Parser——既复用了 Parser,又让 SET 命令一行支持 6 种类型。
🔑 教学要点:到这一节,你才真正看到**§02 异常体系的远期回报**——80 行的异常类换来了 §07 这里3 个 throw 点对应 1 个顶层 catch 的清爽防御层。这就是"先打地基"的工程价值。
# Step 7.1 循环读命令兜底
新建 main.cpp:
// main.cpp
#include "KvDatabase.h"
#include "JsonParser.h"
#include <iostream>
#include <sstream>
using namespace jsonkv;
void repl(KvDatabase& db) {
std::cout << "jsonkv REPL.\n"
"Commands:\n"
" GET <path> 查询路径值\n"
" SET <path> <json> 设置(json 是合法 JSON)\n"
" DEL <path> 删除\n"
" SAVE 立即落盘\n"
" EXIT 退出(自动落盘)\n";
std::string line;
while (std::cout << "> ", std::getline(std::cin, line)) {
std::istringstream iss(line);
std::string cmd; iss >> cmd;
try { // ⭐ 顶层一把抓所有 JsonError
if (cmd == "GET") {
std::string path; iss >> path;
auto v = db.get(path);
std::cout << (v ? *v : "(nil)") << "\n";
}
else if (cmd == "SET") {
std::string path; iss >> path;
std::string rest;
std::getline(iss, rest); // path 后面剩下的整行 = JSON
JsonParser p(rest);
db.set(path, p.parse());
std::cout << "OK\n";
}
else if (cmd == "DEL") {
std::string path; iss >> path;
std::cout << (db.del(path) ? "OK" : "(nil)") << "\n";
}
else if (cmd == "SAVE") {
db.save();
std::cout << "saved\n";
}
else if (cmd == "EXIT") {
break;
}
else if (cmd.empty()) {
continue;
}
else {
std::cout << "unknown command\n";
}
}
catch (const JsonError& e) { // 解析错/类型错/IO错都进这里
std::cout << "ERR " << e.what() << "\n";
}
}
}
int main(int argc, char** argv) {
KvDatabase db(argc > 1 ? argv[1] : "kv.json");
db.load();
repl(db);
db.save();
return 0;
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
编译并交互一把:
🧪 编译运行:
$ g++ -std=c++17 *.cpp -o jsonkv
$ ./jsonkv kv.json
jsonkv REPL.
Commands: ...
> SET user.name "Alice"
OK
> SET user.age 30
OK
> SET user.address.city "Beijing"
OK
> GET user
{"address":{"city":"Beijing"},"age":30,"name":"Alice"}
> GET user.address.city
"Beijing"
> DEL user.age
OK
> SET bad {{{
ERR [Parse] line 1 col 2: unexpected char: {
> SAVE
saved
> EXIT
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
🧪 重启验证持久化:
$ ./jsonkv kv.json
> GET user.name
"Alice"
> EXIT
2
3
4
5
✅ 重启后数据还在——"内存 + 文件双层存储"完整闭环。故意打错 JSON 也不会崩——异常被顶层 catch 兜住,REPL 继续等下一行。
┌─ 📌 阶段 ⑦ 小结 ────────────────────────────────────────┐
│ ✅ 已掌握 │
│ • Step 7.1 5 命令 REPL + 顶层异常兜底 │
│ 🎉 整个项目跑通,进入 [08.项目总结] 复盘 │
│ 💡 commit 建议:feat(jsonkv): cli repl │
└──────────────────────────────────────────────────────────┘
2
3
4
5
6
💼 生产化补丁建议:替换
std::getline为 linenoise (opens new window) 或 GNU readline 即获得命令历史 + Tab 补全;加WATCH <path>命令配合 inotify 即可作为配置中心使用。
# 08.项目总结盘点
| 现代特性 | 出现次数 | 典型用法 |
|---|---|---|
std::variant | 1 处(核心) | 表达 6 种 JSON 类型 |
std::unique_ptr | 全篇 | 节点所有权 |
make_unique | 10+ 处 | 替代 new |
std::optional | 3 处 | get 返回值 |
std::string_view | Parser | 零拷贝字符串引用 |
移动语义 std::move | 全篇 | 字符串、unique_ptr 转移 |
| 结构化绑定 | 5 处 | for (auto& [k, v] : obj) |
std::visit | (挑战) | variant 模式匹配 |
[[noreturn]] | error() | 静态分析友好 |
继承构造 using base::base | 异常类 | 复用基类构造 |
项目数据:1100 行 / 8 文件 / 0 处裸 new / 0 处手动 delete / 5 个自定义异常类。
# 09.项目技术思考
# 9.1 unique_ptr 拷贝问题
unique_ptr<T> 表达独占所有权——同一时间只能有一个 unique_ptr 指向同一对象。它禁用了拷贝构造和拷贝赋值,只允许移动构造和移动赋值。
auto p1 = std::make_unique<JsonNode>();
auto p2 = p1; // ❌ 编译错误:拷贝构造被删除
auto p3 = std::move(p1); // ✅ 转移所有权,p1 变 nullptr
2
3
为什么这样设计?避免"两个指针指向同一对象、各自 delete 一次"的灾难(double-free)。如果你需要共享所有权,用 shared_ptr。
# 9.2 异常 vs 错误码
| 场景 | 推荐 | 理由 |
|---|---|---|
| 用户输入错误(如菜单选错) | 错误码 | 频繁发生,开销不可忽略 |
| 资源不可用(文件打不开、内存不足) | 异常 | 偶发但严重,必须处理 |
| 不变量被破坏(数组越界、变体类型错) | 异常 | 程序员错误,应当立即崩溃 |
| 跨多层调用栈传递 | 异常 | 中间层不需要逐层传 errno |
| 性能敏感的内层循环 | 错误码 | 异常路径开销大(几百纳秒到微秒) |
本案例的选择:所有 JSON 解析错、类型错、文件错都用异常;CLI 的"未知命令"用错误码(cout 提示)。
# 9.3 string_view 危险
string_view 是指针 + 长度的视图,不拥有数据。如果原始字符串先析构,view 就成了野指针:
std::string_view bad() {
std::string s = "hello";
return s; // ⚠️ s 析构后,返回的 view 指向无效内存
}
2
3
4
安全用法:
- 函数参数(接收方不会保存)
- 局部变量(不返回、不存放)
- 解析器内部(解析过程中原字符串保持存活)
本案例 JsonParser 在构造时接收 string_view,使用方必须保证传入的字符串在 parse() 期间不被析构。
# 10.衔接与延伸
# 10.1 与上一案例的差异
| 维度 | 案例 03 校园 | 案例 04 JSON+KV |
|---|---|---|
| 容器节点类型 | 同质 | 异质(variant 6 种) |
| 内存管理 | shared_ptr | unique_ptr 全 RAII |
| 错误处理 | 错误码 | 完整异常体系 |
| 数据结构 | 扁平表 | 递归树 |
| 字符串 | std::string | string_view 零拷贝 |
# 10.2 下一案例的递进
下一案例 05.多线程订单与线程池 会做四件升级:
- 加并发:多个线程同时读写 KvDatabase(mutex / shared_mutex)
- 生产者-消费者:condition_variable 实现订单队列
- future / promise:异步任务结果获取
- 线程池组件:可重用的工业级线程池
# 10.3 三个延伸挑战
挑战 A(基础)· 实现 std::visit
用 std::visit 重写 JsonWriter::writeNode:
void writeNode(const JsonNode& n) {
std::visit([this](auto&& arg){
using T = std::decay_t<decltype(arg)>;
if constexpr (std::is_same_v<T, std::nullptr_t>) oss << "null";
else if constexpr (std::is_same_v<T, bool>) oss << (arg ? "true" : "false");
// ...
}, n.value());
}
2
3
4
5
6
7
8
挑战 B(进阶)· 加 Unicode \uXXXX 转义解析
完整支持 RFC 8259 的字符串转义,包括 \u4E2D 这种中文 Unicode。
挑战 C(现代化)· 用 PMR(多态内存资源)
用 std::pmr::vector 替换 std::vector,所有 JSON 节点从同一个内存池分配——大幅减少堆碎片。这是 C++17 的高级特性。
挑战 D(事务原子性)· set 操作要么全成功要么全失败
回到 §6.3 故障演示 提到的"半成品陷阱"。重写 set() 让它具备真实数据库的原子性——这里给两种实现思路:
- 预校验法(已在 §6.3 给出代码片段):先遍历一遍校验路径合法,再开始修改
- 影子拷贝法:修改前先拷贝整个 root 树(深拷贝),如果 set 中途抛异常就 root = std::move(backup) 回滚
实现哪种取决于你的取舍:预校验法零额外内存但要遍历两遍;影子拷贝法只走一遍但需要深拷贝整个树。这个权衡正是现实数据库的"乐观并发 vs 悲观锁"的雏形。
挑战 E(生产化)· 配置热加载
让 KvDatabase 监听 kv.json 文件变化(inotify/FSEvents),文件被外部修改时自动重新 load——这样这个 KV 数据库就变成了一个配置中心。提示:用 std::thread + 文件 mtime 轮询是最简实现,挑战 E+ 是用真正的 inotify。
- ⬅ 上一案例:03.校园身份预约系统 (opens new window)
- ➡ 下一案例:05.多线程订单与线程池 —— 并发栈:mutex / cv / future / 线程池