leptjson 开发实践:测试驱动与安全编程启示

Foolish-Han Lv3

一直觉得自己的代码水平处在纸上谈兵的层面,好像什么都学了又好像什么都干不了。为了成为一名不只是会面向作业编程的 Coder,于是决心学习开源项目,提高业务水平。于是,便从 json-tutorial 这一代码量仅在一千行的项目启程,开启打怪升级之路~

项目简介

JSON 即 JavaScript Object Notation,是一种轻量级的文本数据交换格式。主要包括以下六种类型:

  • 数字(整数或浮点数)
  • 字符串(在双引号中)
  • 逻辑值(true 或 false)
  • 数组(在中括号中)
  • 对象(在大括号中)
  • null

leptjson 项目旨在使用 C 语言完成一批库函数,能够对 JSON 文本进行解析并以一定的数据结构存储起来,同时能够使用该数据结构进行 JSON 的生成、修改与输出。

TDD

本次项目中,给我印象最深刻的当属测试驱动开发的方法论。

Test Driven Development (TDD) is a software development methodology that emphasizes writing tests before writing the actual code. It ensures that code is always tested and functional, reducing bugs and improving code quality. In TDD, developers write small, focused tests that define the desired functionality, then write the minimum code necessary to pass these tests, and finally, refactor the code to improve structure and performance.

Test-Driven-Devlopment
Test-Driven-Devlopment

尽管以前的 OO 课程非常强调测试,甚至有一整个单元都围绕单元测试展开,但由于当时的测试仅仅面向一个功能,同时也因为有官方的评测机为我们兜底,实际上对于测试的价值并没有过多的体会,是为了测试而测试。

在本项目中,在每实现一个库函数之间,都要首先编写测试程序来对函数的正确性进行约束,然后再去实现函数使得其能够通过测试,这样做的好处是显而易见的。

一方面,测试程序为我们明确了目标和方向,使得我们能够聚焦在一个规模较小的具体问题上,直击要害。我们的开发过程会轻松许多,每通过一个测试点便会有一分成就,不会因为要“一口吃下大胖子”望而生畏。

另一方面,由于有测试程序为我们“兜底”,我们可以大胆地进行重构、调优、扩展。在实现新功能的过程中,可以同时对原有功能进行测试,能够及时地发现新代码与旧代码冲突的地方,将问题的发现时机大大提前。

当然,编写测试程序可能是一件比较烦琐的事情。在本项目中,测试程序与功能程序达到了同一个体量,只多不少。但由于软件的测试本身就是必要的,漫无目的 debug 的代价更高,编写测试程序实际上是一件性价比很高的事。

人在持续地进行重复性的、注重细节的工作中是很容易犯错的,TDD 则正合人性。

安全开发

说实话,我以前写的代码没有任何安全性可言,仅仅停留在能跑通评测的层面。对于错误处理、内存分配的问题,纯粹是采用鸵鸟算法了。安全性是什么呢?在本次的项目开发中,我认为应当是一套行之有效的使用规范

在本项目中,显而易见的是函数开头大量的 assert 语句,由此保证参数满足基本的规范(指针不是空指针、对象类型符合要求、数字大小符合约束……)。这些语句本质上是为程序员本身写的,提醒程序员以合法的方式调用这些函数。人是很健忘的,不可能时刻记住每一段代码的意图,我们必须在实现之初把这些要求形式化地规定出来,由此降低后续犯错的可能性。

1
2
3
4
5
6
7
8
9
void lept_remove_object_value(lept_value *v, size_t index) {
assert(v != NULL && v->type == LEPT_OBJECT && index < v->u.o.size);
lept_free(&v->u.o.m[index].v);
free(v->u.o.m[index].k);
for (size_t i = index + 1; i < v->u.a.size; i++) {
v->u.o.m[i - 1] = v->u.o.m[i];
}
v->u.o.size -= 1;
}

众所周知的是,C 语言是一种内存不安全的语言,但长期以来,我并没有意识到这一点。既是因为在设计代码时,从来没有这方面的考量,也是因为以往的内存泄露还没有累积到内存不够用的层面,从而产生明显的问题。在本次的开发中,由于使用了 valgrind 工具进行内存监控,我才始觉背后发凉,感受到内存分配与释放这一需要重视的问题。

lept_value 结构体为例,它的生命周期可能很“长”,跨越了很多函数。可能一个结构体本身就是动态内存分配的产物,而其成员指针又指向了一块动态分配的内存,这使得内存分配具有了一定的“递归属性”。此外,由于分配与释放的分离、指针变量的赋值,“所有权”会发生转移,内存的追踪和控制变得相当难以琢磨。因此,我们必须建立一套严格的规范,初始化、释放、重新分配的时机都应当有明确的约束。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
struct lept_value {
union {
/* number */
double n;
/* object: members, member count, capacity */
struct {
lept_member *m;
size_t size;
size_t capacity;
} o;
/* array: elements, element count, capacity */
struct {
lept_value *e;
size_t size;
size_t capacity;
} a;
/* string: null-terminated string, string length */
struct {
char *s;
size_t len;
} s;
} u;
lept_type type;
};

例如,这里在每创建一个结构体时,都首先需要通过 lept_init 进行初始化,避免使用未经初始化的成员造成的潜在问题。而当使用完毕后,都应当调用 lept_free 进行释放。这种定制化的封装,是安全性的基本保障。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
static void test_parse_false() {
printf("test_parse_false:\n");
lept_value v;
lept_init(&v);
lept_set_boolean(&v, 1);
EXPECT_EQ_INT(LEPT_PARSE_OK, lept_parse(&v, "false"));
EXPECT_EQ_INT(LEPT_FALSE, lept_get_type(&v));
lept_free(&v);
}

#define lept_init(v) \
do { \
(v)->type = LEPT_NULL; \
} while (0)

void lept_free(lept_value *v) {
size_t i;
assert(v != NULL);
switch (v->type) {
case LEPT_STRING:
free(v->u.s.s);
break;
case LEPT_ARRAY:
for (i = 0; i < v->u.a.size; i++) {
lept_free(&v->u.a.e[i]);
}
free(v->u.a.e);
break;
case LEPT_OBJECT:
for (i = 0; i < v->u.o.size; i++) {
lept_free(&v->u.o.m[i].v);
free(v->u.o.m[i].k);
}
free(v->u.o.m);
break;
default:
break;
}
v->type = LEPT_NULL;
}

总结

写代码是个艺术活,可以天马行空地创造,但必须戴着镣铐起舞。同这世间大多数事情一样,必须养成良好的习惯,形成有效的规范,才能从幼稚走向成熟。

目录
leptjson 开发实践:测试驱动与安全编程启示