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

1 引言

接着上週的文法介紹,本週介紹的是語法分析。git

以解析順序爲角度,語法分析分爲兩種,自頂而下與自底而上。github

自頂而下通常採用遞歸降低方式處理,稱爲 LL(k),第一個 L 是指從左到右分析,第二個 L 指從左開始推導,k 是指超前查看的數量,若是實現了回溯功能,k 就是無限大的,因此帶有回溯功能的 LL(k) 幾乎是最強大的。LL 系列通常分爲 LL(0)、LL(1)、LL(k)、LL(∞)。sql

自底而上通常採用移進(shift)規約(reduce)方式處理,稱爲 LR,第一個 L 也是從左到右分析,第二個 R 指從右開始推導,而規約時可能產生衝突,因此經過超前查看一個符號解決衝突,就有了 SLR,後面還有功能更強的 LALR(1) LR(1) LR(k)typescript

經過這張圖能夠看到 LL 家族與 LR 家族的能力範圍:bash

如圖所示,不管 LL 仍是 LR 都解決不了二義性文法,還好全部計算機語言都屬於無二義性文法。函數

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

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

2 精讀

遞歸降低能夠理解爲走多出口的迷宮:spa

咱們先根據 SQL 語法構造一個迷宮,進迷宮的不是探險家,而是 SQL 語句,這個 SQL 語句會拿上一堆令牌(切分好的 Tokens,詳情見 精讀:詞法分析),迷宮每前進一步都會要求按順序給出令牌(交上去就沒收),若是走到出口令牌恰好交完,就成功走出了迷宮;若是出迷宮時手上還有令牌,會被迷宮工做人員帶走。這個迷宮會有一些分叉,在分岔路上會要求你亮出幾個令牌中任意一個便可經過(LL1),有的迷宮容許你失敗了存檔,只要沒有走出迷宮,均可以讀檔重來(LLk),理論上能夠構造一個最寬容的迷宮,只要還沒走出迷宮,能夠在分叉處任意讀檔(LL∞),這個留到下一篇文章介紹。code

詞法分析

首先對 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

若是你想參與討論,請點擊這裏,每週都有新的主題,週末或週一發佈。

相關文章
相關標籤/搜索