精读《手写 SQL 编译器 - 回溯》

1 弁言

上回 精读《手写 SQL 编译器 – 语法分析》 说到了怎样应用 Js 函数完成语法分析时,留下了一个回溯题目,也就是存档、读档题目。

我们把语法分析树看成一个迷宫,有直线有岔道,而想要走出迷宫,在碰到岔道时须要提早举行存档,在后面走错时读档换下一个岔道举行尝试,这个功用就叫回溯。

上一篇我们完成了 分支函数,在分支实行失利后回滚 TokenIndex 位置并重试,但在函数挪用栈中,假如其子函数实行终了,客栈跳出,我们便没法找到本来的函数栈从新实行。

为了越发细致的形貌这个题目,举一个例子,存在以下岔道:

a -> tree() -> c
     -> b1 -> b1'
     -> b2 -> b2'

上面形貌了两条推断分支,离别是 a -> b1 -> b1' -> ca -> 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 函数有两个特质:

  1. 非马上实行,我们就可以 预先天生实行链条 ,并对链条构造举行优化、以至掌握实行递次,完成回溯功用。
  2. 无需显现通报 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;
};

须要申明两点:

  1. chain 函数返回第一个链表节点,就可以经由历程 visiter 函数接见整条链表了。
  2. (...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 编译器 – 回溯》 · Issue #96 · dt-fe/weekly

假如你想介入议论,请点击这里,每周都有新的主题,周末或周一宣布。

    原文作者:黄子毅
    原文地址: https://segmentfault.com/a/1190000015810070
    本文转自网络文章,转载此文章仅为分享知识,如有侵权,请联系博主进行删除。
点赞