面試題|手寫JSON解析器

這周的 Cassidoo 的每週簡訊有這麼一個面試題::javascript

寫一個函數,這個函數接收一個正確的 JSON 字符串並將其轉化爲一個對象(或字典,映射等,這取決於你選擇的語言)。示例輸入:html

fakeParseJSON('{ "data": { "fish": "cake", "array": [1,2,3], "children": [ { "something": "else" }, { "candy": "cane" }, { "sponge": "bob" } ] } } ')
複製代碼

當時,我想這麼寫:java

const fakeParseJSON = JSON.parse;
複製代碼

可是,我想起以前寫了一些關於AST的文章,git

其中涵蓋了編譯器管道的概述以及如何操做AST,可是我沒有過多介紹如何實現解析器。由於實現JavaScript編譯器對我來講是一項艱鉅的任務。github

那就不必擔憂。 JSON也是一種語言,有本身的語法,能夠參考規範。 根據編寫JSON解析器所需的知識和技術轉移到編寫JS解析器中。面試

好了,那就開始編寫一個JSON解析器吧。json

語法

查看規範文檔頁面,能夠看到如下兩個圖。bash

image

json
  element

value
  object
  array
  string
  number
  "true"
  "false"
  "null"

object
  '{' ws '}'
  '{' members '}'
複製代碼

兩個圖實際上是等價的。babel

一個基於視覺,一個基於文本。基於文本語法的語法 —— 巴科斯-諾爾範式,一般被提供給另外一個解析這種語法併爲其生成解析器的解析器,終於說到解析器了!🤯框架

在這篇文章中,咱們重點關注鐵路圖上,由於它是可視化的,看起來更友好。

先來看下第一張的鐵路圖:

image

因此這就是JSON中「object」的語法。

從左側開始,沿着箭頭的方向走,一直到右側爲止。

圓圈裏面是一個字符,例如 {,:},矩形裏面是其它語法的佔位符,例如 whitespace(空格)stringvalue。所以要解析"whitespace",咱們須要查閱"whitepsace"語法。

所以,對於一個對象而言,從左邊開始,第一個字符必須是一個左花括號 {,而後往下走會有兩種狀況:

  • whitespace} → 結束
  • whitespacestringwhitespace:value} → 結束

固然當抵達value的時候,你能夠選擇繼續下去:

  • } → 結束,或者
  • ,whitespace → … → value 你能夠繼續循環,直到你決定去:
  • } → 結束。 如今已經熟悉了鐵路圖,咱們繼續到下一節。

實現解析器

下面咱們開始編寫代碼,代碼結構以下:

function fakeParseJSON(str) {
  let i = 0;
  // TODO
}
複製代碼

初始化 i 將其做爲當前字符的索引值,只要 i 值到達 str 的長度,咱們就會結束函數。

後面咱們來實現「object」語法:

function fakeParseJSON(str) {
  let i = 0;
  function parseObject() {
    if (str[i] === '{') {
      i++;
      skipWhitespace();

      // 若是不是 '}',
      // 咱們接收 string -> whitespace -> ':' -> value -> ... 這樣的路徑字符串
      while (str[i] !== '}') {
        const key = parseString();
        skipWhitespace();
        eatColon();
        const value = parseValue();
      }
    }
  }
}
複製代碼

咱們能夠調用 parseObject 來解析相似string和whitespace之類的語法,只要咱們實現這些功能,一切都解決了🤞。

還有就是我我忘記加逗號了。逗號只會出如今開始第二次whitespacestringwhitespace: → … 循環以前。

在這個基礎上,咱們加上了一下幾行:

function fakeParseJSON(str) {
  let i = 0;
  function parseObject() {
    if (str[i] === '{') {
      i++;
      skipWhitespace();

      let initial = true;
      // 若是不是 '}',
      // 就按照這樣的路徑執行: string -> whitespace -> ':' -> value -> ...
      while (str[i] !== '}') {
        if (!initial) {
          eatComma();
          skipWhitespace();
        }
        const key = parseString();
        skipWhitespace();
        eatColon();
        const value = parseValue();
        initial = false;
      }
      // move to the next character of '}'
      i++;
    }
  }
}
複製代碼

一些命名上的約定:

  • 當咱們根據語法解析代碼並使用返回值時,命名爲parseSomething
  • 當咱們指望字符在那裏,可是咱們沒有使用字符時,命名爲eatSomething
  • 當字符不存在,咱們也能夠接受。命名skipSomething

下面來實現eatCommaeatColon

function fakeParseJSON(str) {
  // ...
  function eatComma() {
    if (str[i] !== ',') {
      throw new Error('Expected ",".');
    }
    i++;
  }

  function eatColon() {
    if (str[i] !== ':') {
      throw new Error('Expected ":".');
    }
    i++;
  }
}
複製代碼

到目前爲止,咱們實現了parseObject的語法,可是這個解析函數的返回值是什麼呢?

不錯,咱們須要返回一個JavaScript對象:

function fakeParseJSON(str) {
  let i = 0;
  function parseObject() {
    if (str[i] === '{') {
      i++;
      skipWhitespace();

      const result = {};

      let initial = true;
      // 若是不是 '}',
      // 就按照這樣的路徑執行: string -> whitespace -> ':' -> value -> ...
      while (str[i] !== '}') {
        if (!initial) {
          eatComma();
          skipWhitespace();
        }
        const key = parseString();
        skipWhitespace();
        eatColon();
        const value = parseValue();
        result[key] = value;
        initial = false;
      }
      // 移動到下一個字符 '}' 
      i++;

      return result;
    }
  }
}
複製代碼

如今你已經看到我怎麼去實現「object「語法,如今是時候讓你嘗試一下」array「語法了:

images

function fakeParseJSON(str) {
  // ...
  function parseArray() {
    if (str[i] === '[') {
      i++;
      skipWhitespace();

      const result = [];
      let initial = true;
      while (str[i] !== ']') {
        if (!initial) {
          eatComma();
        }
        const value = parseValue();
        result.push(value);
        initial = false;
      }
      // 移動到下一個字符 ']'
      i++;
      return result;
    }
  }
}
複製代碼

如今,咱們來看一個更有趣的語法,「value」:

image
如上圖的路徑: 一個值是以「whitespace」開始,而後接着是如下類型的一種:「string」,「number」,「object」,「array」,「true」,「false」 或者null,最後以一個「whitespace」結束。

function fakeParseJSON(str) {
  // ...
  function parseValue() {
    skipWhitespace();
    const value =
      parseString() ??
      parseNumber() ??
      parseObject() ??
      parseArray() ??
      parseKeyword('true', true) ??
      parseKeyword('false', false) ??
      parseKeyword('null', null);
    skipWhitespace();
    return value;
  }
}
複製代碼

這個??叫作空值合併運算符,它相似咱們用來設置默認值 foo || default 中的 ||,只要foo是假值,|| 就會返回 default, 而空值合併運算符只會在 foo 爲 null 或 undefined 時返回 default。能夠看個例子:

const foo = null ?? 'default string';
console.log(foo);
// 輸出: "default string"

複製代碼

parseKeyword 將檢查當前 str.slice(i) 是否與關鍵字字符串匹配,若是匹配,將返回關鍵字值:

function fakeParseJSON(str) {
  // ...
  function parseKeyword(name, value) {
    if (str.slice(i, i + name.length) === name) {
      i += name.length;
      return value;
    }
  }
}
複製代碼

這個就是parseKeyword的實現。

咱們還有 3 個以上的語法要實現,但我爲了控制文章篇幅,在下面的 CodeSandbox 中實現這些語法。 CodeSandbox

完成全部語法實現以後,而後返回由parseValue返回的json值:

function fakeParseJSON(str) {
  let i = 0;
  return parseValue();

  // ...
}
複製代碼

就是這樣!

好了,尚未那麼快完成朋友,咱們只是完成的理想的部分,那麼非理想的部分呢?

處理異常輸入

做爲一個優秀的開發人員,咱們也須要優雅地處理非理想狀況。對於解析器,這意味着使用適當的錯誤消息大聲警告開發人員。

讓咱們來處理兩個最多見的錯誤狀況:

  • Unexpected token
  • Unexpected end of string

在全部的 while 循環中,例如 parseObject 中的 while 循環:

function fakeParseJSON(str) {
  // ...
  function parseObject() {
    // ...
    while(str[i] !== '}') {
複製代碼

咱們須要確保訪問的字符不會超過字符串的長度。這發生在字符串意外結束時,而咱們仍然在等待一個結束字符 —— }。好比說下面的例子:

function fakeParseJSON(str) {
  // ...
  function parseObject() {
    // ...
    while (i < str.length && str[i] !== '}') {
      // ...
    }
    checkUnexpectedEndOfInput();

    // move to the next character of '}'
    i++;

    return result;
  }
}
複製代碼

更好的優化

你還記得當你仍是一個初級開發者的時候,每次遇到一些不清晰的語法錯誤的時候,你徹底不知道哪裏出問題了?

如今你有經驗了,是時候中止這種惡性循環和吐槽了。

Unexpected token "a"
複製代碼

例如以上的錯誤,只會讓用戶很困惑地盯着屏幕,而不知道錯誤在哪裏。

相比去吐槽,其實有不少更好的方式去改善這些錯誤提示,下面有幾點建議能夠考慮加到解析器裏面:

錯誤碼和標準錯誤信息

標準關鍵字對用戶谷歌尋求幫助頗有用

// 很差的提示
Unexpected token "a"
Unexpected end of input

// 好的提示
JSON_ERROR_001 Unexpected token "a"
JSON_ERROR_002 Unexpected end of input
複製代碼

更好地查看哪裏出問題

像 Babel 這樣的解析器,會向你顯示一個代碼框架,它是一個帶有下劃線、箭頭或突出顯示錯誤的代碼片斷

// 很差的提示
Unexpected token "a" at position 5

// 好的提示
{ "b"a
      ^
JSON_ERROR_001 Unexpected token "a"
複製代碼

一個如何輸出代碼片斷的例子:

function fakeParseJSON(str) {
  // ...
  function printCodeSnippet() {
    const from = Math.max(0, i - 10);
    const trimmed = from > 0;
    const padding = (trimmed ? 3 : 0) + (i - from);
    const snippet = [
      (trimmed ? '...' : '') + str.slice(from, i + 1),
      ' '.repeat(padding) + '^',
      ' '.repeat(padding) + message,
    ].join('\n');
    console.log(snippet);
  }
}
複製代碼

修正錯誤建議

能夠的話,能夠說明是哪裏出問題以及給出修復建議。

// 很差的提示
Unexpected token "a" at position 5

// 好的提示
{ "b"a
      ^
JSON_ERROR_001 Unexpected token "a".
Expecting a ":" over here, eg:
{ "b": "bar" }
      ^
You can learn more about valid JSON string in http://goo.gl/xxxxx
複製代碼

若是可能,根據解析器目前收集的上下文提供建議

fakeParseJSON('"Lorem ipsum');

// instead of
Expecting a `"` over here, eg: "Foo Bar" ^ // show Expecting a `"` over here, eg:
"Lorem ipsum"
            ^
複製代碼

基於上下文的建議會讓人感受更有關聯性和可操做性。 記住全部的建議,用如下幾點檢查已經更新的CodeSandbox

  • 有意義的錯誤消息
  • 帶有錯誤指向失敗點的代碼段
  • 爲錯誤恢復提供建議

推薦閱讀Evan Czaplicki的關於如何提升編譯器用戶體驗的一篇文章「編譯器錯誤建議

完整代碼:

function fakeParseJSON(str) {
  let i = 0;

  const value = parseValue();
  expectEndOfInput();
  return value;

  function parseObject() {
    if (str[i] === "{") {
      i++;
      skipWhitespace();

      const result = {};

      let initial = true;
      // if it is not '}',
      // we take the path of string -> whitespace -> ':' -> value -> ...
      while (i < str.length && str[i] !== "}") {
        if (!initial) {
          eatComma();
          skipWhitespace();
        }
        const key = parseString();
        if (key === undefined) {
          expectObjectKey();
        }
        skipWhitespace();
        eatColon();
        const value = parseValue();
        result[key] = value;
        initial = false;
      }
      expectNotEndOfInput("}");
      // move to the next character of '}'
      i++;

      return result;
    }
  }

  function parseArray() {
    if (str[i] === "[") {
      i++;
      skipWhitespace();

      const result = [];
      let initial = true;
      while (i < str.length && str[i] !== "]") {
        if (!initial) {
          eatComma();
        }
        const value = parseValue();
        result.push(value);
        initial = false;
      }
      expectNotEndOfInput("]");
      // move to the next character of ']'
      i++;
      return result;
    }
  }

  function parseValue() {
    skipWhitespace();
    const value =
      parseString() ??
      parseNumber() ??
      parseObject() ??
      parseArray() ??
      parseKeyword("true", true) ??
      parseKeyword("false", false) ??
      parseKeyword("null", null);
    skipWhitespace();
    return value;
  }

  function parseKeyword(name, value) {
    if (str.slice(i, i + name.length) === name) {
      i += name.length;
      return value;
    }
  }

  function skipWhitespace() {
    while (
      str[i] === " " ||
      str[i] === "\n" ||
      str[i] === "\t" ||
      str[i] === "\r"
    ) {
      i++;
    }
  }

  function parseString() {
    if (str[i] === '"') {
      i++;
      let result = "";
      while (i < str.length && str[i] !== '"') {
        if (str[i] === "\\") {
          const char = str[i + 1];
          if (
            char === '"' ||
            char === "\\" ||
            char === "/" ||
            char === "b" ||
            char === "f" ||
            char === "n" ||
            char === "r" ||
            char === "t"
          ) {
            result += char;
            i++;
          } else if (char === "u") {
            if (
              isHexadecimal(str[i + 2]) &&
              isHexadecimal(str[i + 3]) &&
              isHexadecimal(str[i + 4]) &&
              isHexadecimal(str[i + 5])
            ) {
              result += String.fromCharCode(
                parseInt(str.slice(i + 2, i + 6), 16)
              );
              i += 5;
            } else {
              i += 2;
              expectEscapeUnicode(result);
            }
          } else {
            expectEscapeCharacter(result);
          }
        } else {
          result += str[i];
        }
        i++;
      }
      expectNotEndOfInput('"');
      i++;
      return result;
    }
  }

  function isHexadecimal(char) {
    return (
      (char >= "0" && char <= "9") ||
      (char.toLowerCase() >= "a" && char.toLowerCase() <= "f")
    );
  }

  function parseNumber() {
    let start = i;
    if (str[i] === "-") {
      i++;
      expectDigit(str.slice(start, i));
    }
    if (str[i] === "0") {
      i++;
    } else if (str[i] >= "1" && str[i] <= "9") {
      i++;
      while (str[i] >= "0" && str[i] <= "9") {
        i++;
      }
    }

    if (str[i] === ".") {
      i++;
      expectDigit(str.slice(start, i));
      while (str[i] >= "0" && str[i] <= "9") {
        i++;
      }
    }
    if (str[i] === "e" || str[i] === "E") {
      i++;
      if (str[i] === "-" || str[i] === "+") {
        i++;
      }
      expectDigit(str.slice(start, i));
      while (str[i] >= "0" && str[i] <= "9") {
        i++;
      }
    }
    if (i > start) {
      return Number(str.slice(start, i));
    }
  }

  function eatComma() {
    expectCharacter(",");
    i++;
  }

  function eatColon() {
    expectCharacter(":");
    i++;
  }

  // error handling
  function expectNotEndOfInput(expected) {
    if (i === str.length) {
      printCodeSnippet(`Expecting a \`${expected}\` here`);
      throw new Error("JSON_ERROR_0001 Unexpected End of Input");
    }
  }

  function expectEndOfInput() {
    if (i < str.length) {
      printCodeSnippet("Expecting to end here");
      throw new Error("JSON_ERROR_0002 Expected End of Input");
    }
  }

  function expectObjectKey() {
    printCodeSnippet(`Expecting object key here

For example:
{ "foo": "bar" }
  ^^^^^`);
    throw new Error("JSON_ERROR_0003 Expecting JSON Key");
  }

  function expectCharacter(expected) {
    if (str[i] !== expected) {
      printCodeSnippet(`Expecting a \`${expected}\` here`);
      throw new Error("JSON_ERROR_0004 Unexpected token");
    }
  }

  function expectDigit(numSoFar) {
    if (!(str[i] >= "0" && str[i] <= "9")) {
      printCodeSnippet(`JSON_ERROR_0005 Expecting a digit here

For example:
${numSoFar}5
${" ".repeat(numSoFar.length)}^`);
      throw new Error("JSON_ERROR_0006 Expecting a digit");
    }
  }

  function expectEscapeCharacter(strSoFar) {
    printCodeSnippet(`JSON_ERROR_0007 Expecting escape character

For example:
"${strSoFar}\\n"
${" ".repeat(strSoFar.length + 1)}^^
List of escape characters are: \\", \\\\, \\/, \\b, \\f, \\n, \\r, \\t, \\u`); throw new Error("JSON_ERROR_0008 Expecting an escape character"); } function expectEscapeUnicode(strSoFar) { printCodeSnippet(`Expect escape unicode For example: "${strSoFar}\\u0123
${" ".repeat(strSoFar.length + 1)}^^^^^^`);
    throw new Error("JSON_ERROR_0009 Expecting an escape unicode");
  }

  function printCodeSnippet(message) {
    const from = Math.max(0, i - 10);
    const trimmed = from > 0;
    const padding = (trimmed ? 4 : 0) + (i - from);
    const snippet = [
      (trimmed ? "... " : "") + str.slice(from, i + 1),
      " ".repeat(padding) + "^",
      " ".repeat(padding) + message
    ].join("\n");
    console.log(snippet);
  }
}

// console.log("Try uncommenting the fail cases and see their error message");
// console.log("↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓");

// Fail cases:
printFailCase("-");
printFailCase("-1.");
printFailCase("1e");
printFailCase("-1e-2.2");
printFailCase("{");
printFailCase("{}{");
printFailCase('{"a"');
printFailCase('{"a": "b",');
printFailCase('{"a":"b""c"');
printFailCase('{"a":"foo\\}');
printFailCase('{"a":"foo\\u"}');
printFailCase("[");
printFailCase("[][");
printFailCase("[[]");
printFailCase('["]');

function printFailCase(json) {
  try {
    console.log(`fakeParseJSON('${json}')`);
    fakeParseJSON(json);
  } catch (error) {
    console.error(error);
  }
}

複製代碼

總結

要實現解析器,你須要從語法開始。 你能夠用鐵路圖或巴科斯-諾爾範式來使語法正式化。設計語法是最困難的一步。

一旦你解決了語法問題,就能夠開始基於語法實現解析器。

錯誤處理很重要,更重要的是要有有意義的錯誤消息,以便用戶知道如何修復它。

如今,你已經瞭解瞭如何實現簡單的解析器,如今應該關注更復雜的解析器了:

最後,請關注 @cassidoo,她的每週簡訊棒極了。 (完)

以上譯文僅用於學習交流,水平有限,不免有錯誤之處,敬請指正。若是以爲文章對你有幫助,請點個贊吧。

參考

相關文章
相關標籤/搜索