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

1 引言

上回 精读《手写 SQL 编译器 - 语法分析》 说到了如何利用 Js 函数实现语法分析时,留下了一个回溯问题,也就是存档、读档问题。node

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

上一篇咱们实现了 分支函数,在分支执行失败后回滚 TokenIndex 位置并重试,但在函数调用栈中,若是其子函数执行完毕,堆栈跳出,咱们便没法找到原来的函数栈从新执行。github

为了更加详细的描述这个问题,举一个例子,存在如下岔路:数组

clipboard.png

上面描述了两条判断分支,分别是 a -> b1 -> b1' -> c 与 a -> b2 -> b2' -> c,当岔路 b1 执行失败后,分支函数 tree 能够复原到 b2 位置尝试从新执行。闭包

但设想 b1 -> b1' 经过,但 b1 -> b1' -> c 不经过的场景,因为 b1' 执行完后,分支函数 tree 的调用栈已经退出,没法再尝试路线 b2 -> b2' 了。函数

要解决这个问题,咱们要 经过链表手动构造函数执行过程,这样不只能够实现任意位置回溯,还能够解决左递归问题,由于函数并非当即执行的,在执行前咱们能够加一些 Magic 动做,好比调换执行顺序!这文章主要介绍如何经过链表构造函数调用栈,并实现回溯。优化

2 精读

假设咱们拥有了这样一个函数 chain,能够用更简单的方式表示连续匹配:spa

clipboard.png

遇到分支条件时,经过数组表示取代 tree 函数:3d

clipboard.png

这个 chain 函数有两个特质:blog

  1. 非当即执行,咱们就能够 预先生成执行链条 ,并对链条结构进行优化、甚至控制执行顺序,实现回溯功能。
  2. 无需显示传递 Token,减小每一步匹配写的代码量。

封装 scanner、matchToken

咱们能够制做 scanner 函数封装对 token 的操做:

clipboard.png

scanner 拥有两个主要功能,分别是 read 读取当前 token 内容,和 next 将 token 向下移动一位,咱们能够根据这个功能封装新的 matchToken 函数:

clipboard.png

若是 token 消耗完,或者与比对不匹配时,返回 false 且不消耗 token,当匹配时,消耗一个 token 并返回 true。

如今咱们就能够用 matchToken 函数写一段匹配代码了:

clipboard.png

咱们最终但愿表达成这样的结构:

clipboard.png

既然 chain 函数做为线索贯穿整个流程,那 scanner 函数须要被包含在 chain 函数的闭包里内部传递,因此咱们须要构造出第一个 chain。

封装 createChainNodeFactory

咱们须要 createChainNodeFactory 函数将 scanner 传进去,在内部偷偷存起来,不要在外部代码显示传递,并且 chain 函数是一个高阶函数,不会当即执行,由此能够封装二阶函数:

clipboard.png

须要说明两点:

  1. chain 函数返回第一个链表节点,就能够经过 visiter 函数访问整条链表了。
  2. (...elements: any[]): ChainNode 就是 chain 函数自己,它接收一系列参数,根据类型进行功能分类。

有了 createChainNodeFactory,咱们就能够生成执行入口了:

clipboard.png

为了支持 chain('select', '*', 'from', 'table', ';') 语法,咱们须要在参数类型是文本类型时,自动生成一个 matchToken 函数做为链表节点,同时经过 reduce 函数将链表节点关联上:

clipboard.png

使用 reduce 函数对链表上下节点进行关联,这一步比较常规因此忽略掉,经过 createChainChildByElement 函数对传入函数进行分类,若是 传入函数是字符串,就构造一个 matchToken 函数塞入当前链表的子元素,当执行链表时,再执行 matchToken 函数。

重点是咱们对链表节点的处理,先介绍一下链表结构。

链表结构

clipboard.png

ChainNode 是对链表节点的定义,这里给出了和当前文章内容相关的部分定义。这里用到了双向链表,所以每一个 node 节点都拥有 prev 与 next 属性,分别指向上一个与下一个节点,而 childs 是这个链表下挂载的节点,能够是 matchToken 函数、链表节点、或者是函数。

整个链表结构多是这样的:

clipboard.png

对每个节点,都至少存在一个 child 元素,若是存在多个子元素,则表示这个节点是 tree 节点,存在分支状况。

而节点类型 ChainChild 也能够从定义中看到,有三种类型,咱们分别说明:

matchToken 类型

这种类型是最基本类型,由以下代码生成:

clipboard.png

链表执行时,match 是最基本的执行单元,决定了语句是否能匹配,也是惟一会消耗 Token 的单元。

node 类型

链表节点的子节点也多是一个节点,类比嵌套函数,由以下代码生成:

clipboard.png

也就是 chain 的一个元素就是 chain 自己,那这个 chain 子链表会做为父级节点的子元素,当执行到链表节点时,会进行深度优先遍历,若是执行经过,会跳到父级继续寻找下一个节点,其执行机制类比函数调用栈的进出关系。

函数类型

函数类型很是特别,咱们不须要递归展开全部函数类型,由于文法可能存在无限递归的状况。

比如一个迷宫,不少区域都是相同并重复的,若是将迷宫彻底展开,那迷宫的大小将达到无穷大,因此在计算机执行时,咱们要一步步展开这些函数,让迷宫结束取决于 Token 消耗完、走出迷宫、或者 match 不上 Token,而不是在生成迷宫时就将资源消耗完毕。函数类型节点由以下代码生成:

clipboard.png

全部函数类型节点都会在执行到的时候展开,在展开时若是再次遇到函数节点仍会保留,等待下次执行到时再展开。

分支

普通的链路只是分支的特殊状况,以下代码是等价的:

clipboard.png

再对好比下代码:

clipboard.png

不管是直线仍是分支,均可以看做是分支路线,而直线(无分支)的状况能够看做只有一条分叉的分支,对比到链表节点,对应 childs 只有一个元素的链表节点。

回溯

如今 chain 函数已经支持了三种子元素,一种分支表达方式:

clipboard.png

而上文提到了 chain 函数并非当即执行的,因此咱们在执行这些代码时,只是生成链表结构,而没有真正执行内容,内容包含在 childs 中。

咱们须要构造 execChain 函数,拿到链表的第一个节点并经过 visiter 函数遍历链表节点来真正执行。

clipboard.png

上述代码中,nestedMatch 类比嵌套函数,而 treeChances 就是实现回溯的关键。

当前节点执行失败时

因为每一个节点都包含 N 个 child,因此任什么时候候执行失败,都给这个节点的 child 打标,并判断当前节点是否还有子节点能够尝试,并尝试到全部节点都失败才返回 false。

当前节点执行成功时,进行位置存档

当节点成功时,为了防止后续链路执行失败,须要记录下当前执行位置,也就是利用 treeChances 保存一个存盘点。

然而咱们不知道什么时候整个链表会遭遇失败,因此必须等待整个 visiter 执行完才知道是否执行失败,因此咱们须要在每次执行结束时,判断是否还有存盘点(treeChances):

clipboard.png

同时,咱们须要对链表结构新增一个字段 tokenIndex,以备回溯还原使用,同时调用 scanner 函数的 setIndex 方法,将 token 位置还原。

最后若是机会用尽,则匹配失败,只要有任意一次机会,或者能一命通关,则匹配成功。

3 总结

本篇文章,咱们利用链表重写了函数执行机制,不只使匹配函数拥有了回溯能力,还让其表达更为直观:

clipboard.png

这种构造方式,本质上与根据文法结构编译成代码的方式是同样的,只是许多词法解析器利用文本解析成代码,而咱们利用代码表达出了文法结构,同时自身执行后的结果就是 “编译后的代码”。

下次咱们将探讨如何自动解决左递归问题,让咱们可以写出这样的表达式:

clipboard.png

好在 chain 函数并非当即执行的,咱们不会当即掉进堆栈溢出的漩涡,但在执行节点的过程当中,会致使函数无限展开从而堆栈溢出。

解决左递归并不容易,除了手动或自动重写文法,还会有其余方案吗?欢迎留言讨论。

本文做者:翱翔大空

阅读原文

本文为云栖社区原创内容,未经容许不得转载。

相关文章
相关标签/搜索