精读《手写 SQL 编译器 - 语法分析》

1 弁言

接着上周的文法引见,本周引见的是语法剖析。

以剖析递次为角度,语法剖析分为两种,自顶而下与自底而上。

自顶而下平常采纳递归下落体式格局处置惩罚,称为 LL(k),第一个 L 是指从左到右剖析,第二个 L 指从左最先推导,k 是指超前检察的数目,假如完成了回溯功用,k 就是无限大的,所以带有回溯功用的 LL(k) 险些是最壮大的。LL 系列平常分为 LL(0)、LL(1)、LL(k)、LL(∞)。

自底而上平常采纳移进(shift)规约(reduce)体式格局处置惩罚,称为 LR,第一个 L 也是从左到右剖析,第二个 R 指从右最先推导,而规约时能够发生争执,所以经由历程超前检察一个标记处理争执,就有了 SLR,背面另有功用更强的 LALR(1) LR(1) LR(k)

经由历程这张图能够看到 LL 家属与 LR 家属的才能局限:

《精读《手写 SQL 编译器 - 语法分析》》

如图所示,不管 LL 照样 LR 都处理不了二义性文法,还好一切计算机语言都属于无二义性文法。

值得一提的是,假如完成了回溯功用的 LL(k) -> LL(∞),那末才能就可以够与 LR(k) 所比肩,而 LL 系列手写起来更易读,所以笔者采纳了 LL 体式格局誊写,本日引见怎样手写无回溯功用的 LL。

别的也有一些依据文法自动天生 parser 的库,比方兼容多语言的
antlr4 或许对 js 支撑比较友爱的
pegjs

2 精读

递归下落能够理解为走多出口的迷宫:

《精读《手写 SQL 编译器 - 语法分析》》

我们先依据 SQL 语法构造一个迷宫,进迷宫的不是探险家,而是 SQL 语句,这个 SQL 语句会拿上一堆令牌(切分好的 Tokens,概况见 精读:词法剖析),迷宫每行进一步都邑请求按递次给出令牌(交上去就充公),假如走到出口令牌正好交完,就胜利走出了迷宫;假如出迷宫时手上另有令牌,会被迷宫工作人员带走。这个迷宫会有一些分叉,在分岔道上会请求你亮出几个令牌中恣意一个即可经由历程(LL1),有的迷宫许可你失利了存档,只需没有走出迷宫,都能够读档重来(LLk),理论上能够构造一个最宽大的迷宫,只需还没走出迷宫,能够在分叉处恣意读档(LL∞),这个留到下一篇文章引见。

词法剖析

起首对 SQL 举行词法剖析,拿到 Tokens 列表,这些就是探险家 SQL 带上的令牌。

依据上次讲的内容,我们对 select a from b 举行词法剖析,能够拿到四个 Token(疏忽空格与解释)。

Match 函数

递归下落最主要的就是 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

我们一下碰到了两个题目:

  1. 补充 word 函数。
  2. 怎样形貌可选参数。

同理,应用函数挪用,我们假定具有了可选函数 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]+/)

3 总结

递归下落的 SQL 语法剖析就是一个走迷宫的历程,将 Token 从左到右逐一婚配,终究能找到一条线路完整贴合 Token,则 SQL 剖析圆满结束,这个迷宫采纳空字符串发生式、单词婚配、衔接运算、并运算这四个基础文法组合就足以组成。

控制了这四大宝贝,基础的 SQL 剖析已难不倒你了,下一步须要做这些优化:

  • 回溯功用,完成它才能够完成 LL(∞) 的婚配才能。
  • 左递归自动消弭,由于经由历程文法转换,会转变文法的结合律与语义,最好能完成左递归自动消弭(左递归在上一篇精读 文法 有申明)。
  • 天生语法树,仅婚配语句的正确性是不够的,我们还要依据语义天生语法树。
  • 毛病搜检,在毛病的处所给出发起,以至对某些毛病做自动修复,这个在左 SQL 智能提醒时须要用到。
  • 毛病恢复。

下篇文章会引见怎样完成回溯,让递归下落到达 LL(∞) 的结果。

从本文不难看出,经由历程函数挪用体式格局我们没法做到 迷宫存档和读档机制,也就是碰到岔道 A B 时,假如 A 胜利了,函数挪用栈就会退出,而背面迷宫探究失利的话,我们没法回到岔道 B 继承探究。而 回溯功用就给予了这个探险者返回岔道 B 的才能

为了完成这个功用,险些要完整颠覆这篇文章的代码构造构造,不过别忧郁,这四个基础组合头脑还会保存。

下篇文章也会放出一个真正能运转的,完成了 LL(∞) 的代码库,函数形貌更精简,功用(比这篇文章的要领)更壮大,敬请期待。

4 更多议论

议论地点是:
精读《手写 SQL 编译器 – 语法剖析》 · Issue #95 · dt-fe/weekly

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

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