前言
该库的地址:github.com/nlohmann/js...,到目前已经拥有38.5K的star,可以说是被大家广泛赞同,经过简单探究,确实发现非常不错,可以在项目中使用。
正文
该库的集成非常容易,只有1个hpp
文件,拿来即用,完全不需要任何复杂的编译,尤其是刚入门的C++开发来说,编译一些开源库是永远的噩梦。
同时该库使用C++11代码编译,使用了不少特性,也是值得学习其原理,学习C++的不错选择。
下面安装其readme
,简单介绍,以及自己的使用体验。
1. JSON as first-class data type
中文翻译为json作为一等数据类型,即我们可以将json数据类型作为一等公民(first-class citizen
),并且支持相应的操作和语法。
关于某种语言是否把某个特性或者功能作为一等公民看待,我个人有2点感受比较深刻。第一个是Java到Kotlin的编程语言变化,同为JVM语言,Kotlin把函数和lambda表达式看成一等公民,在Kotlin中可以将函数作为参数、作为返回值等,在Kotlin官方实例和源码中大量使用,极大地提高代码简洁性。第二个是学习C++时,C++没有把原生数组类型看成一等公民,比如数组不支持直接拷贝赋值,原因很多,比如必须固定长度,缺乏高级操作以及语法糖支持,指针语义容易混淆(C++中,数组名实际上是一个指向数组首元素的指针)等,所以推荐使用std::vector
来替代原生数组。
在C++中并没有直接将json看成一等公民,与此对应的Python就有非常好的支持,而该库想实现这一目的,就必须得提供足够简洁的使用方式,以及足够丰富的操作。比如我想创建一个json对象,如下:
c
//想创建的json对象,标准json格式
{
"pi": 3.141,
"happy": true,
"name": "Niels",
"nothing": null,
"answer": {
"everything": 42
},
"list": [1, 0, 2],
"object": {
"currency": "USD",
"value": 42.99
}
}
//代码如下
//创建一个空对象
json j;
//增加一个数字,使用double类型存储,
j["pi"] = 3.141;
//增加一个布尔值,使用bool类型存储
j["happy"] = true;
//增加一个字符串,使用std::string类型存储
j["name"] = "Niels";
//通过传递一个nullptr增加一个空对象
j["nothing"] = nullptr;
//在对象内部增加一个对象
j["answer"]["everything"] = 42;
//增加一个数组,使用std::vector
j["list"] = {1, 0, 2};
//增加另一个对象
j["object"] = { {"currency", "USD"}, {"value", 42.99}};
//或者使用更方便的方式
json j2 = {
{"pi", 3.141},
{"happy", true},
{"name", "Niels"},
{"nothing", nullptr},
{"answer", {
{"everything", 42}
}},
{"list", {1, 0, 2}},
{"object", {
{"currency", "USD"},
{"value", 42.99}
}}
};
std::cout << j2.dump(4) << std::endl;
//运行结果
{
"answer": {
"everything": 42
},
"happy": true,
"list": [
1,
0,
2
],
"name": "Niels",
"nothing": null,
"object": {
"currency": "USD",
"value": 42.99
},
"pi": 3.141
}
这里看似非常简单,但是有一些原理我们是需要知道的。我们对比一下给定的json
中list
的结构,它是一个数组,使用[1, 0, 2]
,我们在j
和j2
的构造中,都是使用的{}
来进行赋值的,这一点就和把json看成一等公民的Python语言有较大区别,下面是Python代码:
python
import sys
import os
import json
# 定义一个 Python 对象
person = {
"name": "Alice",
"age": 25,
"hobby": ["reading", "music"]
}
# 将 Python 对象转换为 json 字符串
json_str = json.dumps(person)
# 输出转换后的 JSON 字符串
print(json_str)
//输出结果
{"name": "Alice", "age": 25, "hobby": ["reading", "music"]}
可以发现在Python构建对象时,对于数组结构,可以直接使用[]
,更符合常识,C++为什么不可以,因为j["list"] = {1, 0, 2};
是通过重载[]
运算符对j
进行赋值,默认的数据成员对象类型是std::vector
,对std::vector
的初始化使用列表初始化时,不能使用[]
,即如下:
c
//C++11可以使用列表初始化
std::vector list = {0, 1, 2};
//错误
std::vector list1 = [0, 1, 2];
因为该库只能利用重载运算符等方式来让json操作看起来像是操作json,而非C++语言支持这种操作。
搞清楚这个之后,我们再来看看object
,在原始的json
中,我们可以清晰地知道它对应的类型是一个类类型:
css
"object": {
"currency": "USD",
"value": 42.99
}
但是在使用对j
的object
的赋值如下:
arduino
j["object"] = { {"currency", "USD"}, {"value", 42.99}};
这不是和前面所说的数组赋值冲突了吗,依据列表初始化规则,这个完全可以解析为数组类型,即[["currency", "USD"],["value", 42.99]]
,也就是二维数组,这时我们又可以对比一下Python可以怎么写:
python
import sys
import os
import json
# 定义一个 Python 对象
person = {
"name": "Alice",
"age": 25,
"object": {
"currency":"USD",
"value":"42.99"
}
}
# 将 Python 对象转换为 JSON 字符串
json_str = json.dumps(person)
# 输出转换后的 JSON 字符串
print(json_str)
//输出结果
{"name": "Alice", "age": 25, "object": {"currency": "USD", "value": "42.99"}}
可以发现在Python中可以使用:
,这样对应的键和值非常容易识别,那C++为什么不可以呢?还是一样的问题,在代码j["object"] = { {"currency", "USD"}, {"value", 42.99}};
中,是使用std::map
数据结构来作为其默认的成员数据类型,而C++11中可以使用列表初始化来初始化std::map
,同时没有:
的语法:
c
//C++11可以使用列表初始化
std::map map = { {"currency", "USD"}, {"value", 42.99} };
//错误
std::map map1 = { {"currency":"USD"}, {"value":42.99} };
搞明白为什么之后,我们就要回答前面的问题,为什么object
没有被解析为std::vector
类型呢?原因是默认规则就是这样的,即一层和2层{}
的默认处理逻辑是不一样的。
假如有一些极限情况,我就是想用数组形式来保存对象格式,这时可以显示地声明json值的类型,使用json::array()
和json::object()
函数:
c
//显示声明是一个数组,而非对象
json empty_array_explicit = json::array();
//对于没有构造函数或者这种,默认隐式类型是对象
json empty_object_implicit = json({});
json empty_object_explicit = json::object();
//是一个数组,而非对象
json array_not_object = json::array({ {"currency", "USD"}, {"value", 42.99} });
std::cout << empty_array_explicit.dump(4) << std::endl;
std::cout << empty_object_implicit.dump(4) << std::endl;
std::cout << empty_object_explicit.dump(4) << std::endl;
std::cout << array_not_object.dump(4) << std::endl;
//运行结果
[]
{}
{}
[
[
"currency",
"USD"
],
[
"value",
42.99
]
]
搞清楚默认类型,以及如何显示声明非常重要。
2. 序列化/反序列化
既然想把json打造为一等公民,序列化和反序列化是必须要具备 ,而且要可以从不同的源来进行序列化/反序列化,比如从文件、字符串等。
2.1 与字符串
我们可以从字符串字面量来创建一个json
对象,注意和上面使用=
进行列表初始化的方式不同,这里的参数是字符串字面量:
c
json j = "{"happy":true,"pi":3.141}"_json;
std::cout << j.dump(4) << std::endl;
auto j2 = R"({
"happy": true,
"pi": 3.141
})"_json;
std::cout << j2.dump(4) << std::endl;
json j1 = "{"happy":true,"pi":3.141}";
std::cout << j1.dump(4) << std::endl;
//运行结果
{
"happy": true,
"pi": 3.141
}
{
"happy": true,
"pi": 3.141
}
"{"happy":true,"pi":3.141}"
上面代码都是通过字符串初始化一个json
对象,但是需要注意的点很多。
2.1.1 原始字符串字面量(Raw String Literal
)
首先就是j2
的初始化,它使用了R"()"
这种语法,这种表示字符串的方式叫做原始字符串字面量。在C++中,可以使用R"()"
来表示原始字符串字面量,使用原始字符串字面量可以方便地包含特殊字符(比如反斜杠、引号等)或者多行文本的字符串,而无需对这些特殊字符进行转移。
比如如下代码:
python
{
qDebug() << "Hello\tWorld!";
qDebug() << R"(Hello\tWorld
NEW LINE)";
}
运行结果是:
可以发现普通字符串中\t
被解释为制表符,而在原始字符串字面量中,\t
不会被解析,并且换行也被保留了下来。其实在其他语言中,使用这种方式更为简单,比如Kotlin使用"""
进行包裹字符串,或者Python使用```进行包裹字符串。
通过这个小知识点的学习,我们明显发现j2
就比j
的初始化方式更人性化,至少不用去在意字符串中的转移字符。
2.1.2 自定义字面量操作符
然后我们关注点来到j
和j1
的区别,可以发现j
多了一个_json
的后缀,然后在输出打印中就可以正常解析,而j1
却不可以。这里的核心点就是字符串字面量后面的_json
,我们看一下它的源码:
arduino
JSON_HEDLEY_NON_NULL(1)
inline nlohmann::json operator "" _json(const char* s, std::size_t n)
{
return nlohmann::json::parse(s, s + n);
}
看到operator
就应该想到自定义操作符,没错,operator ""
是C++11引入的自定义字面量操作符,通过重载operator ""
可以为特定的后缀自定义语义,这种机制可以使得我们可以像使用内置的字面量(如整数、浮点数、字符串等)一样自然地使用自定义的字面量,从而提高代码的可读性和表达力。
举个简单的例子:
arduino
//为long long类型定义一个_km后缀
constexpr long long operator "" _km(unsigned long long x) {
return x * 1000;
}
long long distance = 20_km;
std::cout << "distance:" << distance << "meters\n";
//输出结果
distance:20000meters
从这个例子我们来看上面_json
的含义,它就是给const char*
类型即字符串类型添加一个_json
后缀,作用就是调用nlohmann::json::parse(s, s + n)
返回一个json
对象,理解了这个之后,我们也就可以理解为什么j1
不能被解析,因为没有_json
后缀,根本不会调用parse
函数进行解析成json
对象。
因为_json
这种用法就是语法糖,所以其实想解析一个字符串成json
,就可以直接调用json::parse()
函数:
python
json j3 = json::parse(R"({"happy": true,"pi": 3.141})");
从前面的错误用法打印来看,一般情况下我们认为json
对象要不表示一个对象,要不表示一个数组,但是从打印j1
来看:
c
json j1 = "{"happy":true,"pi":3.141}";
std::cout << j1.dump(4) << std::endl;
//运行结果
"{"happy":true,"pi":3.141}"
这里居然返回一个字符串。所以这里一定要区分序列化和赋值的区别,对于序列化,在前面我们说了2种方式,一种是使用列表初始化,一种是使用字符串字面量,而直接赋值的话,json
内部会直接保存一个字符串。
2.2 与file
从文件中反序列化一个json
对象可以说是在配置文件时非常常用了,这里使用也非常简单,代码如下:
c
std::string dirPath = QApplication::applicationDirPath().toStdString();
std::string file = dirPath + "/file.json";
//使用ifstream读取file
std::ifstream i(file);
json j_file;
//把输入流中的信息放入json中
i >> j_file;
std::cout << j_file.dump(4) << std::endl;
json j_file_bak = {
{"pi", 3.141},
{"happy", true},
{"name", "Niels"},
{"nothing", nullptr},
{
"answer", {
{"everything", 42}
}
},
{"list", {1, 0, 2}},
{
"object", {
{"currency", "USD"},
{"value", 42.99}
}
}
};
std::string file_bak = dirPath + "/file_bak.json";
std::ofstream o(file_bak);
o << std::setw(4) << j_file_bak << std::endl;
可以发现只要获取到标准的输入输出流之后,我们就可以使用>>
和<<
符号来进行文件序列化和反序列化了,这里的原理非常简单,也是操作符重载,在源码中重载了>>
和<<
:
arduino
friend std::istream& operator>>(std::istream& i, basic_json& j)
{
parser(detail::input_adapter(i)).parse(false, j);
return i;
}
friend std::ostream& operator<<(std::ostream& o, const basic_json& j)
{
// read width member and use it as indentation parameter if nonzero
const bool pretty_print = o.width() > 0;
const auto indentation = pretty_print ? o.width() : 0;
// reset width to 0 for subsequent calls to this stream
o.width(0);
// do the actual serialization
serializer s(detail::output_adapter<char>(o), o.fill());
s.dump(j, pretty_print, false, static_cast<unsigned int>(indentation));
return o;
}
对于C++开发来说,左移和右移符号已经非常熟悉了。
3. STL-like access
STL-like access
是STL风格访问的意思,在C++中可以为一些类定义STL风格访问的API,可以提高类型的灵活性和易用性,这样我们就可以像STL容器一样使用迭代器、算法等功能,从而可以简化很多操作,提高了代码的可读性和可维护性。
3.1 STL风格API
由于json
本身就是由标准库中的类型进行解析的,所以为其设计一套STL风格访问的API也就很有必要,使用如下:
c
//创建一个数组, 使用push_back
json j;
j.push_back("foo");
j.push_back(1);
j.push_back(true);
//可以使用emplace_back
j.emplace_back(1.78);
//使用迭代器遍历数组
for (json::iterator it = j.begin(); it != j.end(); it++) {
std::cout << *it << std::endl;
}
//快速for循环
for (auto& element : j) {
std::cout << element << std::endl;
}
//getter/setter
const auto tmp = j[0].template get<std::string>();
j[1] = 42;
bool foo = j.at(2);
//比较运算符
j == R"(["foo", 1, true, 1.78])"_json;
j.size();
j.empty();
j.type();
j.clear();
//快捷类型判断
j.is_null();
j.is_boolean();
j.is_number();
j.is_object();
j.is_array();
j.is_string();
//创建一个对象
json o;
o["foo"] = 23;
o["bar"] = false;
o["baz"] = 3.14;
//特殊的成员迭代器函数
for (json::iterator it = o.begin(); it != o.end(); it++) {
std::cout << it.key() << " : " << it.value() << std::endl;
}
//快速for循环
for (auto& el : o.items()) {
std::cout << el.key() << " : " << el.value() << std::endl;
}
//c++17特性,结构化绑定
for (auto& [key, value] : o.items()) {
std::cout << key << " : " << value << std::endl;
}
//判断有没有某个键值对
if (o.contains("foo")) {
std::cout << "contain foo" << std::endl;
}
这种使用方式可以让我们操作json像操作标准容器一样,非常方便。
3.2 从STL容器构造
同理,我们可以从STL容器构造出json对象,任何序列容器,比如std::array
、std::vector
、std::deque
、std::forward_list
和std::list
,其中保存值可以构造json的值,比如int
,float
、boolean
、字符串类型等,这些容器均可以用来构造json数组。对于类似的关联容器(std::set
、std::multiset
、std::unordered_set
、std::unordered_multiset
)也是一样,但是在这种情况下,数组元素的顺序取决于元素在数组中的排序方式。
使用代码如下:
c
std::vector<int> c_vector {1, 2, 3, 4};
json j_vec(c_vector);
std::deque<double> c_deque {1,2, 2.3, 3.4, 5.6};
json j_deque(c_deque);
std::list<bool> c_list {true, true, false, true};
json j_list(c_list);
std::forward_list<int64_t> c_flist {12345678909876, 23456789098765, 34567890987654, 45678909876543};
json j_flist(c_flist);
std::array<unsigned long, 4> c_array{{1, 2, 3, 4}};
json j_array(c_array);
std::set<std::string> c_set {"one", "two", "three", "four", "one"};
json j_set(c_set);
std::unordered_set<std::string> c_uset {"one", "two", "three", "four", "one"};
json j_uset(c_uset);
std::multiset<std::string> c_mset {"one", "two", "one", "four"};
json j_mset(c_mset);
std::unordered_multiset<std::string> c_umset {"one", "two", "one", "four"};
json j_umset(c_umset);
关于这几种STL容器,可以做个简单的概述:
容器类型 | 底层实现 | 特点 | 适用场景 |
---|---|---|---|
std::vector | 动态数组 | 可变大小的连续存储空间,支持随机访问,尾部插入和删除效率高。 | 需要在末尾进行频繁插入和删除操作,以及随机访问元素的情况。 |
std::array | 静态数组 | 固定大小的连续的静态数组,在编译期就确定了大小,并且不会改变。 | 替代原生数组,有更多的成员函数与操作API。 |
std::list | 双向链表 | 支持双向迭代器,插入和删除元素效率高,不支持随机访问。 | 需要频繁在中间位置进行插入和删除操作,不需要随机访问元素。 |
std::deque | 双端队列 | 支持随机访问,支持在两端插入和删除元素,动态地分配存储空间。 | 需要在两端插入和删除元素,并且需要随机访问元素的情况。 |
std::set | 红黑树 | 自动排序元素,不允许重复元素,插入和删除的时间复杂度为O(logN)。 | 需要自动排序且不允许重复元素的情况。 |
std::multiset | 红黑树 | 自动排序元素,允许重复元素,插入和删除的时间复杂度为O(1)。 | 需要自动排序且允许重复元素的情况。 |
std::unordered_set | 哈希表 | 无序存储元素,不允许重复元素,插入和查找的时间复杂度为O(1)。 | 不需要排序,并且不允许重复元素的情况。 |
std::unordered_multiset | 哈希表 | 无序存储元素,允许重复元素,插入和查找的时间复杂度为O(1)。 | 不需要排序,并且允许重复元素的情况。 |
类似的,STL的键值对容器,只要键可以构造std::string
对象,值可以构造json对象的,也可以用来构造json对象类型,测试代码如下:
c
std::map<std::string, int> c_map { {"one", 1}, {"two", 2}, {"three", 3} };
json j_map(c_map);
std::unordered_map<const char*, double> c_umap { {"one", 1.2}, {"two", 2.3}, {"three", 3.4} };
json j_umap(c_umap);
std::multimap<std::string, bool> c_mmap { {"one", true}, {"two", false}, {"three", false}, {"three", true} };
json j_mmap(c_mmap);
std::unordered_multimap<std::string, bool> c_ummap { {"one", true}, {"two", true}, {"three", false}, {"three", true} };
json j_ummap(c_ummap);
这几种容器也做个概述,和std::set
类似,也是从是否自动排序和重复(一键多值)这两个维度来扩展,和Java还是有一点区别的,在Java中使用最多的是HashMap
,类似C++
的std::unordered_set
:
容器类型 | 底层 | 特点 |
---|---|---|
std::map | 红黑树 | 根据key进行自动排序;每个key只能出现一次;由于红黑树的平衡性,查找、插入和删除的时间复杂度均为O(logn);不支持高效的随机访问。 |
std::unordered_map | 哈希表 | 不会自动排序;每个key只能出现一次;查找、插入和删除的时间复杂度为O(1),支持高效的随机访问。 |
std::multimap | 红黑树 | 根据key自动排序;每个key支持对应多个value,即可以插入多个key一样的键值对;查找、插入和删除的时间复杂度为O(logn)。 |
std::unorder_multimap | 哈希表 | 不会自动排序;支持每个key对应对个value;操作的时间复杂度为O(1)。 |
4. JSON指针和补丁
该库还支持JSON指针,是一种作为寻址结构化值的替代方法。并且,JSON补丁(Patch)允许描述2个JSON值之间的差异。这两点我觉得非常不错,有助于在版本迭代时进行合并配置文件,直接看代码:
python
//原始json对象
json j_1 = R"({
"baz": ["one", "two", "three"],
"foo": "bar"
})"_json;
std::cout << "j_1:" << j_1.dump(4) << std::endl;
std::cout << j_1["/baz/1"_json_pointer] << std::endl;
//补丁也是一个json对象
json j_patch = R"([
{ "op": "replace", "path": "/baz", "value": "boo" },
{ "op": "add", "path": "/hello", "value": ["world"] },
{ "op": "remove", "path": "/foo"}
])"_json;
std::cout << "j_patch:" << j_patch.dump(4) << std::endl;
//合并补丁
json j_result = j_1.patch(j_patch);
std::cout << "j_result:" << j_result.dump(4) << std::endl;
//计算出差值补丁,差值是第一个参数如何操作成为第二个参数的差值
json j_diff = json::diff(j_result, j_1);
std::cout << "j_diff:" << j_diff.dump(4) << std::endl;
//使用插值进行合并
json j_result_1 = j_result.patch(j_diff);
std::cout << "j_result_1:" << j_result_1.dump(4) << std::endl;
//输出结果
j_1:{
"baz": [
"one",
"two",
"three"
],
"foo": "bar"
}
"two"
j_patch:[
{
"op": "replace",
"path": "/baz",
"value": "boo"
},
{
"op": "add",
"path": "/hello",
"value": [
"world"
]
},
{
"op": "remove",
"path": "/foo"
}
]
j_result:{
"baz": "boo",
"hello": [
"world"
]
}
j_diff:[
{
"op": "replace",
"path": "/baz",
"value": [
"one",
"two",
"three"
]
},
{
"op": "remove",
"path": "/hello"
},
{
"op": "add",
"path": "/foo",
"value": "bar"
}
]
j_result_1:{
"baz": [
"one",
"two",
"three"
],
"foo": "bar"
}
上面代码很容易理解,首先就是json指针的使用j_1["/baz/1"_json_pointer]
,在这里和前面_json
的自定义操作符一样,_json_pointer
也是对字符串的自定义操作,其中通过/
符号来找到json对象中深层次的内容。
接着就是补丁,补丁自己也是一个json对象,每个操作对应一个对象,分别是op
表示操作,path
是json指针,表示需要操作的地方,value
就是新的值。调用json::patch
方法就可以把补丁合并,生成一个新的json对象,可以看上面例子中的j_result
对象。
最后就是json::diff
方法,它是用来计算2个json对象的差值,生成补丁。传递进该函数的2个json对象,补丁就是第一个json对象到第二个json对象的补丁,所以在上面例子中,我们对j_result
合并j_diff
补丁,又可以回到最开始的json对象。
或许你可能觉得json指针有点太难用了,在前面我们也看见了,其实patch
也是一个json对象,所以该库还支持直接使用json对象来进行合并补丁,这种场景非常适合配置文件的迭代,比如下面代码:
c
//多了一个配置项head,已有的配置项的值为默认值
json j_new_config = {
{"name", "modbus"},
{"config",{
{"type", ""},
{"startIndex", 0}}
},
{"head", 10}
};
std::cout << "j_new_config:" << j_new_config.dump(4) << std::endl;
//旧的配置项,已经有值了
json j_old_config = {
{"name", "modbus"},
{"config", {
{"type", "floatlh"},
{"startIndex", 17}}
}
};
std::cout << "j_old_config:" << j_old_config.dump(4) << std::endl;
j_new_config.merge_patch(j_old_config);
std::cout << "result:" << j_new_config.dump(4) << std::endl;
//输出结果
j_new_config:{
"config": {
"startIndex": 0,
"type": ""
},
"head": 10,
"name": "modbus"
}
j_old_config:{
"config": {
"startIndex": 17,
"type": "floatlh"
},
"name": "modbus"
}
result:{
"config": {
"startIndex": 17,
"type": "floatlh"
},
"head": 10,
"name": "modbus"
}
在上面代码中,假如j_old_config
是已经运行的配置项,而j_new_config
是这次版本升级后的新的配置项,其中多了一个字段head
,且其他配置项都是默认值,经过把旧的配置文件合并到新的配置文件中,我们可以看到最终合并后的配置文件,即含有head
字段,也有旧的配置,这样就完成了配置文件的升级。
5. 任意类型转换
在前面说过,对于支持的类型可以隐式的转换为json中的值,但是当从json值获取值时,不建议使用隐式转换,建议使用显示的方式。比如下面代码:
ini
//推荐写法
std::string s1 = "Hello World";
json js = s1;
auto s2 = js.template get<std::string>();
//不推荐写法
std::string s3 = js;
这里有一个写法是template get<std::string>()
,其实也就是模板成员函数调用的写法。
5.1 直接写法
我们研究json序列化库的最终目的是想把任何类型都可以进行序列化和反序列化,通过前面的学习,我们可以大概知道如何把任意一个类类型转成json对象,以及从json对象转变为类类型对象。直接看代码:
ini
Student s = {"jack", 18};
//类类型对象转换为json对象
json j;
j["name"] = s.name;
j["age"] = s.age;
//json对象转换为类类型对象
Student s1 {j["name"].template get<std::string>(), j["age"].template get<int>()};
这里我们定义一个Student
类型,通过前面所学的json操作,很容易写出这样代码,但是这种代码有点冗余。
5.2 from_json和to_json
其实我们可以把序列化和反序列化的操作写在类中,也就是让该类拥有了该能力,这种写法如下:
c
#ifndef STUDENT_H
#define STUDENT_H
#include <string>
#include "json.hpp"
#include <iostream>
using json = nlohmann::json;
struct Student
{
std::string name;
int age;
};
void to_json(json& j, const Student& s) {
j = json{ {"name", s.name}, {"age", s.age} };
}
void from_json(const json& j, Student& s) {
j.at("name").get_to(s.name);
j.at("age").get_to(s.age);
}
#endif // STUDENT_H
直接在定义类的头文件中多定义2个方法,分别为to_json
和from_json
,然后使用如下:
c
Student s = {"jack", 18};
//类类型对象转换为json对象
json j = s;
std::cout << "j:" << j.dump(4) << std::endl;
//json对象转换为类类型对象
auto s2 = j.template get<Student>();
这里也是非常容易理解,当调用json的构造函数,参数是自定义类型时,就会调用to_json
方法;类似的,当调用template get<Type>()
或者get_to(Type)
时,这个from_json
就会被调用。这里有几点需要注意:
- 这些方法必须是公有的命名空间或者类型的命令空间,否则库无法定位它们。
- 这些方法必须是可访问的,不能是私有的等。
- 函数参数必须要注意,从上面例子可以看出,否则无法自动定位它们。
- 自定义的类型必须有且可以默认构造。
我们仔细思考一下这2个方法,其中to_json
是根据类对象构造json对象,在前面我们说了很多。但是from_json
可能就会有问题,比如json对象中缺少一些key,这时就会报错,因为访问不到,比如下面代码:
csharp
json j = {
{"name", "jack"}
};
//json对象转换为类类型对象
auto s2 = j.template get<Student>();
这个j对象就没有age,然后调用from_json
时就会出错,这里会直接抛出异常,有没有其他办法不抛出异常呢?还是有的,可以通过value
方法进行:
ini
void from_json(const json& j, Student& s) {
s.name = j.value("name", "");
s.age = j.value("age", 0);
}
当json对象中没有某个键时,可以通过该方法设置一个默认值。
5.3 使用宏
上面代码可能还不够简洁,这里可以更加容易,如果想序列化后的字段和原来类类型字段一样,可以使用宏来默认实现,这里有2个宏,一个是NLOHMANN_DEFINE_TYPE_NON_INTRUSIVE
用于class或者struct对象其成员都是public的情况,还可以使用有侵入式的NLOHMANN_DEFINE_TYPE_INTRUSIVE
来访问private成员。使用宏的话,整个使用就非常简单了,测试代码如下:
c
struct Student
{
std::string name;
int age;
NLOHMANN_DEFINE_TYPE_INTRUSIVE(Student, name, age)
};
struct Teacher {
std::vector<Student> students;
std::string name;
std::string subject;
NLOHMANN_DEFINE_TYPE_INTRUSIVE(Teacher, students, name, subject)
};
//使用
Student s1 {"zs", 11};
Student s2 {"ls", 13};
Teacher t;
t.name = "wang";
t.subject = "math";
t.students.push_back(s1);
t.students.push_back(s2);
json j = t;
std::cout << "j:" << j.dump(4) << std::endl;
Teacher t1 = j.template get<Teacher>();
只需要在宏里面写好类名,以及需要序列化的成员即可。
5.4 枚举类
在自定义类型的序列化中,枚举类型需要额外关注,默认情况下枚举会被序列化为int值,因为枚举本身保存的也就是int值。但是,在序列化和反序列化中,这种逻辑可能出现问题。比如现在有枚举类如下:
arduino
enum TaskState{
TS_STOPPED, //0
TS_RUNNING, //1
TS_COMPLETED, //2
TS_INVALID = -1
};
这里我们把TS_INVALID赋值为-1,表示无效,其他枚举值会按照规范依次被赋值为0、1和2,不论我们打印还是默认序列化,TS_STOPPED的值都是0:
c
std::cout << TS_STOPPED << std::endl;
//输出结果
0
假如后面项目变化,需要新增一种枚举,如下:
arduino
enum TaskState{
TS_TEMP, //0
TS_STOPPED, //1
TS_RUNNING, //2
TS_COMPLETED, //3
TS_INVALID = -1
};
这时TS_TEMP就会变成0,假如我们有一个旧对象序列化后的json保存在文件里,旧的json中保存的还是0,经过反序列化后0会被反序列化为TS_TEMP,而不是预期的TS_STOPPED了,这就是默认使用int作为枚举值的弊端。
在该库中,我们可以更加精确地指定给定枚举如何映射到json以及如何从json映射,使用NLOHMANN_JSON_SERIALIZE_ENUM
宏,代码如下:
arduino
enum TaskState{
TS_STOPPED, //0
TS_RUNNING, //1
TS_COMPLETED, //2
TS_INVALID = -1
};
NLOHMANN_JSON_SERIALIZE_ENUM(TaskState, {
{TS_INVALID, nullptr},
{TS_STOPPED, "stopped"},
{TS_RUNNING, "running"},
{TS_COMPLETED, "completed"}
})
该宏就是可以声明在to_json()
和from_json()
时枚举所对应的字符串,这样不使用默认的int来保存,就大大提高了程序的稳定性:
ini
json j = TS_STOPPED;
assert(j == "stopped");
json j3 = "running";
assert(j3.template get<TaskState>() == TS_RUNNING);
上述代码可以正常运行,说明TS_RUNNING
在序列化时变成了running
,假如我们新增了一种枚举,只要使用宏包括进来:
arduino
enum TaskState{
TS_TEMP,
TS_STOPPED, //0
TS_RUNNING, //1
TS_COMPLETED, //2
TS_INVALID = -1
};
NLOHMANN_JSON_SERIALIZE_ENUM(TaskState, {
{TS_INVALID, nullptr},
{TS_STOPPED, "stopped"},
{TS_RUNNING, "running"},
{TS_COMPLETED, "completed"},
{TS_TEMP, "temp"}
})
上述代码依旧可以执行成功,不会出现反序列化错误的情况。
这里有一点需要特别注意,就是在宏NLOHMANN_JSON_SERIALIZE_ENUM
的定义中,我们把默认的无效枚举TS_INVALID定义在第一个,这个是有特殊意义的,假如json中的值未定义,无法反序列化为任何一种枚举,就会被反序列化为这个默认值,代码如下:
ini
json jPi = 3.14;
assert(jPi.template get<TaskState>() == TS_INVALID);
上面的3.14
属于未定义的枚举值,在这种情况下会默认反序列化为默认值。
总结
通过系统地学习一遍其API,发现该库还是非常牛的,后续有机会研究一下其源码实现。