標籤:

從零開始的 JSON 庫教程(二):解析數字

本文是《從零開始的 JSON 庫教程》的第二個單元。本單元的源代碼位於 json-tutorial/tutorial02。

本單元內容:

  1. 初探重構

  2. JSON 數字語法

  3. 數字表示方式

  4. 單元測試

  5. 十進位轉換至二進位

  6. 總結與練習

  7. 參考

  8. 常見問題

(題圖 Photo by Tim Evans)

1. 初探重構

在討論解析數字之前,我們再補充 TDD 中的一個步驟──重構(refactoring)。根據[1],重構是一個這樣的過程:

在不改變代碼外在行為的情況下,對代碼作出修改,以改進程序的內部結構。

在 TDD 的過程中,我們的目標是編寫代碼去通過測試。但由於這個目標的引導性太強,我們可能會忽略正確性以外的軟體品質。在通過測試之後,代碼的正確性得以保證,我們就應該審視現時的代碼,看看有沒有地方可以改進,而同時能維持測試順利通過。我們可以安心地做各種修改,因為我們有單元測試,可以判斷代碼在修改後是否影響原來的行為。

那麼,哪裡要作出修改?Beck 和 Fowler([1] 第 3 章)認為程序員要培養一種判斷能力,找出程序中的壞味道。例如,在第一單元的練習中,可能大部分人都會複製 lept_parse_null() 的代碼,作一些修改,成為 lept_parse_true() 和lept_parse_false()。如果我們再審視這 3 個函數,它們非常相似。這違反編程中常說的 DRY(dont repeat yourself)原則。本單元的第一個練習題,就是嘗試合併這 3 個函數。

另外,我們也可能發現,單元測試代碼也有很重複的代碼,例如 test_parse_invalid_value() 中我們每次測試一個不合法的 JSON 值,都有 4 行相似的代碼。我們可以把它用宏的方式把它們簡化:

#define TEST_ERROR(error, json)n do {n lept_value v;n v.type = LEPT_FALSE;n EXPECT_EQ_INT(error, lept_parse(&v, json));n EXPECT_EQ_INT(LEPT_NULL, lept_get_type(&v));n } while(0)nnstatic void test_parse_expect_value() {n TEST_ERROR(LEPT_PARSE_EXPECT_VALUE, "");n TEST_ERROR(LEPT_PARSE_EXPECT_VALUE, " ");n}n

最後,我希望指出,軟體的架構難以用單一標準評分,重構時要考慮平衡各種軟體品質。例如上述把 3 個函數合併後,優點是減少重複的代碼,維護較容易,但缺點可能是帶來性能的少量影響。

2. JSON 數字語法

回歸正題,本單元的重點在於解析 JSON number 類型。我們先看看它的語法:

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

number 是以十進位表示,它主要由 4 部分順序組成:負號、整數、小數、指數。只有整數是必需部分。注意和直覺可能不同的是,正號是不合法的。

整數部分如果是 0 開始,只能是單個 0;而由 1-9 開始的話,可以加任意數量的數字(0-9)。也就是說,0123 不是一個合法的 JSON 數字。

小數部分比較直觀,就是小數點後是一或多個數字(0-9)。

JSON 可使用科學記數法,指數部分由大寫 E 或小寫 e 開始,然後可有正負號,之後是一或多個數字(0-9)。

JSON 標準 ECMA-404 採用圖的形式表示語法,也可以更直觀地看到解析時可能經過的路徑:

上一單元的 null、false、true 在解析後,我們只需把它們存儲為類型。但對於數字,我們要考慮怎麼存儲解析後的結果。

3. 數字表示方式

從 JSON 數字的語法,我們可能直觀地會認為它應該表示為一個浮點數(floating point number),因為它帶有小數和指數部分。然而,標準中並沒有限制數字的範圍或精度。為簡單起見,leptjson 選擇以雙精度浮點數(C 中的 double 類型)來存儲 JSON 數字。

我們為 lept_value 添加成員:

typedef struct {n double n;n lept_type type;n}lept_value;n

僅當 type == LEPT_NUMBER 時,n 才表示 JSON 數字的數值。所以獲取該值的 API 是這麼實現的:

double lept_get_number(const lept_value* v) {n assert(v != NULL && v->type == LEPT_NUMBER);n return v->n;n}n

使用者應確保類型正確,才調用此 API。我們繼續使用斷言來保證。

4. 單元測試

我們定義了 API 之後,按照 TDD,我們可以先寫一些單元測試。這次我們使用多行的宏的減少重複代碼:

#define TEST_NUMBER(expect, json)n do {n lept_value v;n EXPECT_EQ_INT(LEPT_PARSE_OK, lept_parse(&v, json));n EXPECT_EQ_INT(LEPT_NUMBER, lept_get_type(&v));n EXPECT_EQ_DOUBLE(expect, lept_get_number(&v));n } while(0)nnstatic void test_parse_number() {n TEST_NUMBER(0.0, "0");n TEST_NUMBER(0.0, "-0");n TEST_NUMBER(0.0, "-0.0");n TEST_NUMBER(1.0, "1");n TEST_NUMBER(-1.0, "-1");n TEST_NUMBER(1.5, "1.5");n TEST_NUMBER(-1.5, "-1.5");n TEST_NUMBER(3.1416, "3.1416");n TEST_NUMBER(1E10, "1E10");n TEST_NUMBER(1e10, "1e10");n TEST_NUMBER(1E+10, "1E+10");n TEST_NUMBER(1E-10, "1E-10");n TEST_NUMBER(-1E10, "-1E10");n TEST_NUMBER(-1e10, "-1e10");n TEST_NUMBER(-1E+10, "-1E+10");n TEST_NUMBER(-1E-10, "-1E-10");n TEST_NUMBER(1.234E+10, "1.234E+10");n TEST_NUMBER(1.234E-10, "1.234E-10");n TEST_NUMBER(0.0, "1e-10000"); /* must underflow */n}n

以上這些都是很基本的測試用例,也可供調試用。大部分情況下,測試案例不能窮舉所有可能性。因此,除了加入一些典型的用例,我們也常會使用一些邊界值,例如最大值等。練習中會讓同學找一些邊界值作為用例。

除了這些合法的 JSON,我們也要寫一些不合語法的用例:

static void test_parse_invalid_value() {n /* ... */n /* invalid number */n TEST_ERROR(LEPT_PARSE_INVALID_VALUE, "+0");n TEST_ERROR(LEPT_PARSE_INVALID_VALUE, "+1");n TEST_ERROR(LEPT_PARSE_INVALID_VALUE, ".123"); /* at least one digit before . */n TEST_ERROR(LEPT_PARSE_INVALID_VALUE, "1."); /* at least one digit after . */n TEST_ERROR(LEPT_PARSE_INVALID_VALUE, "INF");n TEST_ERROR(LEPT_PARSE_INVALID_VALUE, "inf");n TEST_ERROR(LEPT_PARSE_INVALID_VALUE, "NAN");n TEST_ERROR(LEPT_PARSE_INVALID_VALUE, "nan");n}n

5. 十進位轉換至二進位

我們需要把十進位的數字轉換成二進位的 double。這並不是容易的事情 [2]。為了簡單起見,leptjson 將使用標準庫的strtod() 來進行轉換。strtod() 可轉換 JSON 所要求的格式,但問題是,一些 JSON 不容許的格式,strtod() 也可轉換,所以我們需要自行做格式校驗。

#include <stdlib.h> /* NULL, strtod() */nnstatic int lept_parse_number(lept_context* c, lept_value* v) {n char* end;n /* TODO validate number */n v->n = strtod(c->json, &end);n if (c->json == end)n return LEPT_PARSE_INVALID_VALUE;n c->json = end;n v->type = LEPT_NUMBER;n return LEPT_PARSE_OK;n}n

加入了 number 後,value 的語法變成:

value = null / false / true / numbern

記得在第一單元中,我們說可以用一個字元就能得知 value 是什麼類型,有 11 個字元可判斷 number:

  • 0-9/- ? number

但是,由於我們在 lept_parse_number() 內部將會校驗輸入是否正確的值,我們可以簡單地把餘下的情況都交給lept_parse_number():

static int lept_parse_value(lept_context* c, lept_value* v) {n switch (*c->json) {n case t: return lept_parse_true(c, v);n case f: return lept_parse_false(c, v);n case n: return lept_parse_null(c, v);n default: return lept_parse_number(c, v);n case 0: return LEPT_PARSE_EXPECT_VALUE;n }n}n

6. 總結與練習

本單元講述了 JSON 數字類型的語法,以及 leptjson 所採用的自行校驗+strtod()轉換為 double 的方案。實際上一些 JSON 庫會採用更複雜的方案,例如支持 64 位帶符號/無符號整數,自行實現轉換。以我的個人經驗,解析/生成數字類型可以說是 RapidJSON 中最難實現的部分,也是 RapidJSON 高效性能的原因,有機會再另外撰文解釋。

此外我們談及,重構與單元測試是互相依賴的軟體開發技術,適當地運用可提升軟體的品質。之後的單元還會有相關的話題。

  1. 重構合併 lept_parse_null()、lept_parse_false()、lept_parse_true 為 lept_parse_literal()。
  2. 加入 維基百科雙精度浮點數 的一些邊界值至單元測試,如 min subnormal positive double、max double 等。
  3. 去掉 test_parse_invalid_value() 和 test_parse_root_not_singular 中的 #if 0 ... #endif,執行測試,證實測試失敗。按 JSON number 的語法在 lept_parse_number() 校驗,不符合標準的程況返回LEPT_PARSE_INVALID_VALUE 錯誤碼。
  4. 去掉 test_parse_number_too_big 中的 #if 0 ... #endif,執行測試,證實測試失敗。仔細閱讀 strtod(),看看怎樣從返回值得知數值是否過大,以返回 LEPT_PARSE_NUMBER_TOO_BIG 錯誤碼。(提示:這裡需要 #include 額外兩個標準庫頭文件。)

以上最重要的是第 3 條題目,就是要校驗 JSON 的數字語法。建議可使用以下兩個宏去簡化一下代碼:

#define ISDIGIT(ch) ((ch) >= 0 && (ch) <= 9)n#define ISDIGIT1TO9(ch) ((ch) >= 1 && (ch) <= 9)n

另一提示,在校驗成功以後,我們不再使用 end 指針去檢測 strtod() 的正確性,第二個參數可傳入 NULL。

如果你遇到問題,有不理解的地方,或是有建議,都歡迎在評論或 issue 中提出,讓所有人一起討論。

7. 參考

[1] Fowler, Martin. Refactoring: improving the design of existing code. Pearson Education India, 2009. 中譯本:《重構:改善既有代碼的設計》,熊節譯,人民郵電出版社,2010年。 [2] Gay, David M. "Correctly rounded binary-decimal and decimal-binary conversions." Numerical Analysis Manuscript 90-10 (1990).

8. 常見問題

  1. 為什麼要把一些測試代碼以 #if 0 ... #endif 禁用?

    因為在做第 1 個練習題時,我希望能 100% 通過測試,方便做重構。另外,使用 #if 0 ... #endif 而不使用 /* ... */,是因為 C 的注釋不支持嵌套(nested),而 #if ... #endif 是支持嵌套的。代碼中已有注釋時,用 #if 0 ... #endif 去禁用代碼是一個常用技巧,而且可以把 0 改為 1 去恢復。

  2. 科學計數法的指數部分沒有對前導零作限制嗎?1E012 也是合法的嗎?

    是的,這是合法的。JSON 源自於 JavaScript(ECMA-262, 3rd edition),數字語法取自 JavaScript 的十進位數字的語法(§7.8.3 Numeric Literals)。整數不容許前導零(leading zero),是因為更久的 JavaScript 版本容許以前導零來表示八進位數字,如 052 == 42,這種八進位常數表示方式來自於 C 語言。禁止前導零避免了可能出現的歧義。但是在指數里就不會出現這個問題。多謝 @Smallay 提出及協助解答這個問題。

其他常見問答將會從評論中整理。


推薦閱讀:

菜鳥數據科學入門03 - NumPy 數組基礎和基本操作
Sketch #0 如何只用方/圓/線和快捷鍵在Sketch里畫插畫
10節課教會你攝影

TAG:JSON | CC | 教程 |