C 语言

关注公众号 jb51net

关闭
首页 > 软件编程 > C 语言 > C++ nlohmann/json

C++开源库nlohmann/json的介绍和使用详解

作者:yuanhao

nlohmann/json 是一个C++实现的JSON解析器,使用非常方便直观,这篇文章主要为大家详细介绍了nlohmann/json的简介和使用,需要的可以参考下

前言

该库的地址:github.com/nlohmann/json,到目前已经拥有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对象,如下:

//想创建的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
}

这里看似非常简单,但是有一些原理我们是需要知道的。我们对比一下给定的jsonlist的结构,它是一个数组,使用[1, 0, 2],我们在jj2的构造中,都是使用的{}来进行赋值的,这一点就和把json看成一等公民的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++11可以使用列表初始化
std::vector list = {0, 1, 2};
//错误
std::vector list1 = [0, 1, 2];

因为该库只能利用重载运算符等方式来让json操作看起来像是操作json,而非C++语言支持这种操作。

搞清楚这个之后,我们再来看看object,在原始的json中,我们可以清晰地知道它对应的类型是一个类类型:

"object": {
    "currency": "USD",
    "value": 42.99
  }

但是在使用对jobject的赋值如下:

j["object"] = { {"currency", "USD"}, {"value", 42.99}};

这不是和前面所说的数组赋值冲突了吗,依据列表初始化规则,这个完全可以解析为数组类型,即[["currency", "USD"],["value", 42.99]],也就是二维数组,这时我们又可以对比一下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++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++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()函数:

    //显示声明是一个数组,而非对象
    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对象,注意和上面使用=进行列表初始化的方式不同,这里的参数是字符串字面量:

    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"()"来表示原始字符串字面量,使用原始字符串字面量可以方便地包含特殊字符(比如反斜杠、引号等)或者多行文本的字符串,而无需对这些特殊字符进行转移。

比如如下代码:

{
    qDebug() << "Hello\tWorld!";
    qDebug() << R"(Hello\tWorld
NEW LINE)";
}

运行结果是:

可以发现普通字符串中\t被解释为制表符,而在原始字符串字面量中,\t不会被解析,并且换行也被保留了下来。其实在其他语言中,使用这种方式更为简单,比如Kotlin使用"""进行包裹字符串,或者Python使用```进行包裹字符串。

通过这个小知识点的学习,我们明显发现j2就比j的初始化方式更人性化,至少不用去在意字符串中的转移字符。

2.1.2 自定义字面量操作符

然后我们关注点来到jj1的区别,可以发现j多了一个_json的后缀,然后在输出打印中就可以正常解析,而j1却不可以。这里的核心点就是字符串字面量后面的_json,我们看一下它的源码:

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 ""可以为特定的后缀自定义语义,这种机制可以使得我们可以像使用内置的字面量(如整数、浮点数、字符串等)一样自然地使用自定义的字面量,从而提高代码的可读性和表达力。

举个简单的例子:

//为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()函数:

json j3 = json::parse(R"({"happy": true,"pi": 3.141})");

从前面的错误用法打印来看,一般情况下我们认为json对象要不表示一个对象,要不表示一个数组,但是从打印j1来看:

    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对象可以说是在配置文件时非常常用了,这里使用也非常简单,代码如下:

    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;

可以发现只要获取到标准的输入输出流之后,我们就可以使用>><<符号来进行文件序列化和反序列化了,这里的原理非常简单,也是操作符重载,在源码中重载了>><<

    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也就很有必要,使用如下:

    //创建一个数组, 使用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::arraystd::vectorstd::dequestd::forward_liststd::list,其中保存值可以构造json的值,比如intfloatboolean、字符串类型等,这些容器均可以用来构造json数组。对于类似的关联容器(std::setstd::multisetstd::unordered_setstd::unordered_multiset)也是一样,但是在这种情况下,数组元素的顺序取决于元素在数组中的排序方式。

使用代码如下:

    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对象类型,测试代码如下:

    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值之间的差异。这两点我觉得非常不错,有助于在版本迭代时进行合并配置文件,直接看代码:

    //原始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对象来进行合并补丁,这种场景非常适合配置文件的迭代,比如下面代码:

    //多了一个配置项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值获取值时,不建议使用隐式转换,建议使用显示的方式。比如下面代码:

    //推荐写法
    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对象转变为类类型对象。直接看代码:

    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

其实我们可以把序列化和反序列化的操作写在类中,也就是让该类拥有了该能力,这种写法如下:

#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_jsonfrom_json,然后使用如下:

    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,这时就会报错,因为访问不到,比如下面代码:

    json j = {
        {"name", "jack"}
    };
    //json对象转换为类类型对象
    auto s2 = j.template get<Student>();

这个j对象就没有age,然后调用from_json时就会出错,这里会直接抛出异常,有没有其他办法不抛出异常呢?还是有的,可以通过value方法进行:

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成员。使用宏的话,整个使用就非常简单了,测试代码如下:

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值。但是,在序列化和反序列化中,这种逻辑可能出现问题。比如现在有枚举类如下:

enum TaskState{
    TS_STOPPED,     //0
    TS_RUNNING,     //1
    TS_COMPLETED,   //2
    TS_INVALID = -1
};

这里我们把TS_INVALID赋值为-1,表示无效,其他枚举值会按照规范依次被赋值为0、1和2,不论我们打印还是默认序列化,TS_STOPPED的值都是0:

std::cout << TS_STOPPED << std::endl;
//输出结果
0

假如后面项目变化,需要新增一种枚举,如下:

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宏,代码如下:

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来保存,就大大提高了程序的稳定性:

    json j = TS_STOPPED;
    assert(j == "stopped");

    json j3 = "running";
    assert(j3.template get<TaskState>() == TS_RUNNING);

上述代码可以正常运行,说明TS_RUNNING在序列化时变成了running,假如我们新增了一种枚举,只要使用宏包括进来:

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中的值未定义,无法反序列化为任何一种枚举,就会被反序列化为这个默认值,代码如下:

    json jPi = 3.14;
    assert(jPi.template get<TaskState>() == TS_INVALID);

上面的3.14属于未定义的枚举值,在这种情况下会默认反序列化为默认值。

以上就是C++开源库nlohmann/json的介绍和使用详解的详细内容,更多关于C++ nlohmann/json的资料请关注脚本之家其它相关文章!

您可能感兴趣的文章:
阅读全文