精讀《手寫 SQL 編譯器 - 文法介紹》

黃子毅發表於2018-07-16

1 引言

文法用來描述語言的語法規則,所以不僅可以用在程式語言上,也可用在漢語、英語上。

2 精讀

我們將一塊語法規則稱為 產生式,使用 “Left → Right” 表示任意產生式,用 “Left => Right” 表示產生式的推導過程,比如對於產生式:

E → i
E → E + E
複製程式碼

我們進行推導時,可以這樣表示:E => E + E => i + E => i + i + E => i + i + i

也有使用 Left : Right 表示產生式的例子,比如 ANTLR。BNF 正規化通過 Left ::= Right 表示產生式。

舉個例子,比如 SELECT * FROM table 可以被表達為:

S → SELECT * FROM table
複製程式碼

當然這是最固定的語法,真實場景中,* 可能被替換為其他單詞,而 table 不但可能有其他名字,還可能是個子表示式。

一般用大寫的 S 表示文法的開頭,稱為開始符號。

終結符與非終結符

下面為了方便書寫,使用 BNF 正規化表示文法。

終結符就是語句的終結,讀到它表示產生式分析結束,相反,非終結符就是一個新產生式的開始,比如:

<selectStatement> ::= SELECT <selectList> FROM <tableName>

<selectList> ::= <selectField> [ , <selectList> ]

<tableName> ::= <tableName> [ , <tableList> ]
複製程式碼

所有 ::= 號左邊的都是非終結符,所以 selectList 是非終結符,解析 selectStatement 時遇到了 selectList 將會進入 selectList 產生式,而解析到普通 SELECT 單詞就不會繼續解析。

對於有二義性的文法,可以通過 上下文相關文法 方式描述,也就是在產生式左側補全條件,解決二義性:

aBc -> a1c | a2c
dBe -> d3e
複製程式碼

一般產生式左側都是非終結符,大寫字母是非終結符,小寫字母是終結符。

上面表示,非終結符 Bac 之間時,可以解析為 12,而在 de 之間時,解析為 3。但我們可以增加一個非終結符讓產生式可讀性更好:

B -> 1 | 2
C -> 3
複製程式碼

這樣就將上下文相關文法轉換為了上下文無關文法。

上下文無關文法

根據是否依賴上下文,文法分為 上下文相關文法上下文無關文法,一般來說 上下文相關文法 都可以轉換為一堆 上下文無關文法 來處理,而用程式處理 上下文無關文法 相對輕鬆。

SQL 的文法就是上下文相關文法,在正式介紹 SQL 文法之前,舉一個簡單的例子,比如我們描述等號(=)的文法:

SELECT
  CASE
    WHEN bee = 'red' THEN 'ANGRY'
    ELSE 'NEUTRAL'
  END AS BeeState
FROM bees;

SELECT * from bees WHERE bee = 'red';
複製程式碼

上面兩個 SQL 中,等號前後的關鍵字取決於當前是在 CASE WHEN 語句裡,還是在 WHERE 語句裡,所以我們認為等號所在位置的文法是上下文相關的。

但是當我們將文法粒度變細,將 CASE WHENWHERE 區塊分別交由兩塊文法解決,將等號這個通用的表示式抽離出來,就可以不關心上下文了,這種方式稱為 上下文無關文法

附上一個 mysql 上下文無關文法集合

左推導與右推導

上面提到的推導符號 => 在實際執行過程中,顯然有兩種方向左和右:

E + E => ?
複製程式碼

從最左邊的 E 開始分析,稱為左推導,對語法解析來說是自頂向下的方式,常用方法是遞迴下降。

從最右邊的 E 開始分析,稱為右推導,對語法解析來說是自底向上的方式,常用方法是移進、規約。

右推導過程比左推導過程複雜,所以如果考慮手寫,最好使用左推導的方式。

左推導的分支預測

比如 select <selectList>selectList 產生式,它可以表示為:

<SelectList> ::= <SelectList> , <SelectField>
               | <SelectField>
複製程式碼

由於它可以展開:SelectList => SelectList , a => SelectList , b, a => c, b, a。

但程式執行時,讀到這裡會進入死迴圈,因為 SelectList 可以被無限展開,這就是左遞迴問題。

消除左遞迴

消除左遞迴一般通過轉化為右遞迴的方式,因為左遞迴完全不消耗 Token,而右遞迴可以通過消耗 Token 的方式跳出死迴圈。

Token 見上一期精讀 精讀《手寫 SQL 編譯器 - 詞法分析》

<SelectList> ::= <SelectField> <G>

<G> ::= , <SelectList>
      | null
複製程式碼

這其實是一個通用處理,可以抽象出來:

E → E + F
E → F
複製程式碼
E → FG
G → + FG
G → null
複製程式碼

不過我們也不難發現,通過通用方式消除左遞迴後的文法更難以閱讀,這是因為用死迴圈的方式解釋問題更容易讓人理解,但會導致機器崩潰。

筆者建議此處不要生硬的套公式,在套了公式後,再對產生式做一些修飾,讓其更具有語義:

<SelectList> ::= <SelectField>
               | , <SelectList>
複製程式碼

提取左公因式

即便是上下文無關的文法,通過遞迴下降方式,許多時候也必須從左向右超前檢視 K 個字元才能確定使用哪個產生式,這種文法稱為 LL(k)。

但如果每次超前檢視的內容都有許多字元相同,會導致第二次開始的超前檢視重複解析字串,影響效能。最理想的情況是,每次超前檢視都不會對已確定的字元重複檢視,解決方法是提取左公因式。

設想如下的 sql 文法:

<Field> ::= <Text> as <Text>
          | <Text> as<String>
          | <Text> <Text>
          | <Text>
複製程式碼

其實 Text 本身也是比較複雜的產生式,最壞的情況需要對 Text 連續匹配六遍。我們將 Text 公因式提取出來就可以僅匹配一遍,因為無論是何種 Field 產生式,都必定先遇到 Text:

<Field> ::= <Text> <F>

<F> ::= <G>
      | <Text>

<G> ::= as <H>

<H> ::= <space> <Text>
      | <String>
複製程式碼

和消除左遞迴一樣,提取左公因式也會降低文法的可讀性,需要進行人為修復。不過提取左公因式的修復沒辦法在文法中處理,在後面的 “函式式” 處理環節是有辦法處理的,敬請期待。

結合優先順序

對 SQL 的文法來說不存在優先順序的概念,所以從某種程度來說,SQL 的語法複雜度還不如基本的加減乘除。

3 總結

在實現語法解析前,需要使用文法描述 SQL 的語法,文法描述就是語法分析的主幹業務程式碼。

下一篇將介紹語法分析相關知識,幫助你一步步打造自己的 SQL 編譯器。

4 更多討論

討論地址是:精讀《手寫 SQL 編譯器 - 文法介紹》 · Issue #94 · dt-fe/weekly

如果你想參與討論,請點選這裡,每週都有新的主題,週末或週一釋出。

相關文章