接着上週的文法介紹,本週介紹的是語法分析。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 支持比較友好的 pegjs。ui
遞歸降低能夠理解爲走多出口的迷宮:spa
咱們先根據 SQL 語法構造一個迷宮,進迷宮的不是探險家,而是 SQL 語句,這個 SQL 語句會拿上一堆令牌(切分好的 Tokens,詳情見 精讀:詞法分析),迷宮每前進一步都會要求按順序給出令牌(交上去就沒收),若是走到出口令牌恰好交完,就成功走出了迷宮;若是出迷宮時手上還有令牌,會被迷宮工做人員帶走。這個迷宮會有一些分叉,在分岔路上會要求你亮出幾個令牌中任意一個便可經過(LL1),有的迷宮容許你失敗了存檔,只要沒有走出迷宮,均可以讀檔重來(LLk),理論上能夠構造一個最寬容的迷宮,只要還沒走出迷宮,能夠在分叉處任意讀檔(LL∞),這個留到下一篇文章介紹。code
首先對 SQL 進行詞法分析,拿到 Tokens 列表,這些就是探險家 SQL 帶上的令牌。
根據上次講的內容,咱們對 select a from b
進行詞法分析,能夠拿到四個 Token(忽略空格與註釋)。
遞歸降低最重要的就是 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
咱們一下遇到了兩個問題:
同理,利用函數調用,咱們假定擁有了可選函數 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]+/) 複製代碼
遞歸降低的 SQL 語法解析就是一個走迷宮的過程,將 Token 從左到右逐個匹配,最終能找到一條路線徹底貼合 Token,則 SQL 解析圓滿結束,這個迷宮採用空字符串產生式、單詞匹配、鏈接運算、並運算這四個基本文法組合就足以構成。
掌握了這四大法寶,基本的 SQL 解析已經難不倒你了,下一步須要作這些優化:
下篇文章會介紹如何實現回溯,讓遞歸降低達到 LL(∞) 的效果。
從本文不難看出,經過函數調用方式咱們沒法作到 迷宮存檔和讀檔機制,也就是遇到岔路 A B 時,若是 A 成功了,函數調用棧就會退出,然後面迷宮探索失敗的話,咱們沒法回到岔路 B 繼續探索。而 回溯功能就賦予了這個探險者返回岔路 B 的能力。
爲了實現這個功能,幾乎要徹底推翻這篇文章的代碼組織結構,不過別擔憂,這四個基本組合思想還會保留。
下篇文章也會放出一個真正能運行的,實現了 LL(∞) 的代碼庫,函數描述更精簡,功能(比這篇文章的方法)更強大,敬請期待。
若是你想參與討論,請點擊這裏,每週都有新的主題,週末或週一發佈。