3

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

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

PEG 与 LPEG

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

模式

人通常无法在短时间内识别自己从未见过的事物,但是却能够想象自己从未见过的事物。这也许是因为,凡是见过的事物,就会在大脑里形成相应的模式。这些模式经过分割重组,便可以构造自己从未见过的事物的想象,而对于新的事物,大脑里既有的任何一种模式,一时之间皆无法与之匹配,因而便无法将其识别出来。当然,这并非意味着新的事物永远不能被我们识别。通常情况下,既有的模式可以再度分割重组,再经过一些时间之后,有可能会建立与这种新的事物匹配的模式。

LPEG 只能替我们完成模式与文本的匹配工作,因为它即没有制造模式的能力,也没有获取输入的能力。若让 LPEG 识别某种语言,我们需要将自己对这种语言的认知转化为模式,然后以 LPEG 能够接受的方式传递给它,之后再将待解析的文本传递给它。不过,幸好 LPEG 不具备这些能力。

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

对现实更宽容的人,在遭遇失望时,会对大脑中的已有模式进行调整。若我足够宽容,会认为,好在我的这把刀还有 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 方法,专事于匹配。

我是个宽容的人,我希望传递给 LPEG 的模式也能够宽容一些。因此,我对 knife 模式作了以下调整:

> 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] 怎样写一个解释器


garfileo
6k 声望1.9k 粉丝

这里可能不会再更新了。


« 上一篇
改造
下一篇 »
对齐