上回 精讀《手寫 SQL 編譯器 - 語法分析》 說到了如何利用 Js 函數實現語法分析時,留下了一個回溯問題,也就是存檔、讀檔問題。node
咱們把語法分析樹看成一個迷宮,有直線有岔路,而想要走出迷宮,在遇到岔路時須要提早進行存檔,在後面走錯時讀檔換下一個岔路進行嘗試,這個功能就叫回溯。git
上一篇咱們實現了 分支函數,在分支執行失敗後回滾 TokenIndex 位置並重試,但在函數調用棧中,若是其子函數執行完畢,堆棧跳出,咱們便沒法找到原來的函數棧從新執行。github
爲了更加詳細的描述這個問題,舉一個例子,存在如下岔路:typescript
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'
了。bash
要解決這個問題,咱們要 經過鏈表手動構造函數執行過程,這樣不只能夠實現任意位置回溯,還能夠解決左遞歸問題,由於函數並非當即執行的,在執行前咱們能夠加一些 Magic 動做,好比調換執行順序!這文章主要介紹如何經過鏈表構造函數調用棧,並實現回溯。閉包
假設咱們擁有了這樣一個函數 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
函數有兩個特質:ui
咱們能夠製做 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 函數將 scanner 傳進去,在內部偷偷存起來,不要在外部代碼顯示傳遞,並且 chain 函數是一個高階函數,不會當即執行,由此能夠封裝二階函數:
const createChainNodeFactory = (scanner: Scanner, parentNode?: ChainNode) => (
...elements: any[]
): ChainNode => {
// 生成第一個節點
return firstNode;
};
複製代碼
須要說明兩點:
(...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
也能夠從定義中看到,有三種類型,咱們分別說明:
這種類型是最基本類型,由以下代碼生成:
chain("word");
複製代碼
鏈表執行時,match 是最基本的執行單元,決定了語句是否能匹配,也是惟一會消耗 Token 的單元。
鏈表節點的子節點也多是一個節點,類比嵌套函數,由以下代碼生成:
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 位置還原。
最後若是機會用盡,則匹配失敗,只要有任意一次機會,或者能一命通關,則匹配成功。
本篇文章,咱們利用鏈表重寫了函數執行機制,不只使匹配函數擁有了回溯能力,還讓其表達更爲直觀:
chain("a");
複製代碼
這種構造方式,本質上與根據文法結構編譯成代碼的方式是同樣的,只是許多詞法解析器利用文本解析成代碼,而咱們利用代碼表達出了文法結構,同時自身執行後的結果就是 「編譯後的代碼」。
下次咱們將探討如何自動解決左遞歸問題,讓咱們可以寫出這樣的表達式:
const foo = (chain: IChain) => chain(foo, bar);
複製代碼
好在 chain 函數並非當即執行的,咱們不會當即掉進堆棧溢出的漩渦,但在執行節點的過程當中,會致使函數無限展開從而堆棧溢出。
解決左遞歸併不容易,除了手動或自動重寫文法,還會有其餘方案嗎?歡迎留言討論。
若是你想參與討論,請點擊這裏,每週都有新的主題,週末或週一發佈。