【C++三方组件】nlohmann/json:像写 STL 一样操作 JSON
【摘要】:JSON 是现代开发里最短的距离------配置、接口、日志,处处是它。上一篇说过,标准库在这里一片空白,而手拼字符串的第一个反斜杠就会让你翻车。nlohmann/json 给出的答案是「人体工学优先」:一个
json对象 ≈ 自描述的 variant,穿了一件 STL 的衣服,j["name"] = "x"就完成赋值,初始化列表直接长成 JSON 的形状。本文按七段式拆解:30 秒接入、心智模型、常用 API(示例已实测)、与 RapidJSON/simdjson 的选型对比、五个必知的坑(全部在 v3.12.0 上实测复现),以及什么时候该换走。【关键词】:nlohmann/json、JSON、序列化、STL 风格、ADL、选型
【版本基准】:nlohmann/json v3.12.0(MIT)|代码基准 C++17|文中输出均为 g++ 11.2 实测
1. 痛点:手写 JSON 的两种死法
上一篇的手拼函数已经展示过第一种死法------序列化:
cpp
// ❌ 转义只处理了双引号
auto body = "{\"name\":\"" + name + "\"}";
// name = R"(a"b\c)" 时输出非法 JSON
第二种死法是解析 :从 {"msg": "他说:\"好\""} 里切出字符串值,需要一台能处理转义、代理对、嵌套括号的状态机。手写它不是为了加班,就是为了挖洞。
nlohmann/json 把这两件事压缩到两行:
cpp
json j = json::parse(input); // 解析
auto out = j.dump(); // 序列化
代价是什么、什么时候这代价太大,见第 5、6 节。先看怎么把它请进来。
2. 30 秒接入
三条路任选其一(上一篇第 5 节的详细对比):
json
// 路线一 vcpkg:vcpkg.json
{ "dependencies": [ "nlohmann-json" ] }
cmake
# 路线一续 / 路线三通用:
find_package(nlohmann_json CONFIG REQUIRED)
target_link_libraries(app PRIVATE
nlohmann_json::nlohmann_json)
cmake
# 路线三 FetchContent(URL 即锁版本)
include(FetchContent)
FetchContent_Declare(json
URL https://github.com/nlohmann/json/
releases/download/v3.12.0/json.tar.xz)
FetchContent_MakeAvailable(json)
还有第四条最原始的路:直接把单头文件 json.hpp 拷进工程------它约 2.5 万行 / 950KB ,零依赖,一个 #include <nlohmann/json.hpp> 就能用。快速原型和教学场景这条路最快。
3. 核心心智模型:自描述的 variant,穿了 STL 的衣服
用一句话携带这个库:json 是一个「自己知道自己是什么」的值。展开成类型:
cpp
// 概念上(非真实定义):
json ≈ variant<nullptr_t, bool,
int64_t, uint64_t, double, // 数字三态
string,
vector<json>, // array
map<string, json>>; // object
三层皮包住这个 variant:
| 皮 | 表现 | 例 |
|---|---|---|
| STL 容器接口 | 像容器一样操作 | j["k"]、push_back、size、迭代器、items() |
| 隐式转换 | 赋值即定型 | j["port"] = 8080; 自动存成整数 |
| 序列化入口 | 一进一出 | parse() / dump() |
用好它的前提,是接受「值是动态类型」这个心智 :C++er 的直觉是类型在编译期定死,而 json 的类型在运行期由内容决定------j.is_number_integer()、is_null()、is_array() 是你的 typeid。这个转换想通了,后面的 API 全都是顺水推舟。
有一个语法现象值得单独点破:为什么初始化列表能直接长成 JSON 的形状------
cpp
json j = {{"name", "sync"}, // 每个元素是二元素数组
{"tags", {"a", "b"}}};
// j.dump() == {"name":"sync","tags":["a","b"]}
花括号列表会优先匹配 initializer_list<json> 构造重载;库再对「列表元素全都是二元素数组」做特判,折叠成对象,否则按数组收------同一套 initializer_list 语法,两种 JSON 形态,这就是「像写 STL 一样写 JSON」的语法根基。
4. 常用 API:一个示例看全
下面这段程序覆盖五个高频场景,编译命令 g++ -std=c++17 demo.cpp,输出为实测:
cpp
#include <nlohmann/json.hpp>
#include <iostream>
#include <string>
using json = nlohmann::json;
struct Server {
std::string host;
int port = 0;
};
NLOHMANN_DEFINE_TYPE_NON_INTRUSIVE(
Server, host, port)
int main() {
// 1 解析:从字符串(也可从文件/流)
json cfg = json::parse(R"({
"name": "asset-sync",
"retries": 3,
"tags": ["fast", "safe"],
"server": {"host": "127.0.0.1",
"port": 8080}
})");
// 2 访问:get / value / contains
std::cout << cfg["name"]
.get<std::string>() << "\n";
std::cout << cfg.value("timeout", 30)
<< "\n"; // 缺键给默认值
std::cout << std::boolalpha
<< cfg.contains("tags") << "\n";
// 3 修改 + 序列化
cfg["retries"] = 5;
cfg["tags"].push_back("retry");
// 4 遍历(C++17 结构化绑定)
for (auto& [key, val] : cfg.items()) {
if (val.is_number())
std::cout << key << " ";
}
std::cout << "\n";
for (auto& tag : cfg["tags"])
std::cout << tag << " ";
std::cout << "\n";
// 5 自定义类型直转
Server s = cfg["server"].get<Server>();
std::cout << s.host << ":" << s.port
<< "\n";
json sj = Server{"db.local", 5432};
std::cout << sj.dump() << "\n";
}
实测输出:
text
asset-sync
30
true
retries
"fast" "safe" "retry"
127.0.0.1:8080
{"host":"db.local","port":5432}
五个场景的要点:
- 访问三件套 :
get<T>()显式取值(类型不符抛异常);value(key, 默认值)缺键或类型错都给默认值------配置读取首选 ;contains(key)只查不碰。 - 遍历的产出是
json&不是字符串 ------注意输出里"fast"带着引号:直接<<一个 json 值得到的是它的 JSON 文本;要裸值需tag.get<std::string>()。 - 自定义类型直转 是它最讨喜的能力:
NLOHMANN_DEFINE_TYPE_NON_INTRUSIVE(Server, host, port)一个宏生成to_json/from_json,此后Server与json互相赋值。宏的原理是 ADL:序列化函数必须定义在类型所在的命名空间------放进别的 namespace 会编译失败(第 6 节坑 ① 的近亲)。 - 序列化用
dump():dump(4)带 4 空格缩进;dump(-1, ' ', true)把非 ASCII 转成\uXXXX(实测"中文"→"\u4e2d\u6587"),跨编码环境更稳。 - 文件进出走标准流:
std::ifstream in("cfg.json"); json j; in >> j;解析、out << j.dump(2)写出(实测)------运算符重载让 JSON 和int、string一样进出 iostream。 - 数字三态保真:整数存整数、浮点存浮点------
json(3).dump()是3,json(3.0).dump()是3.0(实测)。
5. 选型对比:人体工学的价目表
| nlohmann/json | RapidJSON | simdjson | |
|---|---|---|---|
| 心智模型 | STL 值语义 | DOM/SAX 双模 | 只读 SIMD 解析 |
| 可写回 | ✅ 任意修改 | ✅(较繁琐) | ❌ 只读 |
| 单头文件 | ~950KB | ~600KB(仅头) | 需编译 |
| 许可 | MIT | MIT | Apache-2.0 |
| 甜区 | 配置/业务对象 | 高频读写热路径 | 极高吞吐只读 |
性能差异必须给出量级但别背数字:以 RapidJSON 官方基准及多项公开第三方测试的普遍结论,解析吞吐上 RapidJSON 通常领先 nlohmann/json 数倍到一个数量级 ,simdjson 再高一个台阶;但倍数高度依赖数据形态与编译器,选型前务必用自己的真实数据自测(这正是第 1 篇五维度把性能排在最后的原因------配置文件一天解析一次的场景,慢十倍毫无感知)。
一句话选型:默认 nlohmann/json,性能敏感的热路径换 RapidJSON,只读的海量解析上 simdjson。〔关联 第 3 篇:RapidJSON 的 SAX 与 in-situ 解析〕
6. 五个必知的坑(v3.12.0 实测)
坑 ①:operator[] 缺键自动插入 null,拿 null 当别的类型读会抛异常。
cpp
json m = json::object();
m["ghost"]; // 光是访问,m 就多了一个 null 成员
// 实测:m.contains("ghost") == true,size 0→1
bool b = static_cast<bool>(m["ghost"]);
// ❌ 抛出 type_error.302:
// "type must be boolean, but is null"
查询语义请交给三件套(contains / value / at);operator[] 只用于「确定存在」或「就是想写入」。另外在 const json 上对缺键用 operator[] 是未定义行为,at() 才是带异常的安全版(实测抛 out_of_range,错误 id 403)。
坑 ②:对象遍历与 dump 的键序是字典序,不是插入序。 默认 object 用 std::map 实现------实测先插 zeta/alpha/mid,遍历输出 alpha mid zeta。要保插入序,换 nlohmann::ordered_json(实测输出 zeta alpha mid),两者 API 完全一致,只差 object 的底层容器。
坑 ③:解析失败默认抛异常。 与库交互的失败路径是异常(parse_error 带字节位置、out_of_range、type_error 各带错误 id),映射到结构化异常处理没问题;但在禁异常的代码库里,用无异常模式:
cpp
json j = json::parse(text, nullptr, false);
if (j.is_discarded()) { /* 解析失败 */ }
坑 ④:NaN/inf 序列化为 null (实测 dump() 输出 null),不报错。浮点计算结果直接进 JSON 前,先想清楚这是不是你要的语义。
坑 ⑤:编译期成本真实存在。 近千 KB 的头文件直接吃编译时间。缓解办法:只在少数几个翻译单元里 #include,模块边界上进出的是 Server 这类具体类型而不是 json------这顺便也是好的封装。
7. 延伸与联动
- 官方文档:json.nlohmann.me------API 全集、特性矩阵、异常列表都在,本文只覆盖了高频的一成。
- 想知道「数字三态」「异常 id」在实现上怎么落地的,看
basic_json的模板参数与detail::parse_error的定义------单头文件无跳转,正是源码阅读的好材料。〔关联 《C++ 源码学习》专栏:同样的库,那边讲设计,这边讲使用〕 - 下一篇正面对比:RapidJSON------把「快」做到极致的另一条路线,SAX 流式解析与 in-situ 就地解析是怎么省掉每一次拷贝的。
参考 :nlohmann/json 官方文档、GitHub 仓库(v3.12.0,MIT 许可)。文中全部行为断言与输出在 v3.12.0 + g++ 11.2(-std=c++17)实测复现;性能量级表述的口径见第 5 节。