JSON for Modern C++ 实战指南:nlohmann::basic_json::diff 生成 RFC 6902 JSON Patch
本文基于 JSON for Modern C++(nlohmann/json)仓库中 diff 接口文档 展开,系统讲解静态成员函数 nlohmann::basic_json::diff 的语义、参数与返回值约定,并结合 single_include/nlohmann/json.hpp 中的实际实现,深入剖析它如何递归地比较两个 JSON 值、按类型分支生成 replace / remove / add 三类操作,以及为什么 source.patch(diff(source, target)) == target 这一往返(round-trip)性质总能成立。读完本文,你可以直接在项目中用 diff + patch 组合实现文档增量比对与同步。
接口定义与核心语义
diff 是 basic_json 的静态方法,用于计算两个 JSON 值之间的差异,并产出一个符合 RFC 6902 (JSON Patch) 规范的补丁:
static basic_json diff(const basic_json& source,
const basic_json& target);
其核心语义是:构造一个 JSON Patch,使得对 source 应用该补丁后得到 target。对于任意两个 JSON 值 source 和 target,下面的恒等式始终成立:
source.patch(diff(source, target)) == target;
这正是该接口最有价值的性质——它是可验证的往返保证:你不需要信任生成过程,只需验证应用结果。patch 接口负责对副本应用补丁并返回新值,patch_inplace 则就地修改当前对象。
参数
| 参数 | 方向 | 说明 |
|---|---|---|
source |
in | 比较的起点,即需要被修改的源 JSON 值 |
target |
in | 比较的目标,即期望达到的 JSON 值 |
返回值
一个 JSON 补丁(JSON 数组),应用后可将 source 转换为 target。若两值完全相同,返回空数组(见下文源码分析)。
异常安全
强保证(Strong guarantee):若抛出异常,JSON 值不会发生任何改变。由于 diff 是纯计算函数、不修改入参,这一保证在实现上自然成立。
复杂度
与 source 和 target 的长度成线性关系。实现上是递归遍历,每个节点最多被访问两次(source 一次、target 一次)。
当前限制(Notes)
按文档说明,当前版本只会生成 remove、add、replace 三种操作,不生成 move、copy、test。这对实际使用意味着:生成的补丁是自包含的、无顺序依赖的(除了数组删除),可以直接交给任何符合 RFC 6902 的外部工具执行。
完整示例:从文档差异到补丁
官方示例位于 examples/diff.cpp,完整代码如下:
#include <iostream>
#include <iomanip>
#include <nlohmann/json.hpp>
using json = nlohmann::json;
using namespace nlohmann::literals;
int main()
{
// the source document
json source = R"(
{
"baz": "qux",
"foo": "bar"
}
)"_json;
// the target document
json target = R"(
{
"baz": "boo",
"hello": [
"world"
]
}
)"_json;
// create the patch
json patch = json::diff(source, target);
// roundtrip
json patched_source = source.patch(patch);
// output patch and roundtrip result
std::cout << std::setw(4) << patch << "\n\n"
<< std::setw(4) << patched_source << std::endl;
}
该示例比较两个对象:source 中 baz 的值是 "qux",且多出键 foo;target 中 baz 变为 "boo",且新增键 hello。运行后输出(与 diff.output 一致):
[
{
"op": "replace",
"path": "/baz",
"value": "boo"
},
{
"op": "remove",
"path": "/foo"
},
{
"op": "add",
"path": "/hello",
"value": [
"world"
]
}
]
{
"baz": "boo",
"hello": [
"world"
]
}
三条操作与文档描述一一对应:共有键 baz 值不同 → replace;foo 只在 source 中存在 → remove;hello 只在 target 中存在 → add。source.patch(patch) 的结果与 target 完全一致,验证了往返性质。
源码解析:diff 是如何逐步生成补丁的
实现位于 single_include/nlohmann/json.hpp,其逻辑可以清晰地拆分为四层判断:
1. 快路径:相等则返回空补丁
// if the values are the same, return an empty patch
if (source == target)
{
return result;
}
若两值完全相等,直接返回空数组。空补丁应用于任何文档都是无操作,因此递归到子节点时不会产生冗余操作——这是补丁保持“最小化”的基础。
2. 类型不同:整体替换
if (source.type() != target.type())
{
// different types: replace value
result.push_back(
{
{"op", "replace"}, {"path", path}, {"value", target}
});
return result;
}
一旦 source 与 target 的 JSON 类型不同(例如对象变为数组、字符串变为数字),不再深入比较,而是直接生成一条 replace 操作把整个值替换掉。path 是递归过程中逐层累积出来的 JSON Pointer,递归入口处默认值为空字符串 "",即文档根节点。
3. 数组:先对齐公共前缀,再删除多余、追加新增
case value_t::array:
{
// first pass: traverse common elements
std::size_t i = 0;
while (i < source.size() && i < target.size())
{
// recursive call to compare array values at index i
auto temp_diff = diff(source[i], target[i],
detail::concat<string_t>(path, '/', detail::to_string<string_t>(i)));
result.insert(result.end(), temp_diff.begin(), temp_diff.end());
++i;
}
// remove my remaining elements
const auto end_index = static_cast<difference_type>(result.size());
while (i < source.size())
{
// add operations in reverse order to avoid invalid indices
result.insert(result.begin() + end_index, object(
{
{"op", "remove"},
{"path", detail::concat<string_t>(path, '/', detail::to_string<string_t>(i))}
}));
++i;
}
// add other remaining elements
while (i < target.size())
{
result.push_back(
{
{"op", "add"},
{"path", detail::concat<string_t>(path, "/-")},
{"value", target[i]}
});
++i;
}
break;
}
这里有三处值得注意的设计细节:
- 公共前缀递归比较:索引相同的元素逐对递归
diff,子补丁直接拼接进结果。 - 尾部删除的插入位置:
source中多出的元素生成remove操作,但这些操作通过result.insert(result.begin() + end_index, ...)插到本轮子补丁序列的最前面。从源码结构看,这是为了让删除操作先于后续追加操作执行,从而避免索引在补丁应用顺序下失效——因为后续add都使用/-(追加到数组末尾)路径,与删除的索引互不影响。 /-追加语义:target中多出的元素统一用 JSON Pointer 的/-特殊 token 表示“追加到数组末尾”,这正是 RFC 6902 定义的合法路径。
4. 对象:双向遍历键集,键名先转义
case value_t::object:
{
// first pass: traverse this object's elements
for (auto it = source.cbegin(); it != source.cend(); ++it)
{
// escape the key name to be used in a JSON patch
const auto path_key = detail::concat<string_t>(path, '/', detail::escape(it.key()));
if (target.find(it.key()) != target.end())
{
// recursive call to compare object values at key it
auto temp_diff = diff(it.value(), target[it.key()], path_key);
result.insert(result.end(), temp_diff.begin(), temp_diff.end());
}
else
{
// found a key that is not in o -> remove it
result.push_back(object(
{
{"op", "remove"}, {"path", path_key}
}));
}
}
// second pass: traverse other object's elements
for (auto it = target.cbegin(); it != target.cend(); ++it)
{
if (source.find(it.key()) == source.end())
{
// found a key that is not in this -> add it
const auto path_key = detail::concat<string_t>(path, '/', detail::escape(it.key()));
result.push_back(
{
{"op", "add"}, {"path", path_key},
{"value", it.value()}
});
}
}
break;
}
对象分支的处理与示例输出完全对应:
- 第一遍遍历
source的键:键在target中也存在 → 递归比较对应值;键只在source中存在 → 生成remove(示例中的/foo)。 - 第二遍遍历
target的键:键只在target中存在 → 生成add并携带完整值(示例中的/hello)。 - JSON Pointer 转义:键名在拼接到 path 前会经过
detail::escape(...)处理,对~和/做 RFC 6901 规定的转义(~→~0,/→~1)。这意味着键名中包含/或~的对象也不会破坏补丁的合法性。
5. 原始类型:替换整个值
对于 null、字符串、布尔、整数、无符号数、浮点数、binary 与 discarded 类型,同类型但值不同(类型不同已在第 2 层拦截)时统一生成 replace:
// both primitive types: replace value
result.push_back(
{
{"op", "replace"}, {"path", path}, {"value", target}
});
与 patch 的衔接
patch 的实现在 single_include/nlohmann/json.hpp:
/// @brief applies a JSON patch to a copy of the current object
basic_json patch(const basic_json& json_patch) const
{
basic_json result = *this;
result.patch_inplace(json_patch);
return result;
}
patch 先复制当前对象再就地应用补丁,因此调用方持有的 source 不变,返回值即“应用后的新值”——这正是 diff 文档中往返恒等式能够安全书写的底层支撑。patch_inplace 内部按 add / remove / replace / move / copy / test 等 patch_operations 分支执行,并会对非法操作抛出异常(如操作值为 unsuccessful 的 test 会触发 501 类 other_error)。
往返性质在测试中的大规模验证
文档承诺的 source.patch(diff(source, target)) == target 并非空话。单元测试 tests/src/unit-json_patch.cpp 覆盖了大量 RFC 6902 场景(RFC 4.1 add、4.2 remove、4.3 replace 等小节),其中数十处断言反复验证同一模式,例如:
CHECK(doc.patch(json::diff(doc, expected)) == expected);
这些断言散布于该测试文件的 117、143、172、229、271、621 等行,覆盖对象、数组、嵌套结构等多种文档形态;另有反向用例 json::diff(target, source)(650 行附近)验证差异生成的对称方向。可以说,往返恒等式是这套 diff/patch 机制在测试层面被持续守护的不变量。
适用场景与相关接口
diff 的典型使用场景是文档版本同步:服务端保存旧版本 source,客户端提交新版本 target 后,用 json::diff(source, target) 得到最小操作序列,再持久化或转发给其他系统以 patch 重放。相比直接存储整份新文档,补丁只记录变化部分,天然适配审计、消息队列传输等场景。
相关接口可进一步对照阅读:
- patch:对一个副本应用 JSON Patch 并返回结果;
- patch_inplace:就地应用 JSON Patch,不产生中间副本;
- merge_patch:应用 RFC 7386 JSON Merge Patch,语义不同——Merge Patch 用
null表示删除、整体递归合并,操作粒度远粗于diff生成的逐键补丁。
选型上的简单判据:需要精确、可审计、可外部执行的变更操作时用 diff + patch;只是“用补丁对象覆盖原对象的部分字段”这类宽松合并需求时,merge_patch 更省事。
版本与限制说明
diff自 version 2.0.0 引入(见原文档 Version history)。- 当前实现只产出
remove/add/replace三种操作;若你的下游系统支持move/copy,也不会从diff得到这些操作,需要自行优化补丁。 diff比较的是结构值相等(source == target的语义),对象键的顺序不参与比较——这与 RFC 6902 关于对象成员顺序无关的约定一致,tests/src/unit-json_patch.cpp 的 "4. Operations" 小节也专门验证了操作对象内键序不影响等价性。- 数组差异基于位置对齐(按索引逐元素比较),而不是序列对齐算法(LCS 类):两个仅在元素顺序上不同的数组,可能生成较“重”的替换序列,这是理解其行为边界时的一个重要前提。
atomcodeClaude Code 的开源替代方案。连接任意大模型,编辑代码,运行命令,自动验证 — 全自动执行。用 Rust 构建,极致性能。 | An open-source alternative to Claude Code. Connect any LLM, edit code, run commands, and verify changes — autonomously. Built in Rust for speed. Get StartedRust0624
Hy4-previewHy4 preview 是由腾讯混元团队研发的新一代混合专家(MoE)旗舰模型。模型总参数量 770B,每个 token 激活 49B,主干共包含78层,第一层采用标准 FFN,其余 77 层均为 MoE 结构,每层包含 256 个路由专家与 1 个共享专家,每个 token 激活 top-8 路由专家及共享专家。主干之外原生内置 1 层 MTP(总参数量 10B,激活 0.7B)以支持投机解码。Python00
GLM-5.3GLM-5.3 与 GLM-5.2 使用相同的基座模型——所有提升均来自后训练。与 GLM-5.2 相比,它在复杂编程和长程任务上的表现显著提升。Jinja00
GLM-5.3-FlashGLM-5.3-Flash (320B-A18B),是GLM-5系列的首个原生多模态模型。320B总参数,能力超过GLM-5.2Jinja00
Spark-X2.5-4BSpark-X2.5-4B 旨在让强大的 AI 更实用、更高效、更易获得。在广泛日常任务中表现强劲,涵盖对话、写作、翻译、推理、编码、工具调用以及智能体工作流,并在同等规模的开源模型中取得领先成绩。Spark-X2.5 将面向效率的架构与最高 1M tokens 的原生上下文窗口相结合,并支持 200 多种语言。Python00
Spark-X2.5-1.7BSpark-X2.5-1.7B 旨在让强大的 AI 更加实用、高效且易于获取。这些模型在广泛的日常任务中表现出色,涵盖对话、写作、翻译、推理、编程、工具调用和智能体工作流,并在同等规模的开源模型中取得领先结果。Spark-X2.5 将面向效率的架构与最高 1M tokens 的原生上下文窗口相结合,并支持 200 多种语言。Python00