云南省建設(shè)廳招標(biāo)辦網(wǎng)站網(wǎng)絡(luò)營銷活動策劃
1. JSON 是什么
JSON(JavaScript Object Notation)是一個用于數(shù)據(jù)交換的文本格式,現(xiàn)時的標(biāo)準(zhǔn)為ECMA-404 。
雖然 JSON 源自于 JavaScript 語言,但它只是一種數(shù)據(jù)格式,可用于任何編程語言?,F(xiàn)時具有類似功能的格式有XML、YAML,當(dāng)中以 JSON 的語法最為簡單。
例如,一個動態(tài)網(wǎng)頁想從服務(wù)器獲得數(shù)據(jù)時,服務(wù)器從數(shù)據(jù)庫查找數(shù)據(jù),然后把數(shù)據(jù)轉(zhuǎn)換成 JSON 文本格式:
{"title": "Design Patterns","subtitle": "Elements of Reusable Object-Oriented Software","author": ["Erich Gamma","Richard Helm","Ralph Johnson","John Vlissides"],"year": 2009,"weight": 1.8,"hardcover:": true,"publisher": {"Company": "Person Education","Country": "India"},"website": null
}
網(wǎng)頁的腳本代碼就可以把此 JSON 文本解析為內(nèi)部的數(shù)據(jù)結(jié)構(gòu)去使用。
從此例子可看出,JSON 是樹狀結(jié)構(gòu),而 JSON 只包含 6 種數(shù)據(jù)類型:
- null:表示為 null
- boolean:表示為 true 或 false
- number:一般的浮點數(shù)表示方式,在下一單元詳細說明
- string:表示為 “…”
- array:表示為 […]
- object:表示為 {…}
我們要實現(xiàn)的 JSON 庫,主要是完成 3 個需求:
- 把 JSON 文本解析為一個樹狀數(shù)據(jù)結(jié)構(gòu)(parse)。
- 提供接口訪問該數(shù)據(jù)結(jié)構(gòu)(access)。
- 把數(shù)據(jù)結(jié)構(gòu)轉(zhuǎn)換成 JSON 文本(stringify)。
我們會逐步實現(xiàn)這些需求。在本單元中,我們只實現(xiàn)最簡單的 null 和 boolean 解析。
2. 搭建編譯環(huán)境
源代碼位于 kikajson,當(dāng)中 01 為本單元的代碼。
代碼文件只有 3 個:
kikajson.h
:leptjson 的頭文件(header file),含有對外的類型和 API 函數(shù)聲明。kikajson.c
:leptjson 的實現(xiàn)文件(implementation file),含有內(nèi)部的類型聲明和函數(shù)實現(xiàn)。此文件會編譯成庫。test.c
:我們使用測試驅(qū)動開發(fā)(test driven development, TDD)。此文件包含測試程序,需要鏈接 leptjson 庫。
為了方便跨平臺開發(fā),使用一個軟件配置工具 CMake。
使用 Visual Studio 打開本單元項目,VS會自動進行CMake的配置(前提是已經(jīng)VS已經(jīng)安裝好相應(yīng)的插件),然后點開 CMakeLists.txt,運行當(dāng)前文檔,就可以編譯運行了
編譯運行后會出現(xiàn):
D:\rep\kikajson\test.c:56: expect: 3 actual: 0
11/12 (91.67%) passed
若看到類似以上的結(jié)果,說明已成功搭建編譯環(huán)境,可以去看看那幾個代碼文件的內(nèi)容了。
3. 頭文件與 API 設(shè)計
C 語言有頭文件的概念,需要使用 #include
去引入頭文件中的類型聲明和函數(shù)聲明。但由于頭文件也可以 #include
其他頭文件,為避免重復(fù)聲明,通常會利用宏加入 include 防范(include guard):
#ifndef KIKAJSON_H__
#define KIKAJSON_H__/* ... */#endif /* KIKAJSON_H__ */
宏的名字必須是唯一的,通常習(xí)慣以 _H__
作為后綴。由于 leptjson 只有一個頭文件,可以簡單命名為 KIKAJSON_H__
。如果項目有多個文件或目錄結(jié)構(gòu),可以用 項目名稱_目錄_文件名稱_H__
這種命名方式。
如前所述,JSON 中有 6 種數(shù)據(jù)類型,如果把 true 和 false 當(dāng)作兩個類型就是 7 種,我們?yōu)榇寺暶饕粋€枚舉類型(enumeration type):
typedef enum { KIKA_NULL, KIKA_FALSE, KIKA_TRUE, KIKA_NUMBER, KIKA_STRING, KIKA_ARRAY, KIKA_OBJECT } kika_type;
因為 C 語言沒有 C++ 的命名空間(namespace)功能,一般會使用項目的簡寫作為標(biāo)識符的前綴。通常枚舉值用全大寫(如 KIKA_NULL
),而類型及函數(shù)則用小寫(如 kika_type
)。
接下來,我們聲明 JSON 的數(shù)據(jù)結(jié)構(gòu)。JSON 是一個樹形結(jié)構(gòu),我們最終需要實現(xiàn)一個樹的數(shù)據(jù)結(jié)構(gòu),每個節(jié)點使用 kika_value
結(jié)構(gòu)體表示,我們會稱它為一個 JSON 值(JSON value)。
在此單元中,我們只需要實現(xiàn) null, true 和 false 的解析,因此該結(jié)構(gòu)體只需要存儲一個 kika_type。之后的單元會逐步加入其他數(shù)據(jù)。
typedef struct {kika_type type;
}kika_value;
C 語言的結(jié)構(gòu)體是以 struct X {}
形式聲明的,定義變量時也要寫成 struct X x;
。為方便使用,上面的代碼使用了 typedef
。
然后,我們現(xiàn)在只需要兩個 API 函數(shù):
(1)一個是解析 JSON:
int kika_parse(kika_value* v, const char* json);
傳入的 JSON 文本是一個 C 字符串(空字符結(jié)尾字符串/null-terminated string),由于我們不應(yīng)該改動這個輸入字符串,所以使用 const char*
類型。
另一注意點是,傳入的根節(jié)點指針 v 是由使用方負責(zé)分配的,所以一般用法是:
kika_value v;
const char json[] = ...;
int ret = kika_parse(&v, json);
返回值是以下這些枚舉值,無錯誤會返回 KIKA_PARSE_OK
,其他值在下節(jié)解釋。
enum {KIKA_PARSE_OK = 0,KIKA_PARSE_EXPECT_VALUE,KIKA_PARSE_INVALID_VALUE,KIKA_PARSE_ROOT_NOT_SINGULAR
};
(2)另一個是一個訪問結(jié)果的函數(shù),就是獲取其類型:
kika_type kika_get_type(const kika_value* v);
4. JSON 語法子集
下面是此單元的 JSON 語法子集,使用 RFC7159 中的 ABNF 表示:
JSON-text = ws value ws
ws = *(%x20 / %x09 / %x0A / %x0D)
value = null / false / true
null = "null"
false = "false"
true = "true"
當(dāng)中 %xhh
表示以 16 進制表示的字符,/
是多選一,*
是零或多個,()
用于分組。
第一行的意思是,JSON 文本由 3 部分組成,首先是空白(whitespace),接著是一個值,最后是空白。
第二行的意思是,所謂空白,是由零或多個空格符(space U+0020)、制表符(tab U+0009)、換行符(LF U+000A)、回車符(CR U+000D)所組成。
第三行的意思是,我們現(xiàn)時的值只可以是 null、false 或 true。
第四到六行指出了 null、false、 true 對應(yīng)的字面值(literal)。
我們的解析器應(yīng)能判斷輸入是否一個合法的 JSON。如果輸入的 JSON 不合符這個語法,我們要產(chǎn)生對應(yīng)的錯誤碼,方便使用者追查問題。
在這個 JSON 語法子集下,我們定義 3 種錯誤碼:
- 若一個 JSON 只含有空白,傳回
KIKA_PARSE_EXPECT_VALUE
。 - 若一個值之后,在空白之后還有其他字符,傳回
KIKA_PARSE_ROOT_NOT_SINGULAR
。 - 若值不是那三種字面值,傳回
KIKA_PARSE_INVALID_VALUE
。
5. 單元測試
許多初學(xué)者在做編程練習(xí)時,都是以 printf
/cout
打印結(jié)果,再用肉眼對比結(jié)果是否乎合預(yù)期。但當(dāng)軟件項目越來越復(fù)雜,這個做法會越來越低效。一般我們會采用自動的測試方式,例如單元測試(unit testing)。單元測試也能確保其他人修改代碼后,原來的功能維持正確(這稱為回歸測試/regression testing)。
常用的單元測試框架有 xUnit 系列,如 C++ 的 Google Test、C# 的 NUnit。我們?yōu)榱撕唵纹鹨?#xff0c;會編寫一個極簡單的單元測試方式。
一般來說,軟件開發(fā)是以周期進行的。例如,加入一個功能,再寫關(guān)于該功能的單元測試。但也有另一種軟件開發(fā)方法論,稱為測試驅(qū)動開發(fā)(test-driven development, TDD),它的主要循環(huán)步驟是:
- 加入一個測試。
- 運行所有測試,新的測試應(yīng)該會失敗。
- 編寫實現(xiàn)代碼。
- 運行所有測試,若有測試失敗回到3。
- 重構(gòu)代碼。
- 回到 1
TDD 是先寫測試,再實現(xiàn)功能。這樣的好處是,實現(xiàn)只會剛好滿足測試,而不會寫了一些不需要的代碼,或是沒有被測試的代碼。
但無論我們是采用 TDD,或是先實現(xiàn)后測試,都應(yīng)盡量加入足夠覆蓋率的單元測試。
回到 kikajson 項目,test.c
包含了一個極簡的單元測試框架:
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include "kikajson.h"static int main_ret = 0;
static int test_count = 0;
static int test_pass = 0;#define EXPECT_EQ_BASE(equality, expect, actual, format) \do {\test_count++;\if (equality)\test_pass++;\else {\fprintf(stderr, "%s:%d: expect: " format " actual: " format "\n", __FILE__, __LINE__, expect, actual);\main_ret = 1;\}\} while(0)#define EXPECT_EQ_INT(expect, actual) EXPECT_EQ_BASE((expect) == (actual), expect, actual, "%d")static void test_parse_null() {kika_value v;v.type = KIKA_TRUE;EXPECT_EQ_INT(KIKA_PARSE_OK, kika_parse(&v, "null"));EXPECT_EQ_INT(KIKA_NULL, kika_get_type(&v));
}/* ... */static void test_parse() {test_parse_null();/* ... */
}int main() {test_parse();printf("%d/%d (%3.2f%%) passed\n", test_pass, test_count, test_pass * 100.0 / test_count);return main_ret;
}
現(xiàn)時只提供了一個 EXPECT_EQ_INT(expect, actual)
的宏,每次使用這個宏時,如果 expect != actual(預(yù)期值不等于實際值),便會輸出錯誤信息。
若按照 TDD 的步驟,我們先寫一個測試,如上面的 test_parse_null()
,而 kika_parse()
只返回 KIKA_PARSE_OK
:
D:\rep\kikajson\test.c:27: expect: 0 actual: 1
1/2 (50.00%) passed
第一個測試因為 kika_parse()
返回 KIKA_PARSE_OK
,所以是通過的。
第二個測試因為 kika_parse()
沒有把 v.type
改成 KIKA_NULL
,造成失敗。我們再實現(xiàn) kika_parse()
令到它能通過測試。
然而,完全按照 TDD 的步驟來開發(fā),是會減慢開發(fā)進程。所以有時需要在這兩種極端的工作方式取平衡。一種做法是在設(shè)計 API 后,先寫部分測試代碼,再寫滿足那些測試的實現(xiàn)。
6. 宏的編寫技巧
關(guān)于 EXPECT_EQ_BASE
宏的編寫技巧,簡單說明一下。反斜線代表該行未結(jié)束,會串接下一行。而如果宏里有多過一個語句(statement),就需要用 do { /*...*/ } while(0)
包裹成單個語句,否則會有如下的問題:
#define M() a(); b()
if (cond)M();
elsec();/* 預(yù)處理后 */if (cond)a(); b(); /* b(); 在 if 之外 */
else /* <- else 缺乏對應(yīng) if */c();
只用 { }
也不行:
#define M() { a(); b(); }/* 預(yù)處理后 */if (cond){ a(); b(); }; /* 最后的分號代表 if 語句結(jié)束 */
else /* else 缺乏對應(yīng) if */c();
用 do while 就行了:
#define M() do { a(); b(); } while(0)/* 預(yù)處理后 */if (cond)do { a(); b(); } while(0);
elsec();
7. 實現(xiàn)解析器
有了 API 的設(shè)計、單元測試,終于要實現(xiàn)解析器了。
首先為了減少解析函數(shù)之間傳遞多個參數(shù),我們把這些數(shù)據(jù)都放進一個 kika_context
結(jié)構(gòu)體:
typedef struct {const char* json;
}kika_context;/* ... *//* 提示:這里應(yīng)該是 JSON-text = ws value ws */
/* 以下實現(xiàn)沒處理最后的 ws 和 KIKA_PARSE_ROOT_NOT_SINGULAR */
int kika_parse(kika_value* v, const char* json) {kika_context c;assert(v != NULL);c.json = json;v->type = KIKA_NULL;kika_parse_whitespace(&c);return kika_parse_value(&c, v);
}
暫時我們只儲存 json 字符串當(dāng)前位置,之后的單元我們需要加入更多內(nèi)容。
若 kika_parse()
失敗,會把 v
設(shè)為 null
類型,所以這里先把它設(shè)為 null
,讓 kika_parse_value()
寫入解析出來的根值。
kikajson 是一個手寫的遞歸下降解析器(recursive descent parser)。由于 JSON 語法特別簡單,我們不需要寫分詞器(tokenizer),只需檢測下一個字符,便可以知道它是哪種類型的值,然后調(diào)用相關(guān)的分析函數(shù)。對于完整的 JSON 語法,跳過空白后,只需檢測當(dāng)前字符:
- n ? null
- t ? true
- f ? false
- " ? string
- 0-9/- ? number
- [ ? array
- { ? object
所以,我們可以按照 JSON 語法一節(jié)的 EBNF 簡單翻譯成解析函數(shù):
#define EXPECT(c, ch) do { assert(*c->json == (ch)); c->json++; } while(0)/* ws = *(%x20 / %x09 / %x0A / %x0D) */
static void kika_parse_whitespace(kika_context* c) {const char *p = c->json;while (*p == ' ' || *p == '\t' || *p == '\n' || *p == '\r')p++;c->json = p;
}/* null = "null" */
static int kika_parse_null(kika_context* c, kika_value* v) {EXPECT(c, 'n');if (c->json[0] != 'u' || c->json[1] != 'l' || c->json[2] != 'l')return KIKA_PARSE_INVALID_VALUE;c->json += 3;v->type = KIKA_NULL;return KIKA_PARSE_OK;
}/* value = null / false / true */
/* 提示:下面代碼沒處理 false / true,將會是練習(xí)之一 */
static int kika_parse_value(kika_context* c, kika_value* v) {switch (*c->json) {case 'n': return kika_parse_null(c, v);case '\0': return KIKA_PARSE_EXPECT_VALUE;default: return KIKA_PARSE_INVALID_VALUE;}
}
由于 kika_parse_whitespace()
是不會出現(xiàn)錯誤的,返回類型為 void
。其它的解析函數(shù)會返回錯誤碼,傳遞至頂層。
8. 關(guān)于斷言
斷言(assertion)是 C 語言中常用的防御式編程方式,減少編程錯誤。最常用的是在函數(shù)開始的地方,檢測所有參數(shù)。有時候也可以在調(diào)用函數(shù)后,檢查上下文是否正確。
C 語言的標(biāo)準(zhǔn)庫含有 assert() 這個宏(需 #include <assert.h>
),提供斷言功能。當(dāng)程序以 release 配置編譯時(定義了 NDEBUG
宏),assert()
不會做檢測;而當(dāng)在 debug 配置時(沒定義 NDEBUG
宏),則會在運行時檢測 assert(cond)
中的條件是否為真(非 0),斷言失敗會直接令程序崩潰。
例如上面的 kika_parse_null()
開始時,當(dāng)前字符應(yīng)該是 'n'
,所以我們使用一個宏 EXPECT(c, ch)
進行斷言,并跳到下一字符。
初使用斷言的同學(xué),可能會錯誤地把含副作用的代碼放在 assert()
中:
assert(x++ == 0); /* 這是錯誤的! */
這樣會導(dǎo)致 debug 和 release 版的行為不一樣。
另一個問題是,初學(xué)者可能會難于分辨何時使用斷言,何時使用運行時錯誤(如返回錯誤值或在 C++ 中拋出異常)。簡單的答案是,如果那個錯誤是由于程序員錯誤編碼所造成的(例如傳入不合法的參數(shù)),那么應(yīng)用斷言;如果那個錯誤是程序員無法避免,而是由運行時的環(huán)境所造成的,就要處理運行時錯誤(例如開啟文件失敗)。
9. 總結(jié)與練習(xí)
本文介紹了如何配置一個編程環(huán)境,單元測試的重要性,以至于一個 JSON 解析器的子集實現(xiàn)。以下是本單元的練習(xí),解答在 01-exercise 中。
- 修正關(guān)于
KIKA_PARSE_ROOT_NOT_SINGULAR
的單元測試,若 json 在一個值之后,空白之后還有其它字符,則要返回KIKA_PARSE_ROOT_NOT_SINGULAR
。 - 參考
test_parse_null()
,加入test_parse_true()
、test_parse_false()
單元測試。 - 參考
kika_parse_null()
的實現(xiàn)和調(diào)用方法,解析 true 和 false 值。
10. 常見問答
1… 為什么使用宏而不用函數(shù)或內(nèi)聯(lián)函數(shù)?
因為這個測試框架使用了 __LINE__
這個編譯器提供的宏,代表編譯時該行的行號。如果用函數(shù)或內(nèi)聯(lián)函數(shù),每次的行號便都會相同。另外,內(nèi)聯(lián)函數(shù)是 C99 的新增功能,本框架使用 C89。
其他常見問答將會從評論中整理。