[C++] 从零开始编写一个简单的 JSON 解析器
介绍
总体设计与策略
执行
测试
结论
介绍
在进行我的个人 C++ 练习(打算作为半个作品集展示)时,我需要为程序将要使用的超参数创建一个 fixture 文件。然而,与 Node.js 或 Python 等拥有 JSON 格式专用 STL 的语言不同,C++ 没有,而且向 C++ 添加第三方库也比较棘手。
因此,我决定自己编写一个 C++ JSON 解析器,虽然它比生产代码中常用的解析器要简单一些。我认为这将是我提升 C++ 技能的又一次很好的练习。
总体设计与策略
记住,我不想把这变成另一个大项目,所以我想尽量简化它。具体来说,
- 我不会使用任何数组。
- 我将假设该文件包含有效的 JSON 值,因为处理无效值会引入太多特殊情况。
- 我还假设 JSON 文件以花括号 (
{,})开头和结尾。 - 我的需求只需要三种数据类型:字符串(我将使用 `strings` 解析
std::string)、整数和双精度浮点数。这些数值类型不会包含负值。
那么,我们的解析器策略应该是什么样的呢?
- 我们使用
ParseJson()一个主要的解析函数。该函数必须是递归的,以便处理嵌套的 JSON 值。 - 我们使用双引号(
”)和分号(:)来识别每个键。值要么以 (嵌套值)开头,要么由纯数字字符和点( )(整数或双精度浮点数){组成。. - 只会有两种非负基本类型:
double和int。 - 由于存在嵌套值,定义返回值的类型有些困难。这里,我们采用
unionC++ 中的类型定义:
// json_parser.h
union JsonValue {
int i;
double d;
std::map<std::string, JsonValue>* json;
};
请注意,由于我们使用指针来实现递归,因此类型std::map<std::string, JsonValue>*是联合类型的一部分。此外,由于值是在运行时确定的,因此我们不能在这里使用模板,而只能使用其他方法union。
现在,让我们深入了解代码。
执行
主功能的ParseJson()逻辑流程如下,只有两个步骤。
- 根据文件路径读取 fixture JSON 文件。
- 将JSON文本数据解析为
std::map对象。 - 我们将使用递归调用的辅助函数来处理嵌套值。
- 对于递归的基本情况,我们只需要解析任一
int类型double值。
1. 读取文件
首先,我们的解析器需要一个 I/O 系统:我们从 JSON 文件中读取文本作为输入,并将JsonValue数据作为输出返回给调用函数。为此,我们定义了ReadFile()一个读取文本文件并返回std::string对象的函数。
// json_parser.cpp
void JsonParser::ReadFile(const std::string& filepath, std::string& output) {
std::ifstream file(filepath);
std::string line;
while (std::getline(file, line)) {
output.append(line); // append() copies the argument passed as a reference(std::string&)
}
}
2. 解析值
接下来,我们定义ParsePrimitive()只解析原始值(double或int)的值。
// json_parser.cpp
// Remark: test_it is defined in json_parser.h, and it is an
// alias of std::string::iterator
JsonValue JsonParser::ParsePrimitive(const std::string& text, text_it start, text_it end) {
std::string substr = text.substr(start - text.begin(), end - start);
size_t float_point_index = substr.find(".");
if (float_point_index >= (end - start)) { // integer
return {.i = std::stoi(substr)};
} else { // float(double)
return {.d = std::stod(substr) };
}
}
然后我们实现主要逻辑,ParseJsonHelper()该逻辑负责处理此解析过程的递归性。
// json_parser.cpp
JsonValue JsonParser::ParseJsonHelper(const std::string& text, text_it& it) {
assert(*it == '{'); // must start with the left curly bracket
it++;
std::map<std::string, JsonValue>* json_map = new std::map<std::string, JsonValue>;
do {
const auto [key, value] = RetriveKeyValuePair(text, it);
(*json_map)[key] = value;
while (*it == ' ' || *it == '\n') {
it++;
}
} while (*it != '}');
it++; // after '}'
return { .json = json_map };
}
但是,为了检索内部的键值对ParseJsonHelper(),我们将检索逻辑分离到另一个辅助函数中RetrieveKeyValuePair(),该函数返回一个(键,值)对。
// json_parser.cpp
std::pair<std::string, JsonValue> JsonParser::RetriveKeyValuePair(
const std::string& text,
text_it& it
) {
assert(it != text.end());
// ignore white spaces & line breaks
while (*it == ' ' || *it == '\n') {
it++;
}
text_it curr_it;
std::string key;
JsonValue value;
// if hit a double quote for the first time, it is a key
if (*it == '\"') {
curr_it = ++it;
while (*it != '\"') {
it++;
}
key = text.substr(curr_it - text.begin(), it - curr_it);
assert(*(++it) == ':'); // assert that we are parsing the key string
it++;
}
// now we need to have its corresponding value
while (*it == ' ' || *it == '\n') {
it++;
}
if (*it == '{') {
// another json format
value = ParseJsonHelper(text, it);
} else {
// primitive value(double or int)
curr_it = it;
while (isdigit(*it) || *it == '.') {
it++;
}
value = ParsePrimitive(text, curr_it, it);
}
// after parsing the value, check whether the current iterator points to a comma
if (*it == ',') {
it++;
}
return std::make_pair(key, value);
}
(请注意,这两个辅助函数ParseJsonHelper()会RetrieveKeyValuePair()相互递归调用。)
由于我们的算法本质上是深度优先搜索(DFS)算法,我们将文本值的迭代器(来自原始JSON文本文件)作为引用传递给ParseJsonHelper()和RetrieveKeyValuePair()。这使得每个调用堆栈都可以跟踪给定文本值对应的函数调用位置。
3. 主要解析功能
最后,ParseJson()将目前为止实现的所有功能整合在一起,以解析单个 JSON 文件。
// json_parser.cpp
JsonValue JsonParser::ParseJson(const std::string& filepath) {
// 1. read the text data from the given file
std::string text;
ReadFile(filepath, text);
// 2. parse the text with the helper function and return
text_it start = text.begin();
return ParseJsonHelper(text, start);
}
4. 最终代码
由于完整代码会占用太多空间,我将提供最终代码的链接:
测试
如果能对这个函数进行一些单元测试就太好了。我们这里使用的是GoogleTest。
例如,如果我们测试一个简单的 JSON 文本,如下所示:
{
"one": 1,
"two": {
"three": 3,
"four": {
"five": 5
}
},
"six": {
"seven": 7
}
}
测试代码如下:
TEST(JsonParserTest, TestJsonWithNests) {
std::string json_text = "{\n \"one\": 1,\n \"two\": {\n\"three\": 3, \n \"four\": { \n\"five\": 5 \n } \n }, \n \"six\": {\n\"seven\": 7\n } \n}";
text_it start = json_text.begin();
JsonValue parsed = ParseJsonHelper(json_text, start);
JsonValue two = (*parsed.json)["two"];
JsonValue four = (*two.json)["four"];
JsonValue six = (*parsed.json)["six"];
EXPECT_EQ((*parsed.json)["one"].i, 1);
EXPECT_EQ((*two.json)["three"].i, 3);
EXPECT_EQ((*four.json)["five"].i, 5);
EXPECT_EQ((*six.json)["seven"].i, 7);
};
现在看看结果:
结论
这实际上是我第一次为自己编写工具(之前从未用其他语言(例如我更熟悉的 Python 或 Node.js)做过类似的事情)。尽管如此,我仍然不确定我的设计决策或实现方式是否正确(甚至是否符合 C++ 的最佳实践),但我确实从这个非常简单的项目中学到了很多。
- 我从零开始编写了一个程序,没有借助任何外部资源,例如教程或任何第三方库和框架(GoogleTest 除外)。
- 这个程序是供我个人使用的,不是作为家庭作业的一部分。
- 我使用了一些 C++ STL,并实现了一个相当合理的(至少在我看来是这样!)递归算法。
我必须说,这个项目并不成功。我应该积累更多 C++ 经验,阅读更多书籍和开源代码,并练习更多算法,才能让我的项目尽可能完善。
然而,依我拙见,坐下来写代码才是最重要的。实践经验远比单纯读书和做 LeetCode 题更有价值。
文章来源:https://dev.to/uponthesky/c-making-a-simple-json-parser-from-scratch-250g
