編譯器實現之旅——第五章 實現語法分析器前的準備

櫻雨樓發表於2021-02-19

在前面的旅程中,我們已經實現了詞法分析器。詞法分析器可將原始碼轉變為記號流,以供語法分析器使用。所以現在就讓我們啟程,朝著下一站——語法分析器出發吧。

1. 什麼是語法

什麼是語法呢?提到詞法分析器,我們能夠立即聯想到一個個看得見摸得著的詞;而提到語法分析器,又能聯想到什麼呢?

詞法和語法的關係,就像撲克牌和打撲克牌的遊戲規則之間的關係一樣:詞法決定了撲克牌的印刷、張數、花色、點數等等,而語法則決定了我們應該如何打出這些撲克牌,以贏得遊戲勝利;這在程式語言中就是:詞法決定了程式碼中能夠出現哪些詞,而語法決定了這些詞的正確組織方式。由此可見:詞法是具象的,而語法是抽象的。

2. 怎麼表示語法

正如我們通過一段文字描述某種遊戲規則一樣,我們也可以用一段文字來描述一個語法。請看:

句子的語法:

    1. 一個句子,由主語 + 謂語 + 賓語構成
    2. 主語是:“程式設計師”
    3. 謂語是:“沒有”
    4. 賓語是:“頭髮”

顯然,這就是一套語法了。但是我們發現,這種白話文式的語法表示顯然不是我們編譯器一貫的作風嘛。針對這個問題,巴科斯及後人提出了用於表示語法的“巴科斯正規化”和“擴充的巴科斯正規化”。擴充的巴科斯正規化的主要規則如下:

    1. 用“::=”符號表示“是”,或“由...構成”的意思。說的專業點,叫“推匯出”
    2. 用“|”符號表示“或”的意思
    3. 用“[ ... ]”表示中括號中的內容是可選的
    4. 用“{ ... }”表示大括號中的內容可以出現0至無窮多次的重複

現在,就讓我們用“擴充的巴科斯正規化”來描述我們的語法吧:

句子的語法:

    1. 句子 ::= 主語 謂語 賓語
    2. 主語 ::= “程式設計師”
    3. 謂語 ::= “沒有”
    4. 賓語 ::= “頭髮”

3. 語法怎麼使用

我們已經有了語法,現在的問題是,語法有什麼用處?又該怎麼發揮這些用處呢?

首先,我們顯然可以做這樣一件事:判定一個記號流是否符合語法。請看:

假如我們有記號流:(“程式設計師”,“有很多”,“頭髮”),我們從語法的第一條開始,我們知道:“句子 ::= 主語 謂語 賓語”,這句話代表什麼呢?其代表著:我們需要先看到一個主語,再看到一個謂語,最後看到一個賓語,如果都看到了,我們就認為這段記號流是符合語法的,否則,只要有任何一個地方不符合要求,我們就認為這段記號流是不符合語法的。那麼問題來了,主語、謂語、賓語又分別是什麼?顯然,接下來的三條語法告訴了我們答案。我們首先檢查主語,根據語法規則,我們知道:主語必須是“程式設計師”,記號流呢?嗯,第一個記號確實是“程式設計師”,我們接著往下看;語法規則又告訴我們:謂語必須是“沒有”,而此時的記號是“有很多”,這就不對了,後面也不用看了,我們可以立即判定:(“程式設計師”,“有很多”,“頭髮”)這個記號流是不符合語法的。

接下來的故事就不用我多說了吧。經過一番小小的努力,我們終於發現:只有:(“程式設計師”,“沒有”,“頭髮”)這個記號流是符合語法的。

確實符合語法了,然後呢?正如前面的章節所說,此時,我們就可以將這段記號流立體化,變為一棵抽象語法樹了。這棵抽象語法樹長什麼樣呢。請看:

                   句子
      /             |           \
程式設計師(主語)    沒有(謂語)   頭髮(賓語)

一句話解釋:語法長什麼樣,語法樹就長什麼樣。

將上面的模型變為程式碼,我們就得到了抽象語法樹節點的定義。請看:

struct AST
{
    // Attribute
    TOKEN_TYPE tokenType;
    string tokenStr;
    vector<AST *> subList;
    int lineNo;


    // Constructor
    explicit AST(TOKEN_TYPE _tokenType, const string &_tokenStr = "",
        const vector<AST *> &_subList = {}, int _lineNo = 0);

    explicit AST(const Token *tokenPtr);


    // Destructor
    ~AST();
};


AST::AST(TOKEN_TYPE _tokenType, const string &_tokenStr,
    const vector<AST *> &_subList, int _lineNo):
    tokenType(_tokenType),
    tokenStr (_tokenStr),
    subList  (_subList),
    lineNo   (_lineNo) {}


AST::AST(const Token *tokenPtr):
    tokenType(tokenPtr->tokenType),
    tokenStr (tokenPtr->tokenStr),
    lineNo   (tokenPtr->lineNo) {}


AST::~AST()
{
    for (auto subPtr: subList)
    {
        delete subPtr;
    }
}

可見,抽象語法樹節點的定義和前面Token類的定義是高度相似的。抽象語法樹節點也需要標記的類別、標記字串,以及標記所在的行數。此外,抽象語法樹節點還需要一個vector,以儲存這個節點的所有子節點。

4. 語法錯誤的表示

當我們遇到上文中的語法錯誤時,我們就需要一個能夠報告此問題的工具函式,實現如下:

void InvalidToken(const Token *invalidTokenPtr)
{
    printf("Invalid token: %s in line %d\n",
        invalidTokenPtr->tokenStr.c_str(),
        invalidTokenPtr->lineNo);

    exit(1);
}

這個函式的實現很簡單,這裡就不討論了。

5. 消除左遞迴

這巴科斯正規化可不是省油的燈啊。這一節和下一節,我們將分別來看看巴科斯正規化中的兩個常見問題。首先來看消除左遞迴。

假設有以下語法:

    Six ::= Six '6'
          | '6'

這個語法說了什麼?其表示,一個“Six”可以推匯出一個“Six '6'”;而“Six”又是什麼?是“Six '6'”;而“Six”又是什麼?是“Six '6'”...

怎麼回事?我們連一個記號字串都還沒看到呢,就光顧著在這無限迴圈了,這就是“左遞迴”問題,是我們需要消除的。

怎麼消除呢?首先需要明確的是,語法表示並不是神聖不可侵犯的,而是可以進行等價變換的。那麼,上面這個語法該怎麼變換呢?讓我們來找找規律:

    1. Six ::= '6'
    2. Six ::= Six '6' ::= '6' '6'
    3. Six ::= Six '6' ::= Six '6' '6' ::= '6' '6' '6'
    ...

找到規律了!我們發現:

  1. 每一次使用“Six '6'”替換“Six”,就會在“Six”的右邊多出一個“6”
  2. “Six”終將被“6”而不是“Six '6'”替換

也就是說,“Six”推匯出的應該是:1至有限多個“6”。寫成語法就是:

    Six ::= '6' { '6' }

可見,我們成功的消除了左遞迴。現在,語法的推導就可以正常開始並進行下去,而不會出現無限迴圈的情況了。

6. First集合

解決了左遞迴問題後,讓我們再來看看接下來的這個問題。

假設有以下語法:

    1. A ::= B | C
    2. B ::= 'b'
    3. C ::= D
    4. D ::= 'd'

“A ::= B | C”?選B還是C?我們無從獲知。因為“B”和“C”並不代表某個具體的字元,而是另外的兩條語法。這時候怎麼辦呢?這時候呀,我們可就要刨根問底了。

我們先來看看“B”又是什麼,我們發現:“B ::= 'b'”,也就是說,如果我們看到了“b”,我們就選“B”。

那麼,“C”又是什麼?我們發現:“C ::= D”,不行,我們需要繼續看。“D”又是什麼?我們發現:“D ::= 'd'”。也就是說:

  1. 如果我們看到了“d”,我們就選“D”
  2. 由於“C ::= D”,所以如果我們看到了“d”,我們也可以選“C”

經過一番刨根問底,現在我們知道了:對於“A ::= B | C”這條語法而言,如果我們看到了“b”,我們就選“B”;如果我們看到了“d”,我們就選“C”。當然了,如果我們看到的既不是“b”也不是“d”,這就是語法錯誤了。

上面我們得到的結果,其實就是First集合了。First集合可以幫助我們在“多選一”類語法中立即做出正確的選擇。

我們的旅程到這裡就快要暫告一段落了,最後,我們可以考慮兩個問題。請看:

  1. 我就不用First集合,行不行?

行,當我們看到“A ::= B | C”時,管他呢,我們就選“B”。如果選“B”是錯的,那麼我們就想辦法把這個“選錯了”的資訊傳回來,然後我們再換成“C”試試看。

這樣做的缺點顯而易見:計算量可能會非常大,且毫無意義。如果“A ::= B | C”是語法的頂層,那麼我們甚至有可能直到看到最後一個記號,才發現選錯了,然後一切推翻重來。

  1. 刨根問底,會不會還是問不出個所以然?

不會。因為...

...接連便是難懂的話,什麼“喬姆斯基體系”,什麼“上下文無關文法”之類...

至此,我們就完成了所有準備工作,可以開始實現語法分析器了。請看下一章:《實現語法分析器》。

相關文章