1 引言
接著上週的文法介紹,本週介紹的是語法分析。
以解析順序為角度,語法分析分為兩種,自頂而下與自底而上。
自頂而下一般採用遞迴下降方式處理,稱為 LL(k),第一個 L 是指從左到右分析,第二個 L 指從左開始推導,k 是指超前檢視的數量,如果實現了回溯功能,k 就是無限大的,所以帶有回溯功能的 LL(k) 幾乎是最強大的。LL 系列一般分為 LL(0)、LL(1)、LL(k)、LL(∞)。
自底而上一般採用移進(shift)規約(reduce)方式處理,稱為 LR,第一個 L 也是從左到右分析,第二個 R 指從右開始推導,而規約時可能產生衝突,所以通過超前檢視一個符號解決衝突,就有了 SLR,後面還有功能更強的 LALR(1) LR(1) LR(k)。
通過這張圖可以看到 LL 家族與 LR 家族的能力範圍:
如圖所示,無論 LL 還是 LR 都解決不了二義性文法,還好所有計算機語言都屬於無二義性文法。
值得一提的是,如果實現了回溯功能的 LL(k) -> LL(∞),那麼能力就可以與 LR(k) 所比肩,而 LL 系列手寫起來更易讀,所以筆者採用了 LL 方式書寫,今天介紹如何手寫無回溯功能的 LL。
另外也有一些根據文法自動生成 parser 的庫,比如相容多語言的 antlr4 或者對 js 支援比較友好的 pegjs。
2 精讀
遞迴下降可以理解為走多出口的迷宮:
我們先根據 SQL 語法構造一個迷宮,進迷宮的不是探險家,而是 SQL 語句,這個 SQL 語句會拿上一堆令牌(切分好的 Tokens,詳情見 精讀:詞法分析),迷宮每前進一步都會要求按順序給出令牌(交上去就沒收),如果走到出口令牌剛好交完,就成功走出了迷宮;如果出迷宮時手上還有令牌,會被迷宮工作人員帶走。這個迷宮會有一些分叉,在分岔路上會要求你亮出幾個令牌中任意一個即可通過(LL1),有的迷宮允許你失敗了存檔,只要沒有走出迷宮,都可以讀檔重來(LLk),理論上可以構造一個最寬容的迷宮,只要還沒走出迷宮,可以在分叉處任意讀檔(LL∞),這個留到下一篇文章介紹。
詞法分析
首先對 SQL 進行詞法分析,拿到 Tokens 列表,這些就是探險家 SQL 帶上的令牌。
根據上次講的內容,我們對 select a from b
進行詞法分析,可以拿到四個 Token(忽略空格與註釋)。
Match 函式
遞迴下降最重要的就是 Match 函式,它就是迷宮中索取令牌的關卡。每個 Match 函式只要匹配上當前 Token 便將 Token index 下移一位,如果沒有匹配上,則不消耗 Token:
function match(word: string) {
const currentToken = tokens[tokenIndex] // 拿到當前所在的 Token
if (currentToken.value === word) {
// 如果 Token 匹配上了,則下移一位,同時返回 true
tokenIndex++
return true
}
// 沒有匹配上,不消耗 Token,但是返回 false
return false
}
複製程式碼
Match 函式就是精簡版的 if else,試想下面一段程式碼:
if (token[tokenIndex].value === 'select') {
tokenIndex++
} else {
return false
}
if (token[tokenIndex].value === 'a') {
tokenIndex++
} else {
return false
}
複製程式碼
通過不斷對比與移動 Token 進行判斷,等價於下面的 Match 實現:
match('select') && match('a')
複製程式碼
這樣寫出來的語法分析程式碼可讀性會更強,我們能專注精神在對文法的解讀上,而忽略其他環境因素。
順便一提,下篇文章筆者會帶來更精簡的描述方法:
chain('select', 'a')
複製程式碼
讓函式式語法更接近文法形式。
最後這種語法不但描述更為精簡,而且擁有 LL(∞) 的查詢能力,擁有幾乎最強大的語法分析能力。
語法分析主體函式
既然關卡(Match)已經有了,下面開始構造主函式了,可以開始畫迷宮了。
舉個最簡單的例子,我們想匹配 select a from b
,只需要這麼構造主函式:
let tokenIndex = 0
function match() { /* .. */ }
const root = () => match("select") && match("a") && match("from") && match("b")
tokens = lexer("select a from b")
if (root() && tokenIndex === tokens.length) {
// sql 解析成功
}
複製程式碼
為了簡化流程,我們把 tokens、tokenIndex 作為全域性變數。首先通過 lexer
拿到 select a from b
語句的 Tokens:['select', ' ', 'a', ' ', 'from', ' ', 'b']
,注意在語法解析過程中,註釋和空格可以消除,這樣可以省去對空格和註釋的判斷,大大簡化程式碼量。所以最終拿到的 Tokens 是 ['select', 'a', 'from', 'b']
。
很顯然這樣與我們構造的 Match 佇列相吻合,所以這段語句順利的走出了迷宮,而且走出迷宮時,Token 正好被消費完(tokenIndex === tokens.length
)。
這樣就完成了最簡單的語法分析,一共十幾行程式碼。
函式呼叫
函式呼叫是 JS 最最基礎的知識,但用在語法解析裡可就不那麼一樣了。
考慮上面最簡單的語句 select a from b
,顯然無法勝任真正的 SQL 環境,比如 select [位置] from b
這個位置可以放置任意用逗號相連的字串,我們如果將這種 SQL 展開描述,將非常複雜,難以閱讀。恰好函式呼叫可以幫我們完美解決這個問題,我們將這個位置抽象為 selectList
函式,所以主語句改造如下:
const root = () =>
match("select") && selectList() && match("from") && match("b")
複製程式碼
這下能否解析 select a, b, c from table
就看 selectList
這個函式了:
const selectList =
match("a") && match(",") && match("b") && match(",") && match("c")
複製程式碼
顯然這樣做不具備通用性,因為我們將引數名與數量固定了。考慮到上期精讀學到的文法,我們可以這樣描述 selectList
:
selectList ::= word (',' selectList)?
word ::= [a-zA-Z]
複製程式碼
故意繞過了左遞迴,採用右遞迴的寫法,因而避開了語法分析的核心難點。
? 號是可選的意思,與正則的 ? 類似。
這是一個右遞迴文法,不難看出,這個文法可以如此展開:
selectList => word (',' selectList)? => a (',' selectList)? => a, word (',' selectList)? => a, b, word (',' selectList)? => a, b, word => a, b, c
我們一下遇到了兩個問題:
- 補充 word 函式。
- 如何描述可選引數。
同理,利用函式呼叫,我們假定擁有了可選函式 optional
,與函式 word
,這樣可以先把 selectList
函式描述出來:
const selectList = () => word() && optional(match(",") && selectList())
複製程式碼
這樣就通過可選函式 optional
描述了文法符號 ?
。
我們來看 word
函式如何實現。需要簡單改造下 match
使其支援正則,那麼 word
函式可以這樣描述:
const word = () => match(/[a-zA-Z]*/)
複製程式碼
而 optional
不是普通的 match
函式,從呼叫方式就能看出來,我們提到下一節詳細介紹。
注意 selectList
函式的尾部,通過右遞迴的方式呼叫 selectList
,因此可以解析任意長度以 ,
分割的欄位列表。
Antlr4 支援左遞迴,因此文法可以寫成 selectList ::= selectList (, word)? | word,用在我們這個簡化的程式碼中會導致堆疊溢位。
在介紹 optional
函式之前,我們先引出分支函式,因為可選函式是分支函式的一種特殊形式(猜猜為什麼?)。
分支函式
我們先看看函式 word
,其實沒有考慮到函式作為欄位的情況,比如 select a, SUM(b) from table
。所以我們需要升級下 selectList
的描述:
const selectList = () => field() && optional(match(",") && selectList())
const field = () => word()
複製程式碼
這時注意 field
作為一個欄位,也可能是文字或函式,我們假設擁有函式處理函式 functional
,那麼用文法描述 field
就是:
field ::= text | functional
複製程式碼
|
表示分支,我們用 tree
函式表示分支函式,那麼可以如此改寫 field
:
const field = () => tree(word(), functional())
複製程式碼
那麼改如何表示 tree
呢?按照分支函式的特性,tree
的職責是超前檢視,也就是超前檢視 word
是否符合當前 Token 的特徵,如何符合,則此分支可以走通,如果不符合,同理繼續嘗試 functional
。
若存在 A、B 分支,由於是函式式呼叫,若 A 分支為真,則函式堆疊退出到上層,若後續嘗試失敗,則無法再回到分支 B 繼續嘗試,因為函式棧已經退出了。這就是本文開頭提到的 回溯 機制,對應迷宮的 存檔、讀檔 機制。要實現回溯機制,要模擬函式執行機制,拿到函式呼叫的控制權,這個下篇文章再詳細介紹。
根據這個特性,我們可以寫出 tree
函式:
function tree(...args: any[]) {
return args.some(arg => arg())
}
複製程式碼
按照順序執行 tree
的入參,如果有一個函式執行為真,則跳出函式,如果所有函式都返回 false,則這個分支結果為 false。
考慮到每個分支都會消耗 Token,所以我們需要在執行分支時,先把當前 TokenIndex 儲存下來,如果執行成功則消耗,執行失敗則還原 Token 位置:
function tree(...args: any[]) {
const startTokenIndex = tokenIndex
return args.some(arg => {
const result = arg()
if (!result) {
tokenIndex = startTokenIndex // 執行失敗則還原 TokenIndex
}
return result
});
}
複製程式碼
可選函式
可選函式就是分支函式的一個特例,可以描述為:
func? => func | ε
複製程式碼
ε 表示空,也就是這個產生式解析到這裡永遠可以解析成功,而且不消耗 Token。藉助分支函式 tree
執行失敗後還原 TokenIndex 的特性,我們先嚐試執行它,執行失敗的話,下一個 ε 函式一定返回 true,而且會重置 TokenIndex 且不消耗 Token,這與可選的含義是等價的。
所以可以這樣描述 optional
函式:
const optional = fn => tree(fn, () => true)
複製程式碼
基本的運算連線
上面通過對 SQL 語句的實踐,發現了 match
匹配單個單詞、 &&
連線、tree
分支、ε
空字串的產生式這四種基本用法,這是符合下面四個基本文法組合思想的:
G ::= ε
複製程式碼
空字串產生式,對應 () => true
,不消耗 Token,總是返回 true
。
G ::= t
複製程式碼
單詞匹配,對應 match(t)
。
G ::= x y
複製程式碼
連線運算,對應 match(x) && match(y)
。
G ::= x
G ::= y
複製程式碼
並運算,對應 tree(x, y)
。
有了這四種基本用法,幾乎可以描述所有 SQL 語法。
比如簡單描述一下 select 語法:
const root = () => match("select") && select() && match("from") && table()
const selectList = () => field() && optional(match(",") && selectList())
const field = () => tree(word, functional)
const word = () => match(/[a-zA-Z]+/)
複製程式碼
3 總結
遞迴下降的 SQL 語法解析就是一個走迷宮的過程,將 Token 從左到右逐個匹配,最終能找到一條路線完全貼合 Token,則 SQL 解析圓滿結束,這個迷宮採用空字串產生式、單詞匹配、連線運算、並運算這四個基本文法組合就足以構成。
掌握了這四大法寶,基本的 SQL 解析已經難不倒你了,下一步需要做這些優化:
- 回溯功能,實現它才可能實現 LL(∞) 的匹配能力。
- 左遞迴自動消除,因為通過文法轉換,會改變文法的結合律與語義,最好能實現左遞迴自動消除(左遞迴在上一篇精讀 文法 有說明)。
- 生成語法樹,僅匹配語句的正確性是不夠的,我們還要根據語義生成語法樹。
- 錯誤檢查,在錯誤的地方給出建議,甚至對某些錯誤做自動修復,這個在左 SQL 智慧提示時需要用到。
- 錯誤恢復。
下篇文章會介紹如何實現回溯,讓遞迴下降達到 LL(∞) 的效果。
從本文不難看出,通過函式呼叫方式我們無法做到 迷宮存檔和讀檔機制,也就是遇到岔路 A B 時,如果 A 成功了,函式呼叫棧就會退出,而後面迷宮探索失敗的話,我們無法回到岔路 B 繼續探索。而 回溯功能就賦予了這個探險者返回岔路 B 的能力。
為了實現這個功能,幾乎要完全推翻這篇文章的程式碼組織結構,不過別擔心,這四個基本組合思想還會保留。
下篇文章也會放出一個真正能執行的,實現了 LL(∞) 的程式碼庫,函式描述更精簡,功能(比這篇文章的方法)更強大,敬請期待。
4 更多討論
如果你想參與討論,請點選這裡,每週都有新的主題,週末或週一釋出。