精讀《手寫 SQL 編譯器 - 語法分析》

黃子毅發表於2018-07-23

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 家族的能力範圍:

精讀《手寫 SQL 編譯器 - 語法分析》

如圖所示,無論 LL 還是 LR 都解決不了二義性文法,還好所有計算機語言都屬於無二義性文法。

值得一提的是,如果實現了回溯功能的 LL(k) -> LL(∞),那麼能力就可以與 LR(k) 所比肩,而 LL 系列手寫起來更易讀,所以筆者採用了 LL 方式書寫,今天介紹如何手寫無回溯功能的 LL。

另外也有一些根據文法自動生成 parser 的庫,比如相容多語言的 antlr4 或者對 js 支援比較友好的 pegjs

2 精讀

遞迴下降可以理解為走多出口的迷宮:

精讀《手寫 SQL 編譯器 - 語法分析》

我們先根據 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

我們一下遇到了兩個問題:

  1. 補充 word 函式。
  2. 如何描述可選引數。

同理,利用函式呼叫,我們假定擁有了可選函式 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 更多討論

討論地址是:精讀《手寫 SQL 編譯器 - 語法分析》 · Issue #95 · dt-fe/weekly

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

相關文章