接着上周的文法介绍,本周介绍的是语法分析。git
以解析顺序为角度,语法分析分为两种,自顶而下与自底而上。github
自顶而下通常采用递归降低方式处理,称为 LL(k),第一个 L 是指从左到右分析,第二个 L 指从左开始推导,k 是指超前查看的数量,若是实现了回溯功能,k 就是无限大的,因此带有回溯功能的 LL(k) 几乎是最强大的。LL 系列通常分为 LL(0)、LL(1)、LL(k)、LL(∞)。sql
自底而上通常采用移进(shift)规约(reduce)方式处理,称为 LR,第一个 L 也是从左到右分析,第二个 R 指从右开始推导,而规约时可能产生冲突,因此经过超前查看一个符号解决冲突,就有了 SLR,后面还有功能更强的 LALR(1) LR(1) LR(k)。typescript
经过这张图能够看到 LL 家族与 LR 家族的能力范围:函数
如图所示,不管 LL 仍是 LR 都解决不了二义性文法,还好全部计算机语言都属于无二义性文法。优化
值得一提的是,若是实现了回溯功能的 LL(k) -> LL(∞),那么能力就能够与 LR(k) 所比肩,而 LL 系列手写起来更易读,因此笔者采用了 LL 方式书写,今天介绍如何手写无回溯功能的 LL。spa
另外也有一些根据文法自动生成 parser 的库,好比兼容多语言的 antlr4 或者对 js 支持比较友好的 pegjs。
递归降低能够理解为走多出口的迷宫:code
咱们先根据 SQL 语法构造一个迷宫,进迷宫的不是探险家,而是 SQL 语句,这个 SQL 语句会拿上一堆令牌(切分好的 Tokens,详情见 精读:词法分析),迷宫每前进一步都会要求按顺序给出令牌(交上去就没收),若是走到出口令牌恰好交完,就成功走出了迷宫;若是出迷宫时手上还有令牌,会被迷宫工做人员带走。这个迷宫会有一些分叉,在分岔路上会要求你亮出几个令牌中任意一个便可经过(LL1),有的迷宫容许你失败了存档,只要没有走出迷宫,均可以读档重来(LLk),理论上能够构造一个最宽容的迷宫,只要还没走出迷宫,能够在分叉处任意读档(LL∞),这个留到下一篇文章介绍。blog
首先对 SQL 进行词法分析,拿到 Tokens 列表,这些就是探险家 SQL 带上的令牌。递归
根据上次讲的内容,咱们对 select a from b
进行词法分析,能够拿到四个 Token(忽略空格与注释)。
递归降低最重要的就是 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
咱们一下遇到了两个问题:
同理,利用函数调用,咱们假定拥有了可选函数 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]+/)
递归降低的 SQL 语法解析就是一个走迷宫的过程,将 Token 从左到右逐个匹配,最终能找到一条路线彻底贴合 Token,则 SQL 解析圆满结束,这个迷宫采用空字符串产生式、单词匹配、链接运算、并运算这四个基本文法组合就足以构成。
掌握了这四大法宝,基本的 SQL 解析已经难不倒你了,下一步须要作这些优化:
下篇文章会介绍如何实现回溯,让递归降低达到 LL(∞) 的效果。
从本文不难看出,经过函数调用方式咱们没法作到 迷宫存档和读档机制,也就是遇到岔路 A B 时,若是 A 成功了,函数调用栈就会退出,然后面迷宫探索失败的话,咱们没法回到岔路 B 继续探索。而 回溯功能就赋予了这个探险者返回岔路 B 的能力。
为了实现这个功能,几乎要彻底推翻这篇文章的代码组织结构,不过别担忧,这四个基本组合思想还会保留。
下篇文章也会放出一个真正能运行的,实现了 LL(∞) 的代码库,函数描述更精简,功能(比这篇文章的方法)更强大,敬请期待。
讨论地址是: 精读《手写 SQL 编译器 - 语法分析》 · Issue #95 · dt-fe/weekly
若是你想参与讨论,请点击这里,每周都有新的主题,周末或周一发布。