从零开始的 JSON 库教程 笔记

注:好记性不如烂笔头,在看Milo Yip 的从零开始的 JSON 库教程,学习下编程技术,把在学习过程中遇到的自己觉得很有意思的地方给记录了下来,基本是 ctrl + c ctrl + v。所以如果需要看他的教程学习的同学,请移步https://zhuanlan.zhihu.com/json-tutorial。

从零开始的 JSON 库教程(一):启程


作者:Milo Yip
链接:https://zhuanlan.zhihu.com/p/22460835
来源:知乎
著作权归作者所有。商业转载请联系作者获得授权,非商业转载请注明出处。

我们要实现的 JSON 库,主要是完成 3 个需求:

把 JSON 文本解析为一个树状数据结构(parse)。
提供接口访问该数据结构(access)。
把数据结构转换成 JSON 文本(stringify)。

在本单元中,我们只实现最简单的 null 和 boolean 解析。

JSON 是什么

JSON 是树状结构,而 JSON 只包含 6 种数据类型:

  1. null: 表示为 null
  2. boolean: 表示为 true 或 false
  3. number: 一般的浮点数表示方式,在下一单元详细说明
  4. string: 表示为 “…”
  5. array: 表示为 [ … ]
  6. object: 表示为 { … }

我们要实现的 JSON 库,主要是完成 3 个需求:

  1. 把 JSON 文本解析为一个树状数据结构(parse)。
  2. 提供接口访问该数据结构(access)。
  3. 把数据结构转换成 JSON 文本(stringify)。

leptjson function

搭建编译环境

头文件与 API 设计

C 语言有头文件的概念,需要使用 #include去引入头文件中的类型声明和函数声明。但由于头文件也可以 #include 其他头文件,为避免重复声明,通常会利用宏加入 include 防范(include guard)

#ifndef LEPTJSON_H__
#define LEPTJSON_H__

/* ... */

#endif /* LEPTJSON_H__ */

宏的名字必须是唯一的,通常习惯以 _H__ 作为后缀。由于 leptjson 只有一个头文件,可以简单命名为 LEPTJSON_H__。如果项目有多个文件或目录结构,可以用 项目名称_目录_文件名称_H__ 这种命名方式。

JSON 语法子集

下面是此单元的 JSON 语法子集,使用 RFC7159 中的 ABNF 表示:

JSON-text = ws value ws
ws = *(%x20 / %x09 / %x0A / %x0D)
value = null / false / true 
null  = "null"
false = "false"
true  = "true"

当中 %xhh 表示以 16 进制表示的字符,/ 是多选一,* 是零或多个,() 用于分组。

单元测试

M大关于单元测试的讲解,让我眼前一亮,很有意思的一种测试驱动编程方法。通过先写测试,然后去编写功能代码。具体的见链接:

https://github.com/miloyip/json-tutorial/blob/master/tutorial01/tutorial01.md#单元测试

一般来说,软件开发是以周期进行的。例如,加入一个功能,再写关于该功能的单元测试。但也有另一种软件开发方法论,称为测试驱动开发(test-driven development, TDD),它的主要循环步骤是:

  1. 加入一个测试。
  2. 运行所有测试,新的测试应该会失败。
  3. 编写实现代码。
  4. 运行所有测试,若有测试失败回到3。
  5. 重构代码。
  6. 回到 1。

TDD 是先写测试,再实现功能。好处是实现只会刚好满足测试,而不会写了一些不需要的代码,或是没有被测试的代码。

宏的编写技巧

有些同学可能不了解 EXPECT_EQ_BASE 宏的编写技巧,简单说明一下。反斜线代表该行未结束,会串接下一行。而如果宏里有多过一个语句(statement),就需要用 do { /*...*/ } while(0) 包裹成单个语句,否则会有如下的问题:

#define M() a(); b()
if (cond)
    M();
else
    c();

/* 预处理后 */

if (cond)
    a(); b(); /* b(); 在 if 之外     */
else          /* <- else 缺乏对应 if */
    c();

只用 { } 也不行:

#define M() { a(); b(); }

/* 预处理后 */

if (cond)
    { a(); b(); }; /* 最后的分号代表 if 语句结束 */
else               /* else 缺乏对应 if */
    c();

用 do while 就行了:

#define M() do { a(); b(); } while(0)

/* 预处理后 */

if (cond)
    do { a(); b(); } while(0);
else
    c();

实现解析器

关于断言

断言(assertion)是 C 语言中常用的防御式编程方式,减少编程错误。最常用的是在函数开始的地方,检测所有参数。有时候也可以在调用函数后,检查上下文是否正确。

C 语言的标准库含有 assert() 这个宏(需 #include ),提供断言功能。当程序以 release 配置编译时(定义了 NDEBUG 宏),assert() 不会做检测;而当在 debug 配置时(没定义 NDEBUG 宏),则会在运行时检测 assert(cond) 中的条件是否为真(非 0),断言失败会直接令程序崩溃。

例如上面的 lept_parse_null() 开始时,当前字符应该是 'n',所以我们使用一个宏 EXPECT(c, ch) 进行断言,并跳到下一字符。

初使用断言的同学,可能会错误地把含副作用的代码放在 assert() 中:

assert(x++ == 0); /* 这是错误的! */

这样会导致 debug 和 release 版的行为不一样。

另一个问题是,初学者可能会难于分辨何时使用断言,何时处理运行时错误(如返回错误值或在 C++ 中抛出异常)。简单的答案是,如果那个错误是由于程序员错误编码所造成的(例如传入不合法的参数),那么应用断言;如果那个错误是程序员无法避免,而是由运行时的环境所造成的,就要处理运行时错误(例如开启文件失败)。

我的代码

从零开始的 JSON 库教程(二):解析数字


作者:Milo Yip
链接:https://zhuanlan.zhihu.com/p/22497369
来源:知乎
著作权归作者所有。商业转载请联系作者获得授权,非商业转载请注明出处

1.初探重构

重构:在不改变代码外在行为的情况下,对代码作出修改,以改进程序的内部结构。
重构与单元测试是互相依赖的软件开发技术,适当地运用可提升软件的品质。之后的单元还会有相关的话题。

那么,哪里要作出修改?Beck 和 Fowler([1] 第 3 章)认为程序员要培养一种判断能力,找出程序中的坏味道。例如,在第一单元的练习中,可能大部分人都会复制 lept_parse_null() 的代码,作一些修改,成为 lept_parse_true()lept_parse_false()。如果我们再审视这 3 个函数,它们非常相似。这违反编程中常说的 DRY(don’t repeat yourself)原则。本单元的第一个练习题,就是尝试合并这 3 个函数。

另外,我们也可能发现,单元测试代码也有很重复的代码,例如 test_parse_invalid_value()中我们每次测试一个不合法的 JSON 值,都有 4 行相似的代码。我们可以把它用宏的方式把它们简化:

#define TEST_ERROR(error, json)\
    do {\
        lept_value v;\
        v.type = LEPT_FALSE;\
        EXPECT_EQ_INT(error, lept_parse(&v, json));\
        EXPECT_EQ_INT(LEPT_NULL, lept_get_type(&v));\
    } while(0)

static void test_parse_expect_value() {
    TEST_ERROR(LEPT_PARSE_EXPECT_VALUE, "");
    TEST_ERROR(LEPT_PARSE_EXPECT_VALUE, " ");
}

2. JSON 数字语法

我们先看看JSON number 类型的语法:

number = [ "-" ] int [ frac ] [ exp ]
int = "0" / digit1-9 *digit
frac = "." 1*digit
exp = ("e" / "E") ["-" / "+"] 1*digit

number 是以十进制表示,它主要由 4 部分顺序组成:负号、整数、小数、指数。只有整数是必需部分。注意和直觉可能不同的是,正号是不合法的。

JSON 标准 ECMA-404 采用图的形式表示语法,也可以更直观地看到解析时可能经过的路径:

从零开始的 JSON 库教程 笔记_第1张图片

上一单元的 null、false、true 在解析后,我们只需把它们存储为类型。但对于数字,我们要考虑怎么存储解析后的结果。

3. 数字表示方式

4. 单元测试

5. 十进制转换至二进制

我们需要把十进制的数字转换成二进制的 double。这并不是容易的事情 [2]。为了简单起见,leptjson 将使用标准库的strtod() 来进行转换。strtod() 可转换 JSON 所要求的格式,但问题是,一些 JSON 不容许的格式,strtod() 也可转换,所以我们需要自行做格式校验。

6. 总结与练习

3.去掉 test_parse_invalid_value()test_parse_root_not_singular 中的 #if 0 ... #endif,执行测试,证实测试失败。按 JSON number 的语法在 lept_parse_number() 校验,不符合标准的程况返回LEPT_PARSE_INVALID_VALUE 错误码。
再次体现了测试驱动编程开发。

7. 常见问题

  1. 为什么要把一些测试代码以 #if 0 … #endif 禁用?

因为在做第 1 个练习题时,我希望能 100% 通过测试,方便做重构。另外,使用 #if 0 ... #endif 而不使用 /* ... */,是因为 C 的注释不支持嵌套(nested),而 #if ... #endif 是支持嵌套的。代码中已有注释时,#if 0 ... #endif 去禁用代码是一个常用技巧,而且可以把 0 改为 1 去恢复。

我的代码

从零开始的 JSON 库教程(三):解析字符串


1. JSON 字符串语法

JSON的字符串语法和C语言很相似,都是以双引号把字符括起来,如 "Hello" 。并且,JSON都使用 \ 作为转义字符。

比较特殊的是\uXXXX,当中 XXXX 为 16 进位的 UTF-16 编码,本单元将不处理这种转义序列,留待下回分解。

2. 字符串表示

JSON 字符串是允许含有空字符的,例如这个 JSON "Hello\u0000World" 就是单个字符串,解析后为11个字符。如果纯粹使用空结尾字符来表示 JSON 解析后的结果,就没法处理空字符。

因此,我们可以分配内存来储存解析后的字符,以及记录字符的数目(即字符串长度)。

我们知道,一个值不可能同时为数字和字符串,因此我们可使用 C 语言的 union 来节省内存:

typedef struct {
    union {
        struct { char* s; size_t len; }s;  /* string */
        double n;                          /* number */
    }u;
    lept_type type;
}lept_value;

我们要把之前的 v->n 改成 v->u.n。而要访问字符串的数据,则要使用 v->u.s.s 和 v->u.s.len。

3. 内存管理

由于字符串的长度不是固定的,我们要动态分配内存。为简单起见,我们使用标准库 中的malloc()、realloc() 和 free() 来分配/释放内存。

当设置一个值为字符串时,我们需要把参数中的字符串复制一份给 lept_value:

void lept_set_string(lept_value* v, const char* s, size_t len) {
    assert(v != NULL && (s != NULL || len == 0));  // 非空指针(有具体的字符串)或是零长度的字符串都是合法的。
    lept_free(v);
    v->u.s.s = (char*)malloc(len + 1);
    memcpy(v->u.s.s, s, len);
    v->u.s.s[len] = '\0';
    v->u.s.len = len;
    v->type = LEPT_STRING;
}

4. 缓冲区与堆栈

5. 解析字符串

6. 总结和练习

Windows 下的内存泄漏检测方法

在 Windows 下,可使用 Visual C++ 的 C Runtime Library(CRT) 检测内存泄漏。

首先,我们在两个 .c 文件首行插入这一段代码:

#ifdef _WINDOWS
#define _CRTDBG_MAP_ALLOC
#include 
#endif

并在 main() 开始位置插入:

int main() {
#ifdef _WINDOWS
    _CrtSetDbgFlag(_CRTDBG_ALLOC_MEM_DF | _CRTDBG_LEAK_CHECK_DF);
#endif

在 Debug 配置下按 F5 生成、开始调试程序,没有任何异样。然后,我们删去 lept_set_boolean() 中的 lept_free(v):

void lept_set_boolean(lept_value* v, int b) {
    /* lept_free(v); */
    v->type = b ? LEPT_TRUE : LEPT_FALSE;
}

再次按 F5 生成、开始调试程序,在输出会看到内存泄漏信息:

Detected memory leaks!
Dumping objects ->
C:\GitHub\json-tutorial\tutorial03_answer\leptjson.c(212) : {79} normal block at 0x013D9868, 2 bytes long.
 Data:  61 00 
Object dump complete.

注:这里我在 vs2015 的两个 .c 文件上都添加了一行 #define _WINDOWS,然后能顺利测试。

这正是我们在单元测试中,先设置字符串,然后设布尔值时没释放字符串所分配的内存。比较麻烦的是,它没有显示调用堆栈。从输出信息中 … {79} … 我们知道是第 79 次分配的内存做成问题,我们可以加上_CrtSetBreakAlloc(79); 来调试,那么它便会在第 79 次时中断于分配调用的位置,那时候就能从调用堆栈去找出来龙去脉。

7. 参考

8. 常见问题

未完待续

你可能感兴趣的:(编程练习)