一、前言
json是一个非常常见的格式,目前广泛应用于web数据传输、软件配置文件等等。
但可惜C++标准库中并不存在解析json数据的库,所以想要在C++中解析json
格式的数据,目前最好的办法便是使用第三方库,比如很火的nlohmann
,就是一个非常优秀且好用的json
格式解析库。
相比之下,Qt中的那个解析库是真的难用。
正好最近有空,我也写了一个json
数据的生成与解析器,该解析器的目标:
- 精简:只有一个头文件与一个源文件,目前只有
400
行左右的代码量。 - 高效:一个
2M
大小的json
文件(格式化后为8M
),nlohmann
处理需要2s
左右,而我这库只需要0.5
秒左右。 - 简单:使用起来与
python
代码相似。
比如我要解析下面这段数据:
string str = R"(
{
"name":"test",
"number":10.987,
"array":[
1.0,
3.45,
9.80
],
"object":{
"name":"yushi",
"age":100
"sex":"man"
}
}
)";
使用我这个库的代码为:
TJson t; //声明一个对象
t.Parse(str); //解析
就是这么简单
想要访问或修改,那么可以这样写:
t["object"]["age"] = 50; //修改
cout << t["object"]; //访问
输出:
{"age": 50,"name": "yushi","sex": "man"}
看上去是不是相当的简单!
虽然看起来不错,不过目前还是有缺点的,那就是暂不支持UTF-8
编码,不过如果文件数据中只有英文字母、数字、标点符号,那便无所谓文件编码了,都可以用。
亲手写一个json
数据格式的解析器应该是一个不错的学习体验,所以我这里将其作为一个教程文章写出来。
二、了解json格式
json只有5个数据类型,分别为:
number
(数字)boolean
(布尔)string
(字符串)list
(列表)object
(对象)
那么在C++中,这五种数据类型则可以分别对应以下五种数据类型:
double
bool
string
vector
map
其中string
、vector
、map
这三个严格来说应该不算数据类型,而是C++中的容器。
对于C++中的map
而言,其键可以为任意类型,但对于json
中的对象,其键只能为字符串,所以我们后面还需要将其限制一下。
但如果直接暴露以上这5个数据类型给别人使用,那未免太繁琐了,所以我们需要将这5个数据类型进行封装,让其统一成为一个数据类型:TJson
即Tiny json
的简写,当然你可以自行将其换成其它自己想用的名字,这无所谓。
重点是我们如何才能将5个数据类型统一为一个数据类型?
三、统一数据类型:TJson
统一数据类型,实际上就是对上面五种数据类型进行封装,然后在内部用一个变量来判断当前是什么数据类型:
#include<iostream>
#include<map>
#include<vector>
using namespace std;
class TJson {
enum class Type {
NOT, DOUBLE, STRING, BOOL, LIST, MAP
};
Type m_type; //记录当前的数据类型
//五种基本数据类型
double m_double;
string m_str;
vector<TJson> m_list;
map<string,TJson> m_map;
bool m_bool;
};
注意,由于Json
中数组与对象中,其值类型是不定的,所以这里要用我们这里的TJson
类型作为模板参数类型。
这里的NOT
枚举变量,代表当前为不确定类型,以后我们就通过m_type
值来识别当前为什么数据类型。
因为我们的TJson
类代表了五种任意类型,因此使用该类作为模板参数,就可以存储这五种数据类型的任意一种了。
完成了上面这一步,我们还需要初始化,也就是写构造函数:
public:
explicit TJson()
: m_double(0), m_bool(false), m_type(Type::NOT)
{
}
explicit TJson(double number)
:TJson()
{
m_double = number;
m_type = Type::DOUBLE;
}
explicit TJson(bool boolean)
:TJson()
{
m_bool = boolean;
m_type = Type::BOOL;
}
explicit TJson(const string& str)
:TJson()
{
m_str = str;
m_type = Type::STRING;
}
explicit TJson(const vector<TJson>& list)
:TJson()
{
m_list = list;
m_type = Type::LIST;
}
explicit TJson(const map<string, TJson>& map)
:TJson()
{
m_map = map;
m_type = Type::MAP;
}
过程很简单,就是分别进行赋值,并通过m_type
来区分当前代表什么类型。
注意这里初始化列表的写法,如果不理解为什么这样写,可以查看另一篇文章:初始化列表
还有就是前面的那个explicit
关键字,很重要,这是为了避免自动隐式转换的。
比如,由于写了这个类的bool
类型的构造函数,那么当一个函数需要TJson
作为参数时,你可以直接填bool
:
void Test(TJson tj) {
}
Test(true); //如果没有explicit关键字,则可以正常调用
因为传入true
时,会自动调用TJson
的布尔构造函数,构造出一个TJson
之后再传入。
但这种行为必须要进行避免,因为后面我们将重载等于运算符,如果不对这种行为进行禁止,那么使用t["num"] = 50;
这样的语句时,会自动将50
构造成一个TJson
对象,导致其本该调用的等号重载函数不会得到调用。
因为
=
右边的不是数字,而是一个对象,参数不符
这样我们的类就封装好了,只是缺少两个最重要的功能:解析数据、访问数据
四、解析json数据
首先来看看如何解析json数据。
解析这一步很重要,也很繁琐,首先需要注意的一个点是:JSON格式的数据是可以嵌套的。
比如一个列表中的元素,可以继续为一个列表,或者一个对象类型。
基于嵌套的这一点,我们首先可以想到的就是递归,即:让函数可以自己调自己,已解决这种嵌套数据的解析。
嵌套之前,我们需要先写好基本数据类型的解析函数,比如字符串、数字、布尔这三个类型。
为了方便,我们需要一个存放原始数据的缓存区,以及该缓存区的长度。
比如:
char* m_data; //the data
int m_len; //the data's size
现在假设,m_data
已经指向了原始json
数据缓存区,而m_len
则代表这个缓存区的长度。
可以直接它们直接放在类里面,作为私有变量,后面的单个解析函数同理作为私有函数,只需要为用户放出一个parse
的统一解析函数即可。
首先是字符串:
TJson ParseString(int& beg) {
int i = beg;
while (i != m_len && m_data[i] != '\"') i++; //find "
i++; //remove "
int tmp = i; //the string's begin
for (; i < m_len; i++) {
if (m_data[i] != '\"') continue;
//the string's end
beg = i;
return TJson(string(m_data + tmp, m_data + i));
}
throw "parse string error";
}
注意这里的参数与返回值,参数为引用数据类型,其目的是传入我们这个函数应该从m_data
缓存区中的哪里开始进行解析,并在解析结束后,将本函数解析的终点,再通过这个参数传出去,而返回值则是我们的解析结果,包含string
的TJson
类。
因为json中的字符串是在两个引号之间,所以我们要做的,就是找到两个引号,然后取出其中的内容即可。
过程很简单,看代码应该也能看明白,而且有注释。
只是注意最后的一句:
throw "parse string error";
如果函数执行到了这里,就说明解析失败了,我们就直接抛出异常,这里抛出的是字符串类型。
所以可以通过以下代码来处理这个异常:
try
{
int begin = 0;
ParseString(begin);
}
catch (const char* errStr)
{
cout << errStr;
}
然后是解析布尔类型:
TJson ParseBoolean(int& beg) {
int i = beg;
while (i != m_len && m_data[i] != 't' && m_data[i] != 'f') i++;
if (i + 4 > m_len) throw "parse boolean error";
if (m_data[i] == 't') {
if (m_data[i + 1] == 'r' && m_data[i + 2] == 'u' && m_data[i + 3] == 'e') {
beg = i + 3;
return TJson(true);
}
throw "parse boolean error";
}
else {
if (i + 5 > m_len) throw "parse boolean error";
if (m_data[i + 1] == 'a' && m_data[i + 2] == 'l' && m_data[i + 3] == 's' && m_data[i + 4] == 'e') {
beg = i + 4;
return TJson(false);
}
throw "parse boolean error";
}
}
这里的代码就要长一些了,因为json
中的布尔类型是通过true
与false
这两个字符串来表示的。
所以我们这个函数的目的就很明确了,通过在beg
处开始找这两个字符串,如果找到就构造TJson
并返回,否则抛出异常,处理方法与上面相同。
还有解析数字:
TJson ParseNumber(int& beg) {
int i = beg;
while (i != m_len && m_data[i] != '+' && m_data[i] != '-' && !isdigit(m_data[i])) i++;
bool sign = true;
if (m_data[i] == '+') {
i++;
}
else if (m_data[i] == '-') {
sign = false;
i++;
}
int j = i;
while (isdigit(m_data[j]) || m_data[j] == '.') j++;
beg = j;
if (!sign) return TJson(-atof(m_data + i));
else return TJson(atof(m_data + i));
}
逻辑就是找符号(+或-)以及数字作为开始解析的地方,并通过找不是数字与.
的位置作为终点,使用函数atof
进行转换,并将终点位置传回。
isdigit
函数可以判断这个字符是否为数字,这是C标准库里面的函数,可以直接用。
解决了上面三个基础的解析,下面就可以来看看我们重头戏了:解析数组与对象。
两个函数的形式与上面是一致的:
TJson ParseArray(int& ben);
TJson ParseObject(int& beg);
不同之处在于,因为数组与对象中的内容可以仍为数组与对象,所以这两个函数就需要开始进行递归了。
大部分json
数据,都是只有一个数组或对象,只不过其内部继续嵌套了数组与对象,才导致看起来很复杂。