JSON for Modern C++ 诊断位置详解:nlohmann::basic_json::end_pos 与 JSON_DIAGNOSTIC_POSITIONS 实战
【免费下载链接】jsonJSON for Modern C++项目地址: https://gitcode.com/GitHub_Trending/js/json
end_pos()是 nlohmann::basic_json 在开启JSON_DIAGNOSTIC_POSITIONS宏后可用的一个成员函数,用于返回某个 JSON 值在其来源 JSON 字符串中"最后一个字符之后的位置"。配合start_pos()使用,可以精确定位任意节点在原始输入中的字节区间,从而把解析结果与源文本片段一一映射。本文将完整讲清end_pos()的语义、不同 JSON 类型下的返回值规则、失效条件与代价,并深入源码剖析位置信息是如何在 SAX 解析器中写入的,帮助你在构建 JSON 编辑器高亮、错误回溯、增量解析等工具时可靠地落地这一特性。
函数签名与基本语义
官方 API 文档(见 end_pos)给出的声明是:
#if JSON_DIAGNOSTIC_POSITIONS constexpr std::size_t end_pos() const noexcept; #endif即该函数只有在编译前把宏JSON_DIAGNOSTIC_POSITIONS定义为1时才会存在。它返回该 JSON 值被解析时,在原始 JSON 字符串中最后一个字符之后的那个位置(半开区间语义:位置本身不属于该值)。
不同 JSON 类型对应的返回值如下(表格完整继承自官方文档):
| JSON 类型 | 返回值 |
|---|---|
| object | 位置在闭合}之后 |
| array | 位置在闭合]之后 |
| string | 位置在闭合"之后 |
| number | 位置在最后一个字符之后 |
| boolean | 位置在e之后 |
| null | 位置在l之后 |
注意 boolean 与 null 的表述:true/false都以e结尾、null以l结尾,所以end_pos()恰好指向这四个字母之后的字节位置。
返回值规则:如果值是由parse函数创建的,则返回上述位置;如果值是通过其他方式构造的(字面量、push_back、其他构造函数等),则返回std::string::npos。
异常安全:无抛出保证(No-throw guarantee),此成员函数从不抛出异常。时间复杂度:常量。
与之互补的start_pos()(见 start_pos)返回值的第一个字符位置。两者相减即为该值在源串中的完整长度(含开闭括号、引号):
std::size_t len = j.end_pos() - j.start_pos();启用方式:宏定义与 CMake 选项
方式一:在包含头文件前定义宏
最简单的启用方式是在包含库头文件之前把宏定义为 1(注意必须在#include之前):
#define JSON_DIAGNOSTIC_POSITIONS 1 #include <nlohmann/json.hpp>如果宏从未被用户定义,库会自行将其定义为一个默认值。从源码 abi_macros.hpp 可以看到默认值是0(关闭):
#ifndef JSON_DIAGNOSTIC_POSITIONS #define JSON_DIAGNOSTIC_POSITIONS 0 #endif也就是说,位置诊断默认是关闭的,只有显式开启后才存在start_pos()/end_pos()这两个成员函数——不开启时直接调用它们会编译失败。
方式二:CMake 选项 JSON_Diagnostic_Positions
当以 CMake 子目录、FetchContent或find_package方式集成时,可以不用手动定义宏,而是使用 CMake 选项。项目根 CMakeLists.txt 中声明了该选项(默认OFF):
option(JSON_Diagnostic_Positions "Enable diagnostic positions." OFF)开启后,该选项通过target_compile_definitions给nlohmann_json目标追加JSON_DIAGNOSTIC_POSITIONS=1的接口级定义(见 CMakeLists.txt 中的$<$<BOOL:${JSON_Diagnostic_Positions}>:JSON_DIAGNOSTIC_POSITIONS=1>)。配置文档 cmake.md 对这一节也有对应说明:
JSON_Diagnostic_Positions— Enable position diagnostics by defining macroJSON_DIAGNOSTIC_POSITIONS. This option isOFFby default.
典型用法:
cmake -S your_project -B build -DJSON_Diagnostic_Positions=ON # 子项目场景下对应 nlohmann_json 的选项代价说明
启用该宏会带来额外开销,官方文档(见 JSON_DIAGNOSTIC_POSITIONS)明确指出:每个 JSON 值会多出两个std::size_t成员(start_position与end_position,源码中默认初始化为std::string::npos),解析、JSON 值对象的拷贝以及异常错误信息生成都会有轻微运行时开销;作为回报,这些位置信息也会出现在相关异常的错误消息中。因此建议只在确实需要源码位置信息的工具型项目(解析器、编辑器插件、LSP 等)中开启。
完整示例:提取任意节点对应的原始片段
下面这个完整示例继承自仓库中的 diagnostic_positions.cpp,演示了对根对象、嵌套对象、字符串字段、数字字段分别调用start_pos()/end_pos(),并用substr(start_pos(), end_pos() - start_pos())从原始字符串中精确切出该节点对应的子串:
#include <iostream> #define JSON_DIAGNOSTIC_POSITIONS 1 #include <nlohmann/json.hpp> using json = nlohmann::json; int main() { std::string json_string = R"( { "address": { "street": "Fake Street", "housenumber": 1 } } )"; json j = json::parse(json_string); std::cout << "Root diagnostic positions: \n"; std::cout << "\tstart_pos: " << j.start_pos() << '\n'; std::cout << "\tend_pos:" << j.end_pos() << "\n"; std::cout << "Original string: \n"; std::cout << "{\n \"address\": {\n \"street\": \"Fake Street\",\n \"housenumber\": 1\n }\n }" << "\n"; std::cout << "Parsed string: \n"; std::cout << json_string.substr(j.start_pos(), j.end_pos() - j.start_pos()) << "\n\n"; std::cout << "address diagnostic positions: \n"; std::cout << "\tstart_pos:" << j["address"].start_pos() << '\n'; std::cout << "\tend_pos:" << j["address"].end_pos() << "\n\n"; std::cout << "Original string: \n"; std::cout << "{ \"street\": \"Fake Street\",\n \"housenumber\": 1\n }" << "\n"; std::cout << "Parsed string: \n"; std::cout << json_string.substr(j["address"].start_pos(), j["address"].end_pos() - j["address"].start_pos()) << "\n\n"; std::cout << "street diagnostic positions: \n"; std::cout << "\tstart_pos:" << j["address"]["street"].start_pos() << '\n'; std::cout << "\tend_pos:" << j["address"]["street"].end_pos() << "\n\n"; std::cout << "Original string: \n"; std::cout << "\"Fake Street\"" << "\n"; std::cout << "Parsed string: \n"; std::cout << json_string.substr(j["address"]["street"].start_pos(), j["address"]["street"].end_pos() - j["address"]["street"].start_pos()) << "\n\n"; std::cout << "housenumber diagnostic positions: \n"; std::cout << "\tstart_pos:" << j["address"]["housenumber"].start_pos() << '\n'; std::cout << "\tend_pos:" << j["address"]["housenumber"].end_pos() << "\n\n"; std::cout << "Original string: \n"; std::cout << "1" << "\n"; std::cout << "Parsed string: \n"; std::cout << json_string.substr(j["address"]["housenumber"].start_pos(), j["address"]["housenumber"].end_pos() - j["address"]["housenumber"].start_pos()) << "\n\n"; }对应输出(来自 diagnostic_positions.output):
Root diagnostic positions: start_pos: 5 end_pos:109 Original string: { "address": { "street": "Fake Street", "housenumber": 1 } } Parsed string: { "address": { "street": "Fake Street", "housenumber": 1 } } address diagnostic positions: start_pos:26 end_pos:103 Original string: { "street": "Fake Street", "housenumber": 1 } Parsed string: { "street": "Fake Street", "housenumber": 1 } street diagnostic positions: start_pos:50 end_pos:63 Original string: "Fake Street" Parsed string: "Fake Street" housenumber diagnostic positions: start_pos:92 end_pos:93 Original string: 1 Parsed string: 1从输出可以验证几个关键点:
- 根对象从第 5 字节(前导换行与空格之后)开始,到第 109 字节结束——即
end_pos()指向闭合}之后的位置,与"半开区间"定义一致; - 字符串
"Fake Street"的区间[50, 63)长度 13,正好覆盖开闭引号本身; - 数字
1的区间[92, 93)长度 1; - 由于
end_pos() - start_pos()得到的是完整文本长度,substr切出来的内容与源串中该字段的原文(含空白格式)完全一致。这意味着你保留了源文件中的原始排版,而不是dump()重新序列化后的结果——这对"只高亮/只替换被修改字段"这类需求非常有用。
源码剖析:位置信息是怎么写进去的
理解end_pos()为什么可靠,关键要看解析路径。在 json.hpp 中,开启宏后basic_json新增了私有成员和两个公开访问器:
#if JSON_DIAGNOSTIC_POSITIONS /// the start position of the value std::size_t start_position = std::string::npos; /// the end position of the value std::size_t end_position = std::string::npos; public: constexpr std::size_t start_pos() const noexcept { return start_position; } constexpr std::size_t end_pos() const noexcept { return end_position; } #endif可以看到两个成员默认值都是std::string::npos,这正是"非 parse 创建则返回 npos"这一语义的直接实现。
真正的赋值发生在 DOM 解析器 json_sax.hpp 的json_sax_dom_parser中。该解析器在构造时可以接收一个lexer_t*(m_lexer_ref),借助词法分析器的实时游标get_position()记录各回调时刻的字节位置:
- 对象开始(
handle_object):词法分析器刚读完开括号,因此start_position = get_position() - 1指向{; - 对象结束(
handle_end_object):词法分析器已越过闭括号,所以end_position = get_position(),即}之后的位置——这正是end_pos()文档中"position after the closing}"的实现来源; - 数组同理:
handle_array用get_position() - 1记录[,handle_end_array记录]之后的位置; - 原始值(布尔、null、字符串、数字):
handle_diagnostic_positions_for_json_value先用当前游标写入end_position,再按值的类型反推start_position。从源码结构看,其策略是按已知字面量长度倒推:boolean减去 4(true)或 5(false),null减去 4,string/数字等则减去词法单元文本长度get_string().size()。这个"先固定右端、再倒推左端"的方式保证了end_pos()始终与词法游标的绝对位置对齐,不依赖值的内部表示。
basic_json侧还需要保证位置随对象流转。源码 json.hpp 中,拷贝构造、移动构造和移动赋值都对start_position/end_position做了相应处理:拷贝构造直接拷贝两个成员;移动构造把位置搬给新对象后,将源对象的位置置回std::string::npos(即"移动后的原对象不再有位置");移动赋值则交换两个成员。这解释了为什么"移动后的旧引用再调用end_pos()会拿到 npos"。
此外,位置信息还服务于异常诊断。exceptions.hpp 中的get_byte_positions会在start_pos()与end_pos()均非 npos 时拼出(bytes X-Y)前缀。仓库示例 diagnostic_positions_exception.output 展示的效果是:
[json.exception.type_error.302] (bytes 92-95) type must be number, but is string也就是说,end_pos()不只是查询接口,它还是异常消息里字节区间的右端点。
关键限制:只对 parse 有效,且不随修改更新
使用end_pos()前必须牢记文档 JSON_DIAGNOSTIC_POSITIONS 中强调的两条约束:
只有
parse会填充位置。sax_parse以及一切其他方式(构造函数、字面量、push_back等)创建的 JSON 值不会设置诊断位置,其start_pos()/end_pos()一律返回std::string::npos。这与源码一致:位置写入逻辑全部位于依赖 lexer 游标的 DOM 解析回调中,而sax_parse路径不经过这些回调(除非你自行实现携带位置逻辑的 SAX 类)。因此判断"这个值是否可定位"的惯用法是:if (j.end_pos() != std::string::npos) { /* 可安全使用区间 */ }位置失效(Invalidation)警告:返回的位置仅在 JSON 值不被修改时有效;值被修改后位置不会随之更新。从源码看,库并不在每次
operator[]/push_back等变更操作后重新计算位置(那既昂贵也做不到,因为增量修改没有统一的"源串偏移"概念)。所以正确姿势是:解析后只读地查询位置,一旦要写回源文本,应基于"解析时的快照字符串 + 位置区间"做拼接或替换,而不是期待库自动维护一致性。位置是相对于被解析的那份输入字符串,而非 JSON 文档的逻辑行/列。若输入本身是文件的一个片段(例如已经
substr过),得到的偏移需要加上片段在文件中的起始位置才是文件级偏移。
测试用例中的行为验证
仓库的测试进一步印证了上述语义:
- unit-diagnostic-positions.cpp 中对每个词法单元校验
text.substr(v.start_pos(), v.end_pos() - v.start_pos()) == token,并对根对象断言j.end_pos() == root.size()——即根值的end_pos()恰好等于整个源串长度(无尾部空白时),与示例中"半开区间末端即字符串末尾"的行为吻合; - unit-class_parser_diagnostic_positions.cpp 则大量使用
root.substr(start_pos(), end_pos() - start_pos())与预期子串比对,验证嵌套对象、嵌套数组各级区间都能精确还原源文本片段。
这两份测试可以作为你在自己项目中编写"位置回归测试"的模板。
小结
end_pos()本身只是一个constexpr、noexcept、常数时间的简单访问器,但其背后是一套以词法游标为基准、在 SAX 解析回调中逐节点写入的位置追踪机制(见 json_sax.hpp)。实际使用时的要点:
- 开启前提:编译前定义
JSON_DIAGNOSTIC_POSITIONS 1,或使用 CMake 选项JSON_Diagnostic_Positions=ON,默认均为关闭; - 语义:半开区间右端点,
end_pos() - start_pos()即该值在源串中的完整长度; - 可用边界:仅
parse创建的值可定位,sax_parse与手工构造的值返回std::string::npos; - 失效边界:值一旦被修改,位置不再更新,需要基于解析快照自行维护映射;
- 额外收益:异常消息中附带
(bytes X-Y)字节区间,便于定位解析/类型错误。
对于需要"从 JSON 值反向定位源文本"的工具链(编辑器集成、diff 生成、错误诊断),这两个函数是官方提供的标准入口,配套文档见 end_pos、start_pos 与 JSON_DIAGNOSTIC_POSITIONS。
【免费下载链接】jsonJSON for Modern C++项目地址: https://gitcode.com/GitHub_Trending/js/json
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考