【C++三方组件】nlohmann/json:像写 STL 一样操作 JSON

【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_backsize、迭代器、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,此后 Serverjson 互相赋值。宏的原理是 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 和 intstring 一样进出 iostream。
  • 数字三态保真:整数存整数、浮点存浮点------json(3).dump()3json(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_rangetype_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 节。

相关推荐
Yingye Zhu(HPXXZYY)1 小时前
洛谷B4337 [中山市赛 2023] 简单数学题
c++·算法
郝学胜-神的一滴1 小时前
C++11 工程级应用 12:编译期类型魔法,干掉重复与臃肿的代码
开发语言·数据结构·c++·软件工程·visual studio
m0_734571761 小时前
深入理解C++ 析构函数<四>虚析构函数
开发语言·c++
学习智者2 小时前
《玄》IDE v3.8.2重磅发布:数据外置+全链路优化
开发语言·c++·ide·中文语言 玄
anew___10 小时前
蜂鸣器播放音乐——让 Arduino 唱出旋律
c++·stm32·单片机·嵌入式硬件·c
stolentime12 小时前
洛谷P10515 转圈题解
c++·算法·数学建模·贪心算法
tdtsmt13 小时前
穿戴硬件量产工艺实录:天地通电子智能手表主板 SMT 贴片案
c++
m0_7345717616 小时前
深入理解C++ 析构函数<一>基本概念
开发语言·c++
t-think17 小时前
C++ string 类(上)—— string类初识与常用接口详解
开发语言·c++