解析

大千世界,茫茫人海,我老是能够一眼便认出你。这个过程里包含着一个叫作解析的过程。计算机程序也可以经过这样的过程,在一堆文本中认出一些特定形式的文本。在短暂又漫长的计算机语言编译原理的发展过程当中,诞生了不少种形式化文本解析方法,PEG 是其中一种。node

注:在写这篇文档的时候,我没学习过编译原理,仅对正则表达式略知一二。如果有的地方错得离谱,要么让我错下去,要么就帮我改正。

PEG 与 LPEG

PEG,全称是 Parsing Expression Grammar,可译做「解析表达式语法」。PEG 所肩负的历史重任是取代正则表达式(RE)以及对正则表达式的一些特定扩展。LPEG 是 PEG 的 Lua 实现,这意味着 PEG 像是一种语言解析算法,亦即经过 PEG,可解析某种形式化语言。ConTeXt MkIV 即是经过 LPEG 库解析待高亮处理的程序代码 [1]。正则表达式

模式

人一般没法在短期内识别本身从未见过的事物,可是却可以想象本身从未见过的事物。这也许是由于,凡是见过的事物,就会在大脑里造成相应的模式。这些模式通过分割重组,即可以构造本身从未见过的事物的想象,而对于新的事物,大脑里既有的任何一种模式,一时之间皆没法与之匹配,于是便没法将其识别出来。固然,这并不是意味着新的事物永远不能被咱们识别。一般状况下,既有的模式能够再度分割重组,再通过一些时间以后,有可能会创建与这种新的事物匹配的模式。算法

LPEG 只能替咱们完成模式与文本的匹配工做,由于它即没有制造模式的能力,也没有获取输入的能力。若让 LPEG 识别某种语言,咱们须要将本身对这种语言的认知转化为模式,而后以 LPEG 可以接受的方式传递给它,以后再将待解析的文本传递给它。不过,幸亏 LPEG 不具有这些能力。编程

最简单的模式莫过于「原样」。我最心爱的一把刀,若它的刃部崩出来一个豁口,我就会以为这再不是原来的刀了,并对此很介意。这是由于我为这把刀所创建的模式与现实中崩口的刀再也不匹配。大脑里的模式与实际的事物匹配不起来,结果只有一种,即失望。模式越具体,越容易失望。一把崩口的刀,豁口在刀的总体所占的比例即便不过 1%,但我会由于这 1% 的失望而忽略剩下那 99% 的无缺。segmentfault

对现实更宽容的人,在遭遇失望时,会对大脑中的已有模式进行调整。若我足够宽容,会认为,好在个人这把刀还有 99% 的地方是好的。因而,我在大脑中更新了这把刀的模式,这个模式会持续到它下一次受到损坏之时。编程语言

世上最宽容的人也许是柏拉图。他认为存在一个理型世界,这个世界里的一切都是由很是具体(完美)的模式构成。或者说,这个理型世界里具备咱们现实世界里一切事物的模具。模具老是要比它铸造出来的事物更完美。不过,柏拉图只能在大脑里构造这种世界,实际上他构造的只是一种又一种模式罢了。也许是他对现实过于失望,因此便又构造了一个大但愿——咱们生自理型世界,一辈子的追求只是为了回归这个世界。函数

LPEG 不具有创建模式的功能,所以,它在遭遇了一把刀受到损伤之时,只会单纯地失望:学习

$ lua
Lua 5.3.3  Copyright (C) 1994-2016 Lua.org, PUC-Rio
> lpeg = require("lpeg")
> knife = lpeg.P("knife")
> knife:match("knif e")
nil
注 1:这是 Lua 解释器在加载了 LPEG 库以后开启的交互模式。

注 2:knife:match("knif e") 意思是用 knife 这个模式去与 "knif e" 这个字串进行匹配。LPEG 为每一个模式提供了 match 方法,专事于匹配。ui

我是个宽容的人,我但愿传递给 LPEG 的模式也可以宽容一些。所以,我对 knife 模式做了如下调整:lua

> P = lpeg.P
> s = P(" ")^0
> knife = P("k") * s *  P("n") * s *  P("i") * s * P("f") * s * P("e")
> x = "k      n  i   f               e"
> knife:match(x)
32

只要匹配结果不是 nil 就说明模式与事物(在程序里就是字串或文本)匹配成功。上述匹配结果 32,意思是字串 x 的前 31 个字符匹配成功。因为 x 的长度是 31,所以这个结果意味着 x 与模式 knife 彻底匹配,不管它身上有多么大的「崩口」。

上述我创建的 knife 模式,它的含义是,在 k、n、i、f、e 这些字母之间能够存在 0 个或多个空格。显然,x 符合这种模式,所以我便不会失望。糊涂很可贵到,由于须要将本身大脑里的各类模式调整到可以包容各类事物的程度。改变不了世界,就去改变本身,这须要耗费极大心力。不少人不是真的糊涂,而是装糊涂。

LPEG 库提供的 P 函数用于建立简单或基本模式,也就是原样模式。若想让模式具有足够的包容性,须要对原样模式进行分割与组合。在 LPEG 中,模式之间具有加法、乘法、减法以及幂运算。

在上述对 knife 模式的调整中,我用了幂运算符 ^ 和乘法运算符 *P(" ") 建立的是一个空格模式,对它取 0 次幂,即 s = P(" ")^0,意思是这个空格模式会连续出现 0 次或更屡次。

当我将 s 放到 P("k")P("n") 之间,再用乘法运算符 * 将它们链接起来,即 P("k") * s * P("n"),这意味着我构造了一个「字母 k 与 n 之间可能存在空格」的模式,亦即这个模式可以匹配

kn
k n
k  n
k   n
... ... ...

这种形式的字串。显然,它比 P("k") * P("n") 更宽容。

乘法运算能够将一些模式组织成一个系统。例如「P("k") * P("n") * P("i") * P("f") * P("e")」 与「P("knife")」等价。加法运算则并不是如此。例如「P("k") + P("n") + P("i") + P("f") + P("e")」,它的意思是字母集合 {k, n, i, f, e} 中的一个字母,因此它只能匹配一个字母,而不是一个字串。

注:「 P("k") + P("n") + P("i") + P("f") + P("e")」与 lpeg.S("knife") 等价。 S 是「集合(Set)」的缩写。

当我说乘法的本质是用一些元素构建一个系统,意思是说这些元素与这个系统中的其余全部元素存在联系。加法却构建不起来这样的系统。若是说乘法运算能够将一些零件组装成一部机器,那么加法只能算是把这些零件简单地堆了起来。

减法运算的意思是某事物不能出现,或者除某事物以外。例如,P(1) 的意思是「任意一个字母」,那么 P(1) - P("n"))意思就是「除了字母 n 以外的全部字母中的任意一个」。

注:在有运算符的状况下, P(1) 可简写为 1。所以, P(1) - P("n")) 可写为 1 - P("n")

注:P(n) 表示任意 n 个字母。P(-1) 相似于正则表达式里的 $,表示字串的结尾。

知道了上述知识,就能够利用它们去写一些复杂的模式了。

复杂模式

假设有一个字串 "有事能够给我发邮件,个人邮箱是 lyr.m2@live.cn"。我能够写出一个可以匹配这个字串的模式:

> x = "有事能够给我发邮件,个人邮箱是 lyr.m2@live.cn"
> pat = P(1)^0
> pat:match(x)
61

实际上 P(1)^0 能够匹配任何字串,由于它的意思是「任意一个字母出现 0 次或屡次」,这是最为宽容的模式。

如今,若要限定 pat 只匹配到 x 中的电子邮箱的的首字母位置,可做如下修改:

> R = lpeg.R
> S = lpeg.S
> user = (R("az") + S("._") + R("09"))^0
> server = (R("az") + S(".-_") + R("09"))^0
> mail = user * P("@") * server
> pat = (1 - mail)^0
> pat:match(x)
47

这些代码的玄机有二。首先是邮箱地址模式的构造:

> user = (R("az") + S(".-_") + R("09") - P("@"))^1
> server = user
> mail = user * P("@") * server

这里我使用了 LPEG 的 RS 函数。S 函数的用处在上文中已述。R 函数的用法与 S 类似,也是表达一个集合,可是 R 表示的是字母或数字范围。例如 R("az") 表示由 a 到 z 的的全部小写字母构成的集合,而 R("09") 则表示从数字 0 到 9 的全部数字构成的集合。利用模式的加法、乘法和幂运算,就能够构造一个可以匹配相似 lyr.m2@live.cn 这样的邮箱地址的模式。

因为前面提出的限定是,pat 只匹配到 x 中的电子邮箱的的首字母位置。将这个限定引入到原先的 P(1)^0,结果就是:

pat = (1 - mail)^0

熟悉正则表达式的人,在这里必定要注意了,mail 模式所能匹配的事物,在 (1 - mail) 里变成了一个「字符」同样的存在,这就是上述代码中的第二个玄机。当我发现这样居然能够工做的时候,若不认可 PEG 比 RE 更强大且更好用,那么我只好怀疑,我在用错了 LPEG 的前提下,获得了正确的结果。

捕获

用模式去匹配字串,最直接的用途是断定一个字串是否与既定模式相符。更进一步的用途是从字串中捕获符合既定模式的子集,这一用途有些相似于照片编辑软件提供的「抠图」功能,后者本质上也能够认为是从一幅图片中捕获符合某种模式的局部区域。

对于字串 x

> x = "有事能够给我发邮件,个人邮箱是 lyr.m2@live.cn"

如何从中捕获邮箱地址?

LPEG 提供了 C 函数,能够将模式变为捕获器。例如

> C = lpeg.C
> pat = (1 - mail)^0 * C(mail) * (1 - mail)^0 
> pat:match(x)
lyr.m2@live.cn

如今,将 x 更改一下,

> x = "有事能够给我发邮件,个人邮箱是 lyr.m2@live.cn,也能够发给川普 trump@gmail.com。"

再使用 pat 去匹配 x

> pat:match(x)
lyr.m2@live.cn

结果里没有出现第二个邮箱地址。这是由于 pat 模式在完成一次匹配以后,若匹配成功,它的匹配过程也就终止了,所以只能捕获到字串中第一个邮件地址,我在 x 中新增长的那个邮件地址没有机会被捕获。

为了捕获字串中全部的邮件地址,须要将 pat 修改成

> pat = ((1 - mail)^0 * C(mail))^0

再度进行匹配和捕获,

> pat:match(x)
lyr.m2@live.cn    trump@gmail.com

此次获得的结果符合预期。

含有捕获的模式,与字串匹配的结果即是捕获结果。若模式中含有多个捕获,若想获得这些结果,须要用相应数量的变量去容纳模式匹配的返回值。例如:

> mail_1, mail_2 = pat:match(x)
> print(mail_1)
lyr.m2@live.cn
> print(mail_2)
trump@gmail.com

这样作有些繁琐。为此,LPEG 提供了 Ct 函数,可将模式匹配的多个结果归入一个表中。例如:

> Ct = lpeg.Ct
> pat = Ct(((1 - mail)^0 * C(mail))^0)
> result = pat:match(x)
> for i, v in pairs(result) do print(v) end
lyr.m2@live.cn
trump@gmail.com

语法

一段复杂的文本,若它能够被程序解析,那么必定存在某种语法可以与之匹配。这种语法一定是由一些简单的模式复合而成。

下面是一个简单的整数运算表达式:

> x = "20 * (5 + 6) - 30 / 2"

为了解析这个表达式,须要定义一些简单的模式:

> space = P(" ")^0
> integer = C(R("09")^1)
> add_or_sub = space * C(S("+-")) * space
> mul_or_div = space * C(S("*/")) * space
> lpar = space * C(P("(")) * space
> rpar = space * C(P(")")) * space

结合上文所涉及的 LPEG 的模式构造方法,上述模式的含义应当不难理解。如今,我要基于它们来构造一种能够解析整数运算表达式的语法。

对于 x 这样的整数运算表达式,它们不过是由一些带括号的项和整数经过 +-*/ 运算符链接起来的形式而已,下面这个模式

> V = lpeg.V
> e = V("t") + V("f") + integer

足以与之匹配,其中 V("t") 表示和式, V("f") 表示因式,integer 为整数模式。整数四则运算表达式除了这些模式以外,不可能再有其余形式。

这里使用了 LPEG 的 V 函数。LPEG 的文档 [2] 里称 V 函数能够为语法构造一个非终结符(变量)。因为我没学过编译原理,一开始看不懂这个说法。纠结了两天,发现这不过是至关于编程语言里只声明变量但不为之赋值的作法。V("f") 对于 LPEG 而言,表示一个模式,只不过它还没有被定义,V("t") 与之同理。

任何一个和式,一定是从一个因式或一个带括号的项或一个整数开始,加上或减去模式 e 可以匹配的表达式。所以,可将模式 t 定义为

> t = (V("f") + V("e_in_par") + integer) * add_or_sub * V("e")

其中,V("e_in_par") 表示带括号的项。

模式 t 的定义大有玄机。由于在模式 e 的定义中使用了未定义的模式 t,而在 模式 t 的定义中又将 e 视为未定义的模式。此时,若将 t 的定义代入到 e 的定义中,结果为

> e = (V("f") + V("e_in_par") + integer) * add_or_sub * V("e") + V("f") + integer

能够看到,V("e") 出如今自身所声明的模式 e 的定义中了。这意味着自指,或自引用。凡出现自指,必造成递归,因此上述定义的模式 e 本质上是一个递归模式。支持递归模式,PEG 的强大之处,正在于此。

同理,可将模式 f 定义为

> f = (V("e_in_par") + integer) * mul_or_div * (V("f") + V("t_in_par") + integer),

须要注意的是,fmul_or_div 右侧的部分再也不是 V(e) 了。这是由于,对于一个因式而言,它老是由因式、带括号的项或整数构成。若是将mul_or_div 右侧的部分写为 V(e),这意味着 f 模式会将 2 * 3 + 1 也视为因式,显然这是错误的。

至于 e_in_par,就是模式 e 所可以匹配的四则运算表达式的外围裹上一层括号:

> e_in_par = lpar * V("e") * rpar

至此,全部未定义的模式皆已定义完毕。亦即,现已具有一个可以匹配全部的整数四则运算语句的模式。

可是,若让带有 V("模式名") 的模式生效,必须将它们放到一个表中,而后交由 P 函数构造出一个总的模式:

> P{
      "e",
      e = V("t") + V("f") + integer,
      t = (V("f") + V("e_in_par") + integer) * add_or_sub * V("e"),
      f = (V("e_in_par") + integer) * mul_or_div * (V("f") + V("e_in_par") + inteter),
      e_in_par = lpar * V("e") * rpar
}

LPEG 将这种结构称为语法(Grammar)。

下面,采用这种语法对整数四则运算表达式进行匹配:

> calculator = P{
      "e",
      e = V("t") + V("f") + integer,
      t = (V("f") + V("e_in_par") + integer) * add_or_sub * V("e"),
      f = (V("e_in_par") + integer) * mul_or_div * (V("f") + V("e_in_par") + integer),
      e_in_par = lpar * V("e") * rpar
}
> calculator:match(x)
20    *    (    5    +    6    )    -    30    /    2

结果正确。

这个四则运算的语法,还有一种更简单的写法:

> caculator = P{
      "e",
      e = node(V("f") * (add_or_sub * (V("f") + integer))^0),
      f = node(V("t") * (mul_or_div * (V("t") + integer))^0),
      t = lpar * V("e") * rpar + integer,
}

语法树

上一节编写的语法,即 calculator,我天资愚钝,是在不断失败中写出来的。最后我发现,这是一种从上而下,从左而右生长的树形结构。这是由于在模式 tf 以及 e_in_par 中皆包含了模式 e,这意味着在 e 的定义中出现了三种形式的自指。自指必致使递归,一种事物内部的多种自指所引发的递归,一定是树形结构。所以,不妨将 calculator 视为一种具备自增加能力的模式树。

这种模式树的匹配结果一定也是树形结构。例如,calculator

> x = "20 * (5 + 6) - 30 / 2"

的匹配结果,表面上看起来与 x 的形式相同,但实际上,它的结构是树状的,以下图所示:

这样的解析结果称为语法树(Abstract Syntax Tree,AST)。

获得语法树有什么用呢?写解释器 [3]。


[1] 五光十色
[2] LPEG 文档
[3] 怎样写一个解释器

相关文章
相关标签/搜索