第6章:JSON 处理:jsoncpp 实战

一串字符走进内存,变成一棵树,再变回一串字符——跟着柯南把 JSON 的数据流完整走一遍

🔍

本章导师:柯南

核心方法论:追踪数据流

「数据不会说谎,但它会绕路。解析是把一串字符还原成它本来的结构,序列化是让结构重新出发——顺着这条数据流走一遍,每一处异常都藏不住。这一章,我们追踪 JSON 的全程:文本 → 内存树 → 文本。」

6.1 零基础铺垫:JSON 是什么,为什么 C++ 需要库

JSON(JavaScript Object Notation)是一种轻量级的数据交换格式——说人话:它是一种约定俗成的纯文本格式,程序之间用一串字符来传递结构化数据。你写的程序要跟别人写的程序(或者自己的另一个程序)说话,最省事的办法就是:我这边把数据拼成一串文本发过去,你那边把文本拆回数据。JSON 就是这个"拼/拆"的共同约定,它基于 JavaScript 的对象字面量语法,标准定义在 RFC 8259ECMA-404 里,几乎所有编程语言都有现成的支持。它的两大结构只有两个:键值对(key-value pair)——一个名字对应一个值,用冒号连接;以及数组——一组按顺序排列的值,用逗号分隔。把这两种结构随便嵌套,就构成了 JSON 的整个世界:对象套数组、数组套对象,想怎么套怎么套。

JSON 的值一共有六种类型:null(空)、布尔值(true/false)、数字、字符串、数组、对象。注意两点:键必须带双引号,字符串也必须用双引号(单引号不合法);数字不分整数和浮点,写出来什么样就是什么样。下面是一个典型的配置文件,很多程序启动时就是读这种文件来决定"连哪个服务器、开哪些功能":

JSON 类型示例说人话
nullnull空值,表示"这里什么都没有"
布尔值true / false是 / 否
数字173.14整数、小数都算数字
字符串"柯南"一段文本,必须双引号
数组[1, 2, 3]有序的一串值,用方括号
对象{"name": "柯南"}键值对的集合,用花括号
// config.json:程序启动时读它来配置自己
{
  "server": {
    "host": "127.0.0.1",
    "port": 8080
  },
  "users": ["alice", "bob"],
  "debug": true
}

那问题来了:C++ 为什么要借助库来处理 JSON?因为 C++ 标准库没有内置 JSON 支持——标准库里没有"JSON 解析"这个功能,这和 Python(自带 json 模块)、JavaScript(JSON 就是它的亲儿子)都不一样。没有现成的,就得自己写。自己写有多痛苦?你要处理转义字符(字符串里的引号、反斜杠)、处理层层嵌套的括号、判断"17"到底是数字还是字符串、还得报出"第几行第几列出错了"——这些细节错一个,整个解析结果就是错的,而且很难排查。下面的"错误示范"就是手动解析的真实体验:

// 错误示范:想从 JSON 里取 "age" 的值,直接按字符位置截取
std::string s = R"({"name":"柯南","age":17})";
size_t pos = s.find("\"age\"");
std::string v = s.substr(pos + 6);   // 下一个字符是冒号还是空格?值带不带引号?
// 万一 age 的值是字符串 "17" 呢?万一键名顺序变了呢?
// 万一嵌套在深层对象里呢?手动解析处处是坑

所以社区的做法是:用现成的库。库帮你把"解析文本"和"生成文本"这两件又难又烦的事做好,你只管定义"数据长什么样",然后调用几个函数。这一章的实战主角,就是 C++ 生态里最老牌、最简单的 JSON 库之一——jsoncpp。第 5 章我们用 vcpkg 铺好了"装库"的地基,本章正好实战检验。

6.2 jsoncpp 是什么:把文本变成内存里的一棵树

jsoncpp 是一个开源的 C++ JSON 解析与生成库——说人话:给它一串 JSON 文本,它帮你拆成 C++ 能直接用的数据;给它 C++ 里的数据,它帮你拼成 JSON 文本。它的源码托管在 github.com/open-source-parsers/jsoncpp,采用 MIT 许可,可以放心商用。项目已经非常成熟,目前处于"维护模式"(maintenance mode)——官方明确说优先保证稳定可靠,不追新功能、不搞性能竞赛,这对于一个"给老工程当依赖"的库来说反而是优点。它还有个少见的特色:解析和序列化时可以保留注释(JSON 标准本身不允许注释,但 jsoncpp 宽松处理),因此很适合拿 JSON 当配置文件格式的场景。

理解 jsoncpp,最关键的是理解它的核心模型:JSON 文档在内存里是一棵,树的每个节点都是一个 Json::Value。柯南的方法论在这里派上用场——追踪数据流:文本(磁盘/网络)→ 解析 → 内存树 → 序列化 → 文本(磁盘/网络)。字符串进来,变成树;树改好,变回字符串。中间的每一步都有明确的 API 对应,本章后面几节就是沿着这条流逐一走。Json::Value 能表示 JSON 的所有类型,和 JSON 六种值类型一一对应(实现里细分了整数和浮点):

Json::Value 类型对应 JSON取值方法
nullValuenullisNull()
booleanValuetrue / falseasBool()
intValue / uintValue整数asInt() / asUInt()
realValue浮点数asDouble()
stringValue字符串asString()
arrayValue数组size() + 下标
objectValue对象operator[] + 键名

第一次实战,先写一个最小的完整程序:解析一段 JSON 字符串,取出两个字段打印。头文件只有一个,就是素材笔记里的那个 json/json.h

// demo.cpp:解析字符串 + 取字段(新 API:CharReaderBuilder)
#include <json/json.h>
#include <iostream>
#include <memory>
#include <string>

int main() {
    std::string text = R"({"name":"柯南","age":17})";

    Json::CharReaderBuilder builder;                          // 解析器的"工厂"
    Json::Value root;                                         // 树根节点
    std::string errs;                                         // 出错信息收集器
    std::unique_ptr<Json::CharReader> reader(builder.newCharReader());
    bool ok = reader->parse(text.c_str(), text.c_str() + text.size(), &root, &errs);

    if (!ok) {
        std::cerr << "解析失败:" << errs << std::endl;
        return 1;
    }
    std::cout << root["name"].asString() << std::endl;   // 柯南
    std::cout << root["age"].asInt() << std::endl;        // 17
    return 0;
}

这段代码信息量不小,先记住三个动作:建工厂CharReaderBuilder)、解析parse,成功返回 true)、取字段root["键名"] 得到子节点,再 asXxx() 转成 C++ 类型)。解析完立刻再序列化回去,让数据流完整走一圈看看——注意输出的键序:

// 接着上面的代码:解析出来之后,马上序列化回字符串
Json::StreamWriterBuilder wbuilder;
wbuilder["indentation"] = "";                       // 紧凑输出
std::cout << Json::writeString(wbuilder, root) << std::endl;
// 输入是 {"name":"柯南","age":17},输出却是:
// {"age":17,"name":"柯南"}
// 键的顺序变了!为什么?6.7 节揭晓——这正是本章最大的坑
柯南提示

解析(parse)和序列化(write)是本章的两条主线:前者是"文本 → 树",后者是"树 → 文本"。搞混了没关系,记住一个检验法:parse 是动词的"读",write 是动词的"写",一个进一个出,数据流的上下游就清楚了。

6.3 安装 jsoncpp:vcpkg 一键装 & 源码编译

装 jsoncpp 最省事的路径,就是第 5 章铺好的 vcpkg。装完之后 CMake 工程里 find_package(jsoncpp CONFIG REQUIRED) 找到它,链接目标用 jsoncpp_lib——注意 vcpkg 安装完成时会打印它支持的目标名,照抄即可。完整流程三件套:装包、CMake 声明、链接:

# 第 5 章装好的 vcpkg,一条命令装 jsoncpp(Linux 默认编译静态库)
./vcpkg/vcpkg install jsoncpp
# 显式指定 64 位 Linux
./vcpkg/vcpkg install jsoncpp:x64-linux
# CMakeLists.txt:找到 jsoncpp 并链接
cmake_minimum_required(VERSION 3.10)
project(json_demo CXX)

find_package(jsoncpp CONFIG REQUIRED)
add_executable(json_demo main.cpp)
target_link_libraries(json_demo PRIVATE jsoncpp_lib)

第二条路径是源码编译,素材笔记里记录了真实操作。笔记里的环境是 CentOS,用的是老版本 jsoncpp(0.x 时代,如 jsoncpp-src-0.5.0),那个时代它的构建工具是 scons(一个 Python 写的构建工具),命令是 scons platform=linux-gcc。产物很有意思:编译好的链接库存放在源目录下的 libs/ 里,名字形如 libjson_linux-gcc-4.8.5_libmt.so——其中 linux-gcc-4.8.5 是"平台 + 编译器 + 编译器版本",mt 表示多线程(multi-threaded)。头文件则放在 include/ 目录下。这套命名是 scons 构建时代留下的痕迹,看到老博客这么写不要慌:

# 素材笔记:老版本 jsoncpp(0.x)源码编译全过程
# 1. 先装构建工具 scons;yum 找不到就先更新 yum 源再装
yum install scons -y

# 2. 进入 jsoncpp 源目录,执行素材里的真实命令
cd jsoncpp的源目录
scons platform=linux-gcc

# 3. 产物:头文件在 include/,链接库在 libs/(名字带编译器版本号)
ls include/json/json.h
ls libs/libjson_linux-gcc-4.8.5_libmt.so

不过要提醒:新版本 jsoncpp(1.x)早就改用 CMake 构建了,上面那套 scons 命令只适用于 0.x 老版本(1.x 要求 C++11,0.x 为老编译器保留)。自己从源码编新版的话,流程和绝大多数 CMake 项目一样:clone 仓库、建 build 目录、cmake 配置、make。另外 jsoncpp 还提供一个"单文件版"(amalgamated)选项:跑一个 python3 amalgamate.py 脚本,就能把整个库合并成 jsoncpp.cpp + json/json.h 两个文件,直接扔进你的工程一起编译,连 CMake 都省了:

# 新版本(1.x)源码编译:走标准 CMake 流程
git clone https://github.com/open-source-parsers/jsoncpp.git
cd jsoncpp
mkdir -p build && cd build
cmake -DCMAKE_BUILD_TYPE=release ..
make -j4
# 产物:build/lib/ 下的 libjsoncpp.a(静态)或 libjsoncpp.so(动态),头文件在 include/

# 想要单文件版:在 jsoncpp 仓库根目录执行
python3 amalgamate.py
# 生成 dist/ 目录:jsoncpp.cpp + json/json.h,拷贝进工程即可

注意:网上很多老教程还写着 scons 编译 jsoncpp,那是 0.x 时代的命令。你现在从官网 clone 下来的都是 1.x,照抄 scons 会报"找不到 SConstruct"之类的错。判断标准很简单:看到 SConstruct 文件用 scons,看到 CMakeLists.txt 用 CMake——以仓库里实际存在的构建文件为准。

6.4 核心 API:Json::Value——万能节点类型

整个 jsoncpp 的 API 有一大半都集中在 Json::Value 这一个类上。Json::Value 是一个"万能节点"类型——说人话:它一个类能装下 JSON 的全部八种节点形态(null、布尔、整数、无符号整数、浮点、字符串、数组、对象),运行时自己记住"我当前是什么类型"。往里面塞字符串它就变成字符串节点,往里塞数组它就变成数组节点,不需要你预先声明。最常用的操作符是 operator[]:用键名访问对象成员、用下标访问数组元素,读和写都用它。给对象赋值用 v["键"] = 值,往数组里加元素用 append(),问元素个数用 size()。下面这段代码把"构建一个嵌套对象"完整演示了一遍:

// 构建:对象套数组、对象套对象,全用 operator[] 和 append
#include <json/json.h>

Json::Value person;                            // 空节点,默认是 null
person["name"] = "柯南";                  // 赋字符串,节点自动变字符串
person["age"] = 17;                        // 赋整数,节点自动变整数
person["score"] = 95.5;                    // 浮点数
person["isDetective"] = true;               // 布尔
person["skills"].append("推理");            // 第一次 append 时自动变成数组
person["skills"].append("变声");

Json::Value address;                           // 对象套对象
address["city"] = "东京";
address["zip"] = "100-0001";
person["address"] = address;

读数据时,最忌讳"闭着眼睛取"。jsoncpp 提供三组配套方法:存在性——isMember("键名") 判断成员是否存在;类型判断——isString()isInt()isArray()isObject()isNull() 等;取值——asString()asInt()asBool()asDouble() 等。标准姿势是"三连问":先问在不在(isMember),再问是什么类型(isXxx),最后才取值(asXxx)。因为 asInt() 遇到类型不匹配的值(比如字符串、对象)会抛 std::runtime_error 异常,不提前检查程序就直接崩了。数组遍历也很简单,size() 拿到长度,下标从 0 开始:

// 读取:存在性 + 类型 + 取值,三连问之后再动手
if (person.isMember("name") && person["name"].isString()) {
    std::cout << person["name"].asString() << std::endl;
}
if (person["age"].isInt()) {
    std::cout << person["age"].asInt() << std::endl;
}

// 数组遍历:size() + 下标
Json::Value skills = person["skills"];
for (unsigned int i = 0; i < skills.size(); i++) {
    std::cout << skills[i].asString() << std::endl;   // 推理 / 变声
}
柯南提示

真相只有一个:先 isMember,再 isXxx,最后 asXxx。这三连问写起来啰嗦,但它是 JSON 数据安全读取的护身符——接口返回的数据你永远不知道是什么类型,而 asInt() 在类型不匹配时是直接抛异常的。

6.5 解析:从字符串和文件到 Value(Reader / CharReaderBuilder)

数据流的第一站:把 JSON 文本变成内存里的树,这一步叫解析(parse)。jsoncpp 提供两代解析 API。老一代是 Json::Reader,用法最简洁:构造一个 Reader,调用 reader.parse(文本, 树),返回 true 表示成功。它从 0.x 时代就在,网上大量教程用的都是它,新版本里被标记为 deprecated(不推荐使用)——还能用,但新代码不推荐再写:

// 老 API:Json::Reader(简洁,但已标记 deprecated)
#include <json/json.h>
#include <iostream>
#include <string>

int main() {
    std::string text = R"({"name":"柯南","age":17})";

    Json::Reader reader;                       // 一个解析器
    Json::Value root;                          // 结果树
    if (reader.parse(text, root)) {           // 解析成功返回 true
        std::cout << root["name"].asString() << std::endl;
    } else {
        std::cerr << "解析失败" << std::endl;
    }
    return 0;
}

新一代是 Json::CharReaderBuilder + Json::CharReader,也就是 6.2 里第一次出场的那套。它走的是"工厂模式":Builder 负责配置(比如要不要校验、怎么收集错误),newCharReader() 造出一个解析器,解析器的 parse(起始指针, 结束指针, &树, &错误信息) 负责干活。相比老 Reader,它的优势是可配置、错误信息更详细(出错时 errs 里会写清楚位置和原因)。注意 parse 接收的是指针范围(首地址 + 末地址),而不是整个字符串。除了从字符串解析,jsoncpp 还提供了 Json::parseFromStream(),可以直接从文件流解析,省去"先读文件再拼字符串"的中间步骤——数据流的源头直接从磁盘开始:

// 新 API:CharReaderBuilder —— 从字符串解析
std::string text = R"({"host":"127.0.0.1","port":8080})";
Json::CharReaderBuilder builder;
Json::Value root;
std::string errs;
std::unique_ptr<Json::CharReader> reader(builder.newCharReader());
bool ok = reader->parse(text.c_str(), text.c_str() + text.size(), &root, &errs);
if (!ok) {
    std::cerr << "解析失败:" << errs << std::endl;
    return 1;
}
std::cout << root["host"].asString() << std::endl;   // 127.0.0.1
// 新 API:parseFromStream —— 直接从文件流解析(6.1 的 config.json)
#include <json/json.h>
#include <fstream>
#include <iostream>

int main() {
    std::ifstream fin("config.json");
    Json::CharReaderBuilder builder;
    Json::Value conf;
    std::string errs;
    if (!Json::parseFromStream(builder, fin, &conf, &errs)) {
        std::cerr << "解析 config.json 失败:" << errs << std::endl;
        return 1;
    }
    // 链式取嵌套字段:先取 "server" 节点,再取它下面的 "host"
    std::cout << conf["server"]["host"].asString() << std::endl;  // 127.0.0.1
    std::cout << conf["server"]["port"].asInt() << std::endl;       // 8080
    return 0;
}

两个例子都强调同一件事:解析的返回值必须检查。JSON 文本来自外部(配置文件、网络接口),你永远不知道它会不会是坏的:少个逗号、多个引号、类型写错……parse 返回 false 时,root 只会被填成半截甚至空树,不检查就直接用,后面取出来的全是默认值,Bug 藏在最深处。柯南的忠告:解析失败是数据流上的第一道闸门,闸门一开,后面全是脏数据

6.6 序列化:从 Value 回到字符串和文件(FastWriter / StreamWriterBuilder)

数据流的最后一站:把内存里的树变回 JSON 文本,这一步叫序列化(serialize / write)。和解析一样,序列化也有两代 API。老一代是 Json::FastWriterwriter.write(树) 直接返回字符串,输出紧凑、无缩进、无多余空格(键和值之间没有空格,形如 {"age":17,"name":"柯南"}),适合省流量的网络传输场景。它同样被标记为 deprecated,新代码推荐用 Json::StreamWriterBuilder

Json::StreamWriterBuilder 走的是和 CharReaderBuilder 一样的工厂模式,最常用的是它的设置项wbuilder["indentation"] 控制缩进(默认是制表符 \t,设为 " " 就是两级空格,设为 "" 就是紧凑输出)。配合便捷函数 Json::writeString(工厂, 树) 一行出字符串。注意一个细节:jsoncpp 序列化时,对象成员之间用的是 "键" : 值——冒号前有一个空格,而 FastWriter 没有;两边输出风格不一样,别当成 Bug:

// 新 API:StreamWriterBuilder —— 可配置的序列化
Json::Value v;
v["name"] = "柯南";
v["age"] = 17;

Json::StreamWriterBuilder wbuilder;
wbuilder["indentation"] = "  ";                  // 两级空格缩进(默认是 \t)
std::string pretty = Json::writeString(wbuilder, v);
std::cout << pretty << std::endl;
// 输出:
// {
//   "age" : 17,
//   "name" : "柯南"
// }

wbuilder["indentation"] = "";                    // 改成紧凑输出
std::cout << Json::writeString(wbuilder, v) << std::endl;
// 输出:{"age":17,"name":"柯南"}

要把树写回文件,两种姿势:要么 writeString 拿到字符串后自己写文件,要么直接用 StreamWriter 把树直接写进文件流——后者更省事,newStreamWriter() 造出 writer,writer->write(树, &输出流) 一步到位。老 API FastWriter 的用法也一并给出,方便你读懂网上老代码:

// 写回文件:StreamWriter 直接写流(沿用上面的 v)
#include <fstream>

std::ofstream fout("out.json");
Json::StreamWriterBuilder wb;
wb["indentation"] = "  ";
std::unique_ptr<Json::StreamWriter> sw(wb.newStreamWriter());
sw->write(v, &fout);
fout.close();
// 老 API:FastWriter —— 紧凑输出,已标记 deprecated,但老代码很常见
Json::FastWriter writer;
std::string out = writer.write(v);
std::cout << out;                // {"age":17,"name":"柯南"},无缩进无空格
柯南提示

什么时候用哪种输出?给人看的配置文件用缩进版(方便人眼排查),给程序传的数据用紧凑版(省带宽、省存储)。纠结的话记住一句话:配置走缩进,传输走紧凑。

6.7 常见坑与实战:数据流上的三处暗礁

先揭晓 6.2 埋的悬念。坑一:序列化时键名按字母顺序排列。jsoncpp 内部用 std::map 存储对象的成员,而 std::map 是有序容器(默认按键的字母序),所以无论你插入的顺序如何,序列化输出时键永远按字母序排。这不是 Bug,是设计如此——素材笔记里记录了:jsoncpp 和 POCO 库的 JSON 模块都有这个行为(转 JSON 字符串时键名按字母顺序排列),而 protobuf 转 JSON 时则按 message 里字段声明的顺序输出。所以:别指望 JSON 对象的键序,读配置时也永远不要依赖键序;但数组元素顺序是保证的(数组就是顺序表),append 进去什么顺序,出来就什么顺序:

// 键序演示:插入顺序与输出顺序无关
Json::Value v;
v["zeta"] = 1;
v["alpha"] = 2;
v["mike"] = 3;

Json::StreamWriterBuilder wb;
wb["indentation"] = "";
std::cout << Json::writeString(wb, v) << std::endl;
// 输出:{"alpha":2,"mike":3,"zeta":1} —— 按字母序,不是插入序!

坑二:operator[] 找不到键时会"顺手"创建空成员。非 const 的 Value 上执行 root["不存在的键"],jsoncpp 不会报错,而是默默插入一个值为 null 的成员——这原本是为了方便写入(v["name"] = "柯南" 直接就能建键),但读数据时它就是陷阱:你以为在"查",其实在"写",树被悄悄污染。加上 asInt() 对类型不匹配的值(字符串、对象、null)默认会抛 std::runtime_error,两个坑叠在一起,就是"程序莫名崩溃 + 输出多了一堆 null 键"。正确姿势永远只有一种:先 isMember 确认存在,再 isXxx 确认类型,最后 asXxx 取值:

// 坑二演示:读缺失键会"写入"空成员,序列化时现出原形
Json::Value root;
root["who"];                                // 只是"读"一下,成员却被创建了!
Json::StreamWriterBuilder wb2;
wb2["indentation"] = "";
std::cout << Json::writeString(wb2, root) << std::endl;   // {"who":null}

// 坑三:类型不匹配直接抛异常
Json::Value conf;
conf["port"] = "8080";                   // 注意:字符串,不是数字
// int p = conf["port"].asInt();          // 抛出 std::runtime_error!
if (conf["port"].isString()) {              // 先问类型
    int p = std::stoi(conf["port"].asString());   // 再决定怎么转
}

坑四:解析失败不检查,静默使用空树。这个 6.5 强调过,实战里再补一刀:JSON 解析失败时 parse 返回 false,errs 里是错误详情,但 root 并不会被清空——它可能是半截数据。如果你不看返回值,程序会"正常"跑下去,只是所有字段都是默认值(字符串空、数字 0、布尔 false),Bug 极难定位。养成习惯:parse 的返回值必须 if 检查,失败就打印 errs 并终止

最后串一个完整实战:读配置 → 改配置 → 写回,把数据流从头走到尾。注意输出的键序会跟源文件不一样(坑一),这是正常现象,不是写坏了:

// config_tool.cpp:读配置、改配置、写回
#include <json/json.h>
#include <fstream>
#include <iostream>

int main() {
    // 1. 读:文件 → 树
    std::ifstream fin("config.json");
    Json::CharReaderBuilder builder;
    Json::Value conf;
    std::string errs;
    if (!Json::parseFromStream(builder, fin, &conf, &errs)) {
        std::cerr << "解析失败:" << errs << std::endl;
        return 1;
    }
    fin.close();

    // 2. 改:树上的增删改
    conf["server"]["port"] = 9090;                  // 改端口
    conf["users"].append("carol");                    // 数组追加
    conf["version"] = "2.0";                          // 新增字段

    // 3. 写:树 → 文件
    std::ofstream fout("config.json");
    Json::StreamWriterBuilder wb;
    wb["indentation"] = "  ";
    std::unique_ptr<Json::StreamWriter> sw(wb.newStreamWriter());
    sw->write(conf, &fout);
    fout.close();

    std::cout << "配置已更新(键序按字母排列,属正常现象)" << std::endl;
    return 0;
}

总结:JSON 数据流上的四块暗礁——① 键名按字母序输出,别依赖键序;② 读缺失键会污染树,读之前先 isMember;③ asInt 类型不匹配抛异常,先 isXxx 再 asXxx;④ 解析失败必须检查返回值。把这条清单贴在工位上,比记住任何 API 都管用。

章末练习

练习 1:概念三连 入门

JSON 的值有哪几种类型?它们分别对应 jsoncpp 的哪些节点类型?判断下面哪个是合法的 JSON,并说明理由:{"a":1}{a:1}{'a':1}

提示

回想 6.1 的类型表和 6.2 的 Json::Value 类型表;重点检查键的引号规范。

参考答案

JSON 六种值类型:null、布尔、数字、字符串、数组、对象;jsoncpp 的 Json::Value 细分后对应 nullValue / booleanValue / intValue / uintValue / realValue / stringValue / arrayValue / objectValue。合法的只有 {"a":1}——JSON 规定键必须用双引号,{a:1} 键没加引号,{'a':1} 用了单引号,都不合法。

练习 2:抓 Bug 进阶

下面这段代码,假设 root 已经解析成功且内容是 {"name":"柯南","age":17},请指出第 3 行会发生什么,为什么,怎么改?

std::cout << root["age"].asInt() << std::endl;    // ① 正常
std::cout << root["name"].asString() << std::endl; // ② 正常
std::cout << root["name"].asInt() << std::endl;    // ③ ?
std::cout << root["height"].asInt() << std::endl;  // ④ ?
提示

想想 asInt 遇到字符串和 null 时默认的行为;再想想 operator[] 读缺失键的副作用。

参考答案

③ 抛 std::runtime_error——"name" 是字符串,asInt() 类型不匹配默认抛异常;④ 同样抛异常,而且 root["height"] 会先把 height 成员创建为 null(树被污染),再在 null 上 asInt() 抛异常。正确写法:if (root.isMember("name") && root["name"].isString()) std::cout << root["name"].asString();,取数字同理先 isInt()。

练习 3:完整读写实战 挑战

写一个程序:读入 6.1 的 config.json(含 server.host / server.portusers 数组),把 port 改成 9090、向 users 追加一个名字、新增一个字段 version: "2.0",然后写回文件。运行后打开文件,回答:为什么输出文件的键序和源文件不一样?数组顺序变了吗?

提示

三段式:parseFromStream 读 → operator[] 改 → StreamWriterBuilder 写。键序问题回到 6.7 坑一找答案。

参考答案

核心代码见 6.7 的 config_tool.cpp,结构完全一样。原因:jsoncpp 用 std::map 存对象成员,序列化按字母序输出,所以 server 内部的 host/port 以及顶层键都会按字母重排(如 debug 排到 server 前、users 前);而数组底层是顺序容器,append 的顺序就是输出顺序,users 数组内顺序保持不变。结论:对象键序不可依赖,数组顺序可靠。

练习 4:新旧 API 对比 进阶

解析侧 Reader → CharReaderBuilder、序列化侧 FastWriter → StreamWriterBuilder,两对演进各解决了什么问题?为什么新代码推荐用后者?

提示

从"能不能配置、错误信息细不细、输出格式可不可控"三个角度对比。

参考答案

老 API(Reader / FastWriter)用起来最省事,但不可配置:Reader 的错误信息有限,FastWriter 输出格式写死(紧凑、无缩进)。新 API 采用工厂模式,Builder 负责统一配置:CharReaderBuilder 能控制解析行为并通过 errs 收集更详细的错误位置;StreamWriterBuilder 能通过 wbuilder["indentation"] 等设置项控制输出格式(缩进 / 紧凑),还支持直接写流(newStreamWriter() + write(树, &流))。所以老 API 被标记 deprecated,能读懂即可,新代码统一用 Builder 系。