從 0 開始實現程式語言(一):手寫 jsonParser

zhm1992發表於2020-06-24

簡介

本篇作為先導將用一個簡單的例子闡述詞法分析和語法分析階段的任務和實現。我們的任務是用php實現一個json_decode函式作為入門parser的例子,與json_decode函式不同的是,我們實現的函式會增加對註釋的支援,並且為了降低部分沒有必要的複雜度 ,我們將不支援浮點/布林/null型別的資料。如:

{"a", 1} => ["a", 1]
{"a": {"b": [1, 2], "c": {"e": "f"}}} => ["a" => ["b" => [1, 2]], "c" => ["e" => "f"]]
{
    "a": "b" // comments
}
=> ["a": "b"]

為了完成這個工作,我們需要把任務先拆解,分為兩部分:詞法分析器和語法分析器。雖然說實現一個json parser分成兩個部分是沒必要的,但是待會兒你就能看到拆解完的實現有多簡單。

詞法分析器的實現

詞法分析器的作用是將輸入流轉化為一個個token,在我們這裡,輸入就是json,輸出就是一個個token,一個token就是一個單元。比如說:

$a = $b + 1

轉化為token:

token1. type: ID, value: a
token2. type: ASSIGN, value:
token3. type: ID, value: b
token4. type: MINUS, value: 
token4. type: NUMBER, value: 1

每個token可以看作是一個帶有type和value屬性的物件,type用來表示他的型別,value則表示他真正的值。比如說在這裡,$a首先是一個ID型別的token,但是隻有一個type來代表它是不夠的,因為變數名可能不一樣,所以還需要一個value屬性來儲存它真正的名字,同理,數字型別的token也是如此,1被表示成了一個type為number,value為1的token。

回到我們的parser中,現在的目標是將json字串轉化為這樣的token,先來看看json有哪些token:

{ "a" : "b", "c": [ 1 ] } // comments

我們拆一下,有這麼幾種:

{
:
,
[
]
}
// 
STRING
NUMBER

{ } [ ] , :這幾個都是會出現在json中的特殊符號,我們把每一個都當成一種token。// 則是我們為了支援註釋增加的token,STRING型別的token則代表用雙引號包裹的字串,NUMBER則是代表數字。有了這些分類之後,我們可以開始動手實現詞法分析程式了。

首先,定義一個lexer類。一般的,我們都會叫詞法分析器lexer或scanner之類的:

// Lexer.php
class Lexer
{
    private $input; // 輸入的字串

    public function __construct(string $json)
    {
        $this->input = $json;
    }
}

為了讓程式實現更簡單,我們增加一些輔助方法和屬性

class Lexer
{
    private $input; // 輸入的字串

    private $pos = -1;  // 指向當前要讀取字元的位置

    private $c; // 當前的字元

    const EOF = -1; 

    public function nextToken(): array
    {
        // todo
        return [];
    }
}

我們將輸入作為一個流,每次呼叫nextToken方法就會從這個流裡面讀取一個token,同時指標向後移動,讀到結尾則返回EOF。pos和c屬性用來表示當前讀到的位置和字元,EOF表示已經到結尾了。

private function readChar()
{
    if ($this->pos + 1 >= strlen($this->input)) {
        $this->c = self::EOF;
    } else {
        $this->c = $this->input[++$this->pos];
    }
}

private function peekChar(): string
{
    $pos = $this->pos + 1;

    if ($pos >= strlen($this->input)) {
        return self::EOF;
    } else {
        return $this->input[$pos];
    }
}

繼續增加輔助方法,readChar用來移動指標到下一個字元,並把當前的字元賦值給c屬性,如果已經到結尾了,c會變成eof。peekChar方法用來向前看一個字元,有時候,我們根據當前的字元還不足以確定接下來該怎麼做,需要向前看更多字元。我們注意到,剛開始的pos屬性預設值為-1,所以我們需要在構造方法裡呼叫readChar方法將指標移到第一個字元:

public function __construct(string $json)
{
    $this->input = $json;
    $this->readChar();
}

json中可能還有空格或者換行之類的字元,我們希望也能支援,如果碰到這樣的字元,直接跳過即可:

private function skipBlank()
{
    while (($this->c == ' ' || $this->c == "\t"  ||  $this->c == "\n" || $this->c == "\r") && $this->c != self::EOF) {
        $this->readChar();
    }
}

nextToken方法的實現只需要我們根據當前字元去生成不同的token即可:

public function nextToken(): array
{
    $this->skipBlank(); // 跳過空白字元

    switch ($this->c) {
        case self::EOF:
            $tok = $this->makeToken('eof');
            break;
        case '{':
        case '}':
        case '[':
        case ']':
        case ':':
        case ',':
            $tok = $this->makeToken($this->c);
            $this->readChar();
            break;
        default:
            throw new Exception('unknown token '.$this->c);
    }

    return $tok;
}

private function makeToken($type, $value = ''): array
{
    return ['type' => $type, 'value' => $value];
}

這裡要注意的是,如果我們到達結尾,需要返回一個eof的token,這樣後續的語法分析程式才能知道已經到達了結尾。另外生成完token之後,當前指標也需要移動。

如果我們發現當前的字元是/並且下一個字元還是/,說明這是一段註釋,我們需要跳過註釋然後做新一輪匹配,這裡只要支援以//開頭的單行註釋即可:

public function nextToken(): array
{
    $this->skipBlank();

    switch ($this->c) {
            ...
        default:
            if ($this->c == '/' && $this->peekChar() == '/') {
                $this->skipComment();
                return $this->nextToken();
            }
            throw new Exception('unknown token '.$this->c);
    }

    return $tok;
}

private function skipComment()
{
    $this->readChar(); // skip /
    $this->readChar(); // skip /

    while ($this->c != "\n" && $this->c !== self::EOF) {
        $this->readChar();
    }
}

我們已經處理完了特殊字元的token,接下來還剩下STRING型別和NUMBER型別,先來看NUMBER,我們只要發現當前字元是數字型別的,則一直往後匹配到不是數字為止,把這個作為token返回,這裡為了程式簡單沒考慮效能(不要在意這些細節):

public function nextToken(): array
{
    $this->skipBlank();

    switch ($this->c) {
        ... 
        default:
            ...
            if ($this->isNumber($this->c)) {
                $tok = $this->makeToken('number', $this->matchNumber());
                break;
            }

            throw new Exception('unknown token '.$this->c);
    }

    return $tok;
}

private function matchNumber(): int
{
    $str = $this->c;
    $this->readChar();

    while ($this->isNumber($this->c)) {
        $str .= $this->c;
        $this->readChar();
    }

    return (int)$str;
}

private function isNumber($char)
{
    return preg_match('#^[0-9]$#', $char);
}

處理STRING型別的token也是類似,其實雙引號中的字元有些可能需要轉義(\n \r之類的字元),但這裡先不考慮

public function nextToken(): array
{
    $this->skipBlank();

    switch ($this->c) {
        ...
        case '"':
            $tok = $this->makeToken('string', $this->matchStr());
            break;
        ...
    }

    return $tok;
}

private function matchStr(): string
{
    for ($this->readChar(), $str = ''; $this->c != '"' && $this->c !== self::EOF; $this->readChar()) {
        $str .= $this->c;
    }

    $this->expectChar('"');
    return $str;
}

private function expectChar($char)
{
    if ($this->c == $char) {
        $this->readChar();
        return;
    }

    throw new Exception('lexer error: expect '.$char.' but given '.$this->c);
}

這裡增加了一個特殊的expectChar方法,這個方法用來吃掉 一個期望的字元,如果不是期望的字元的話,報錯。這裡需要是因為字串一定是以雙引號結尾的,如果沒有雙引號,我們需要報錯。

這樣,詞法分析程式就完成了,我們來點輸入試一下

// Lexer_test.php
include "Lexer.php";
$l = new Lexer('"a" 1 , { } []: // 123');

while (($tok = $l->nextToken())['type'] != 'eof') {
    print json_encode($tok)."\n";
}
// output
{"type":"string","value":"a"}
{"type":"number","value":1}
{"type":",","value":""}
{"type":"{","value":""}
{"type":"}","value":""}
{"type":"[","value":""}
{"type":"]","value":""}
{"type":":","value":""}

可以看到,程式可以很好的停止工作並且正確的跳過了空白和註釋,token的type和value也是正確的。有了正確的詞法分析程式,我們就可以進入下一步的parser階段了

語法分析器的實現

語法分析器我們一般稱之為parser,這個階段一般完成的任務讀取lexer產生的token,並檢查語法規則是否正確,如果正確的話,可能會產生一顆語法樹。這裡暫時先不介紹語法樹,我們把生成最終物件的任務放在這一步直接完成:

// Parser.php
class Parser
{
    /**
     * @var Lexer
     */
    private $l;

    // 當前token
    private $curToken;

    // 下一個token
    private $peekToken;

    public function __construct(Lexer $l)
    {
        $this->l = $l;
    }
}

l屬性是之前的Lexer物件,curToken表示當前的token,peekToken儲存下一個token用來作為前看符,接下來定義一些輔助方法

private function nextToken()
{
    $this->curToken = $this->peekToken;
    $this->peekToken = $this->l->nextToken();
}

private function curTokenIs($tokenType)
{
    return $this->curToken['type'] === $tokenType;
}

private function peekTokenIs($tokenType)
{
    return $this->peekToken['type'] === $tokenType;
}

private function expectCur($tokenType)
{
    if (!$this->curTokenIs($tokenType)) {
        throw new Exception('syntax error, expect '.$tokenType.' but given '.$this->curToken['type']);
    }

    $this->nextToken();
}

nextToken用來指向下一個token,curTokenIs和peekTokenIs方法用來 判斷當前或下一個token是不是我們期望的型別,expectCur跟Lexer中的expectChar類似:如果當前token是期望的token,那麼吃掉 ,如果不是,則報錯,這幾個函式在我們接下來的實現中用的非常多,抽象出來是非常有必要的。

同樣的,構造方法中需要初始化一下curToken和peekToken:

public function __construct(Lexer $l)
{
    $this->l = $l;
    $this->nextToken();
    $this->nextToken();
}

連續調兩次nextToken方法就可以把curToken指向第一個token,peekToken指向第二個token。接下來就是最重要的parse方法:

public function parse(): array
{
      // todo
      return [];
}

為了實現parse方法,我們先來分析一下json的結構:

json   := array
        | object

array  := [ (val)? (',' val)* ]

object := { (kvpair)? (',' kvpair)* }

kvpair := STRING ':' val

val    := json
        | NUMBER
        | STRING

:=符號表示可推匯出,|符號表示或者,?表示0或者1個,*表示任意多個。其他的{ } [ ] , : NUMBER STRING都是前面提到的token

我們先來考慮第一條規則,json是個array或者object,我們可以根據當前的token型別來判斷:

public function parse(): array
{
    if ($this->curTokenIs('[')) {
        $result = $this->parseArr();
        $this->expectCur('eof');
        return $result;
    } elseif ($this->curTokenIs('{')) {
        $result = $this->parseObj();
        $this->expectCur('eof');
        return $result;
    }

    throw new Exception('syntax error');
}

private function parseArr(): array
{
    // todo
    return [];
}

private function parseObj(): array
{
    // todo
    return [];
}

怎麼樣,是不是看起來很簡單?接下來我們只要補充完parseArr和parseObj就大功告成了。先來看parseArr

private function parseArr(): array
{
    $this->nextToken(); // skip [

    if ($this->curTokenIs(']')) {
        $this->nextToken(); // skip ]
        return [];
    }

    $arr = [];
    $arr[] = $this->parseVal();

    while (!$this->curTokenIs(']')) {
        $this->expectCur(','); // skip ,
        $arr[] = $this->parseVal();
    }

    $this->expectCur(']');
    return $arr;
}

private function parseVal()
{
    // todo
}

如果陣列為空,那麼直接返回空陣列,如果不為空,分為兩部分parse,第一部分直接用parseVal就可以,後續的任意val,只要沒遇到結束符] ,就一直用parseVal,這裡要注意一下, 的處理。接下來我們再來補充parseVal方法,從之前的語法規則我們可以看到val是一個json或者字串或者數字,我們只要把情況都列出來即可,要注意的是返回之前要消耗當前token:

private function parseVal()
{
    if ($this->curTokenIs('[')) {
        return $this->parseArr();
    } elseif ($this->curTokenIs('{')) {
        return $this->parseObj();
    } elseif ($this->curTokenIs('string') || $this->curTokenIs('number')) {
        $val = $this->curToken['value'];
        $this->nextToken(); // skip current
        return $val;
    }

    throw new Exception('syntax error');
}

到這裡,就只剩下parseObj方法了,依葫蘆畫瓢:

private function parseObj(): array
{
    $this->nextToken(); // skip {

    if ($this->curTokenIs('}')) {
        $this->nextToken(); // skip }
        return [];
    }

    $obj = [];
    $this->parseKvPair($obj);

    while (!$this->curTokenIs('}')) {
        $this->expectCur(','); // skip ,
        $this->parseKvPair($obj);
    }

    $this->expectCur('}');
    return $obj;
}

private function parseKvPair(&$obj)
{
    // todo
}

這裡parseKvPair需要往陣列中追加值,為了簡單性,直接傳引用。

private function parseKvPair(&$obj)
{
    $key = $this->curToken['value'];
    $this->expectCur('string');
    $this->expectCur(':');
    $val = $this->parseVal();
    $obj[$key] = $val;
}

感受到遞迴的威力了嗎?我們這裡採用的parse方法叫遞迴下降,簡單的解釋就是將一個任務拆分成多個子任務,同時任務之間又有可能遞迴呼叫。實現完了,我們再寫個簡單的測試驗證一下:

include "Lexer.php";
include "Parser.php";

$tests = [
    '{}',
    '[]',
    '{"a":1}',
    '["a","b"]',
    '{"a":[1,2], "b":{"c":"d", "e":["g"]}}'
];

foreach ($tests as $k => $json) {
    $actual = (new Parser(new Lexer($json)))->parse();
    $expect = json_decode($json, true);
    if ($actual != $expect) {
        print "expect ".json_encode($expect)." but given ".json_encode($actual)." at test {$k}\n";
        exit();
    }
}

print "test pass\n";
// output
test pass
本作品採用《CC 協議》,轉載必須註明作者和本文連結

相關文章