在前面的旅程中,我們已經實現了詞法分析器。詞法分析器可將原始碼轉變為記號流,以供語法分析器使用。所以現在就讓我們啟程,朝著下一站——語法分析器出發吧。
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'
...
找到規律了!我們發現:
- 每一次使用“Six '6'”替換“Six”,就會在“Six”的右邊多出一個“6”
- “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'”。也就是說:
- 如果我們看到了“d”,我們就選“D”
- 由於“C ::= D”,所以如果我們看到了“d”,我們也可以選“C”
經過一番刨根問底,現在我們知道了:對於“A ::= B | C”這條語法而言,如果我們看到了“b”,我們就選“B”;如果我們看到了“d”,我們就選“C”。當然了,如果我們看到的既不是“b”也不是“d”,這就是語法錯誤了。
上面我們得到的結果,其實就是First集合了。First集合可以幫助我們在“多選一”類語法中立即做出正確的選擇。
我們的旅程到這裡就快要暫告一段落了,最後,我們可以考慮兩個問題。請看:
- 我就不用First集合,行不行?
行,當我們看到“A ::= B | C”時,管他呢,我們就選“B”。如果選“B”是錯的,那麼我們就想辦法把這個“選錯了”的資訊傳回來,然後我們再換成“C”試試看。
這樣做的缺點顯而易見:計算量可能會非常大,且毫無意義。如果“A ::= B | C”是語法的頂層,那麼我們甚至有可能直到看到最後一個記號,才發現選錯了,然後一切推翻重來。
- 刨根問底,會不會還是問不出個所以然?
不會。因為...
...接連便是難懂的話,什麼“喬姆斯基體系”,什麼“上下文無關文法”之類...
至此,我們就完成了所有準備工作,可以開始實現語法分析器了。請看下一章:《實現語法分析器》。