精讀《手寫SQL編譯器-回溯》
1 引言
上回 精讀《手寫 SQL 編譯器 – 語法分析》 說到了如何利用 Js 函式實現語法分析時,留下了一個回溯問題,也就是存檔、讀檔問題。
我們把語法分析樹當作一個迷宮,有直線有岔路,而想要走出迷宮,在遇到岔路時需要提前進行存檔,在後面走錯時讀檔換下一個岔路進行嘗試,這個功能就叫回溯。
上一篇我們實現了 分支函式,在分支執行失敗後回滾 TokenIndex 位置並重試,但在函式呼叫棧中,如果其子函式執行完畢,堆疊跳出,我們便無法找到原來的函式棧重新執行。
為了更加詳細的描述這個問題,舉一個例子,存在以下岔路:
a -> tree() -> c
-> b1 -> b1`
-> b2 -> b2`
上面描述了兩條判斷分支,分別是 a -> b1 -> b1` -> c
與 a -> b2 -> b2` -> c
,當岔路 b1
執行失敗後,分支函式 tree
可以復原到 b2
位置嘗試重新執行。
但設想 b1 -> b1`
通過,但 b1 -> b1` -> c
不通過的場景,由於 b1`
執行完後,分支函式 tree
的呼叫棧已經退出,無法再嘗試路線 b2 -> b2`
了。
要解決這個問題,我們要 通過連結串列手動建構函式執行過程,這樣不僅可以實現任意位置回溯,還可以解決左遞迴問題,因為函式並不是立即執行的,在執行前我們可以加一些 Magic 動作,比如調換執行順序!這文章主要介紹如何通過連結串列建構函式呼叫棧,並實現回溯。
2 精讀
假設我們擁有了這樣一個函式 chain
,可以用更簡單的方式表示連續匹配:
const root = (tokens: IToken[], tokenIndex: number) => match(`a`, tokens, tokenIndex) && match(`b`, tokens, tokenIndex) && match(`c`, tokens, tokenIndex)
↓ ↓ ↓ ↓ ↓ ↓
const root = (chain: IChain) => chain(`a`, `b`, `c`)
遇到分支條件時,通過陣列表示取代 tree
函式:
const root = (tokens: IToken[], tokenIndex: number) => tree(
line(match(`a`, tokens, tokenIndex) && match(`b`, tokens, tokenIndex)),
line(match(`c`, tokens, tokenIndex) && match(`d`, tokens, tokenIndex))
)
↓ ↓ ↓ ↓ ↓ ↓
const root = (chain: IChain) => chain([
chain(`a`, `b`),
chain(`c`, `d`)
])
這個 chain
函式有兩個特質:
- 非立即執行,我們就可以 預先生成執行鏈條 ,並對鏈條結構進行優化、甚至控制執行順序,實現回溯功能。
- 無需顯示傳遞 Token,減少每一步匹配寫的程式碼量。
封裝 scanner、matchToken
我們可以製作 scanner 函式封裝對 token 的操作:
const query = "select * from table;";
const tokens = new Lexer(query);
const scanner = new Scanner(tokens);
scanner 擁有兩個主要功能,分別是 read
讀取當前 token 內容,和 next
將 token 向下移動一位,我們可以根據這個功能封裝新的 matchToken
函式:
function matchToken(
scanner: Scanner,
compare: (token: IToken) => boolean
): IMatch {
const token = scanner.read();
if (!token) {
return false;
}
if (compare(token)) {
scanner.next();
return true;
} else {
return false;
}
}
如果 token 消耗完,或者與比對不匹配時,返回 false 且不消耗 token,當匹配時,消耗一個 token 並返回 true。
現在我們就可以用 matchToken
函式寫一段匹配程式碼了:
const query = "select * from table;";
const tokens = new Lexer(query);
const scanner = new Scanner(tokens);
const root =
matchToken(scanner, token => token.value === "select") &&
matchToken(scanner, token => token.value === "*") &&
matchToken(scanner, token => token.value === "from") &&
matchToken(scanner, token => token.value === "table") &&
matchToken(scanner, token => token.value === ";");
我們最終希望表達成這樣的結構:
const root = (chain: IChain) => chain("select", "*", "from", "table", ";");
既然 chain 函式作為線索貫穿整個流程,那 scanner 函式需要被包含在 chain 函式的閉包裡內部傳遞,所以我們需要構造出第一個 chain。
封裝 createChainNodeFactory
我們需要 createChainNodeFactory 函式將 scanner 傳進去,在內部偷偷存起來,不要在外部程式碼顯示傳遞,而且 chain 函式是一個高階函式,不會立即執行,由此可以封裝二階函式:
const createChainNodeFactory = (scanner: Scanner, parentNode?: ChainNode) => (
...elements: any[]
): ChainNode => {
// 生成第一個節點
return firstNode;
};
需要說明兩點:
- chain 函式返回第一個連結串列節點,就可以通過 visiter 函式訪問整條連結串列了。
-
(...elements: any[]): ChainNode
就是 chain 函式本身,它接收一系列引數,根據型別進行功能分類。
有了 createChainNodeFactory,我們就可以生成執行入口了:
const chainNodeFactory = createChainNodeFactory(scanner);
const firstNode = chainNodeFactory(root); // const root = (chain: IChain) => chain(`select`, `*`, `from`, `table`, `;`)
為了支援 chain(`select`, `*`, `from`, `table`, `;`)
語法,我們需要在引數型別是文字型別時,自動生成一個 matchToken 函式作為連結串列節點,同時通過 reduce 函式將連結串列節點關聯上:
const createChainNodeFactory = (scanner: Scanner, parentNode?: ChainNode) => (
...elements: any[]
): ChainNode => {
let firstNode: ChainNode = null;
elements.reduce((prevNode: ChainNode, element) => {
const node = new ChainNode();
// ... Link node
node.addChild(createChainChildByElement(node, scanner, element));
return node;
}, parentNode);
return firstNode;
};
使用 reduce 函式對連結串列上下節點進行關聯,這一步比較常規所以忽略掉,通過 createChainChildByElement 函式對傳入函式進行分類,如果 傳入函式是字串,就構造一個 matchToken 函式塞入當前連結串列的子元素,當執行連結串列時,再執行 matchToken 函式。
重點是我們對連結串列節點的處理,先介紹一下連結串列結構。
連結串列結構
class ChainNode {
public prev: ChainNode;
public next: ChainNode;
public childs: ChainChild[] = [];
}
class ChainChild {
// If type is function, when run it, will expend.
public type: "match" | "chainNode" | "function";
public node?: IMatchFn | ChainNode | ChainFunctionNode;
}
ChainNode 是對連結串列節點的定義,這裡給出了和當前文章內容相關的部分定義。這裡用到了雙向連結串列,因此每個 node 節點都擁有 prev 與 next 屬性,分別指向上一個與下一個節點,而 childs 是這個連結串列下掛載的節點,可以是 matchToken 函式、連結串列節點、或者是函式。
整個連結串列結構可能是這樣的:
node1 <-> node2 <-> node3 <-> node4
|- function2-1
|- matchToken2-1
|- node2-1 <-> node2-2 <-> node2-3
|- matchToken2-2-1
對每一個節點,都至少存在一個 child 元素,如果存在多個子元素,則表示這個節點是 tree 節點,存在分支情況。
而節點型別 ChainChild
也可以從定義中看到,有三種型別,我們分別說明:
matchToken 型別
這種型別是最基本型別,由如下程式碼生成:
chain("word");
連結串列執行時,match 是最基本的執行單元,決定了語句是否能匹配,也是唯一會消耗 Token 的單元。
node 型別
連結串列節點的子節點也可能是一個節點,類比巢狀函式,由如下程式碼生成:
chain(chain("word"));
也就是 chain 的一個元素就是 chain 本身,那這個 chain 子連結串列會作為父級節點的子元素,當執行到連結串列節點時,會進行深度優先遍歷,如果執行通過,會跳到父級繼續尋找下一個節點,其執行機制類比函式呼叫棧的進出關係。
函式型別
函式型別非常特別,我們不需要遞迴展開所有函式型別,因為文法可能存在無限遞迴的情況。
好比一個迷宮,很多區域都是相同並重復的,如果將迷宮完全展開,那迷宮的大小將達到無窮大,所以在計算機執行時,我們要一步步展開這些函式,讓迷宮結束取決於 Token 消耗完、走出迷宮、或者 match 不上 Token,而不是在生成迷宮時就將資源消耗完畢。函式型別節點由如下程式碼生成:
chain(root);
所有函式型別節點都會在執行到的時候展開,在展開時如果再次遇到函式節點仍會保留,等待下次執行到時再展開。
分支
普通的鏈路只是分支的特殊情況,如下程式碼是等價的:
chain("a");
chain(["a"]);
再對比如下程式碼:
chain(["a"]);
chain(["a", "b"]);
無論是直線還是分支,都可以看作是分支路線,而直線(無分支)的情況可以看作只有一條分叉的分支,對比到連結串列節點,對應 childs 只有一個元素的連結串列節點。
回溯
現在 chain 函式已經支援了三種子元素,一種分支表達方式:
chain("a"); // MatchNode
chain(chain("a")); // ChainNode
chain(foo); // FunctionNode
chain(["a"]); // 分支 -> [MatchNode]
而上文提到了 chain 函式並不是立即執行的,所以我們在執行這些程式碼時,只是生成連結串列結構,而沒有真正執行內容,內容包含在 childs 中。
我們需要構造 execChain 函式,拿到連結串列的第一個節點並通過 visiter 函式遍歷連結串列節點來真正執行。
function visiter(
chainNode: ChainNode,
scanner: Scanner,
treeChances: ITreeChance[]
): boolean {
const currentTokenIndex = scanner.getIndex();
if (!chainNode) {
return false;
}
const nodeResult = chainNode.run();
let nestedMatch = nodeResult.match;
if (nodeResult.match && nodeResult.nextNode) {
nestedMatch = visiter(nodeResult.nextNode, scanner, treeChances);
}
if (nestedMatch) {
if (!chainNode.isFinished) {
// It`s a new chance, because child match is true, so we can visit next node, but current node is not finished, so if finally falsely, we can go back here.
treeChances.push({
chainNode,
tokenIndex: currentTokenIndex
});
}
if (chainNode.next) {
return visiter(chainNode.next, scanner, treeChances);
} else {
return true;
}
} else {
if (chainNode.isFinished) {
// Game over, back to root chain.
return false;
} else {
// Try again
scanner.setIndex(currentTokenIndex);
return visiter(chainNode, scanner, treeChances);
}
}
}
上述程式碼中,nestedMatch 類比巢狀函式,而 treeChances 就是實現回溯的關鍵。
當前節點執行失敗時
由於每個節點都包含 N 個 child,所以任何時候執行失敗,都給這個節點的 child 打標,並判斷當前節點是否還有子節點可以嘗試,並嘗試到所有節點都失敗才返回 false。
當前節點執行成功時,進行位置存檔
當節點成功時,為了防止後續鏈路執行失敗,需要記錄下當前執行位置,也就是利用 treeChances 儲存一個存檔點。
然而我們不知道何時整個連結串列會遭遇失敗,所以必須等待整個 visiter 執行完才知道是否執行失敗,所以我們需要在每次執行結束時,判斷是否還有存檔點(treeChances):
while (!result && treeChances.length > 0) {
const newChance = treeChances.pop();
scanner.setIndex(newChance.tokenIndex);
result = judgeChainResult(
visiter(newChance.chainNode, scanner, treeChances),
scanner
);
}
同時,我們需要對連結串列結構新增一個欄位 tokenIndex,以備回溯還原使用,同時呼叫 scanner 函式的 setIndex
方法,將 token 位置還原。
最後如果機會用盡,則匹配失敗,只要有任意一次機會,或者能一命通關,則匹配成功。
3 總結
本篇文章,我們利用連結串列重寫了函式執行機制,不僅使匹配函式擁有了回溯能力,還讓其表達更為直觀:
chain("a");
這種構造方式,本質上與根據文法結構編譯成程式碼的方式是一樣的,只是許多詞法解析器利用文字解析成程式碼,而我們利用程式碼表達出了文法結構,同時自身執行後的結果就是 “編譯後的程式碼”。
下次我們將探討如何自動解決左遞迴問題,讓我們能夠寫出這樣的表示式:
const foo = (chain: IChain) => chain(foo, bar);
好在 chain 函式並不是立即執行的,我們不會立即掉進堆疊溢位的漩渦,但在執行節點的過程中,會導致函式無限展開從而堆疊溢位。
解決左遞迴併不容易,除了手動或自動重寫文法,還會有其他方案嗎?歡迎留言討論。
4 更多討論
如果你想參與討論,請點選這裡,每週都有新的主題,週末或週一釋出。
相關文章
- 精讀《手寫 SQL 編譯器 – 詞法分析》SQL編譯詞法分析
- 精讀《手寫 SQL 編譯器 - 語法樹》SQL編譯
- 精讀《手寫 SQL 編譯器 - 錯誤提示》SQL編譯
- 精讀《手寫 SQL 編譯器 - 語法分析》SQL編譯語法分析
- 精讀《手寫 SQL 編譯器 - 文法介紹》SQL編譯
- 精讀《手寫 SQL 編譯器 – 文法介紹》SQL編譯
- 精讀《手寫 SQL 編譯器 - 詞法分析》SQL編譯詞法分析
- 精讀《手寫 JSON Parser》JSON
- 手寫 Vue2 系列 之 編譯器Vue編譯
- 精讀《編寫有彈性的元件》元件
- 精讀《如何編譯前端專案與元件》編譯前端元件
- 使用C編譯器編寫shellcode編譯
- Vue原理解析:手寫編譯器(節點解析) —— CompileVue編譯Compile
- 精讀Nginx·自動指令碼篇(5)編譯器相關主指令碼Nginx指令碼編譯
- 【譯】使用 Python 編寫虛擬機器直譯器Python虛擬機
- javascript編寫一個簡單的編譯器JavaScript編譯
- 寫給小白的開源編譯器編譯
- JavaScript 編寫的迷你 Lisp 直譯器JavaScriptLisp
- 人人都能讀懂的編譯器原理編譯
- C語言編譯器手機版C語言編譯
- 自己動手寫basic直譯器 一
- gcc 編譯器與 clang 編譯器GC編譯
- 用 JavaScript 寫一個超小型編譯器JavaScript編譯
- 寫程式碼時,編譯器比你聰明編譯
- SQL WHERE IN引數化編譯寫法簡單示例SQL編譯
- 前端與編譯原理——用 JS 寫一個 JS 直譯器前端編譯原理JS
- 前端與編譯原理——用JS寫一個JS直譯器前端編譯原理JS
- 淺談彙編器、編譯器和直譯器編譯
- [譯]iOS編譯器iOS編譯
- 如何讓Java編譯器幫你寫程式碼Java編譯
- 用 Haskell 編寫 CEK 風格的直譯器Haskell
- 學習較底層程式設計:動手寫一個C語言編譯器程式設計C語言編譯
- EBS SQL編寫心得SQL
- 用 golang 寫一個語言(編譯器,虛擬機器)Golang編譯虛擬機
- Sublime 編寫編譯 swift程式碼編譯Swift
- 第一個C語言編譯器是怎樣編寫的?C語言編譯
- 第一個 C 語言編譯器是怎樣編寫的?編譯
- 編譯器的編譯基本過程編譯