解析

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

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
这是一本填补“使用向导”类的VisualC++书籍、产品文档以及MFC源代码之间空隙的MFC书籍。本书是了解MFC内幕的向导,提供了关于那些没有文档记录的MFC类、实用函数和数据成员的独一无二并且透彻的信息,介绍了有用的编码技巧,并对MFC各个类之间的协作方式进行了重要的分析。 本书的第一部分包含了核心的MFC图形用户界面类以及支持它们的类,第二部分包含了像OLE这种扩展基本Windows支持的主题。如果做到以下几点,你就可以成为一位透彻理解MFC实现细节的专家:探索MFC文档/视图结构的内幕,从而学习视图同步、打印和打印预览;更深入地了解MFC序列化中那些没有文档记录的方面和一些没有文档记录的类,例如CPreview、CPreviewDC、CMirrorFile以及CDockBar等等;最后理解MFC 和OLE是如何共同运作的,以及OLE控悠扬是如何实现的;积累技巧,学会自己研究和理解MFC源代码。 目录: 前言 致谢 简介 第1章 MFC的概念性总括 面向对象编程的一些背景 面向对象编程术语 通常的对象 对象与C++ 为什么使用OOP 应用程序框架与MFC MFC要点之旅 结语 第2章 基本的Windows支持 MFC与C/SDK 基本的MFC应用程序组件 现在,找到WinMain() 一些其他隐藏的信息 MFC对GDI的支持 结语 第3章 MFC中的消息处理 CCmdTarget和消息映射表 窗口消息 MFC消息映射内幕 MFC如何使用消息映射表 进入消息循环:PreTranslateMessage() 结语 第4章 MFC实用类 简单值类型 MFC的集合类 CFile家族:MFC对文件的访问 CExcephon:提供更好的错误处理 结语 第5章 CObject 使用CObject的代价 CObject的特性 宏的介绍 运行时类的信息 MFC中的持续性 CObject对诊断的支持 CObject的诊断支持内幕 组合在一起 投入使用 是否值得 结语 第6章 MFC对话框和控件类 CDialog:模态MFC对话框和非模态MFC对话框 MFC公用对话框 OLE对话框 属性页(也称带标签的对话框) MFC控件类 结语 第7章 MFC的文档/视图结构 为什么要用文档/视图 其他原因 旧的方法 体系结构 文档/视图结构内幕 文档舰图内幕再览 结语 第8章 高级文档舰图结构内幕 CMirrorFile CView打印 CView对打印预览支持的内幕 CView的派生类:CScrollView CView的另一个派生类:CCtrlView 结语 第9章 MFC的增强型用户界面类 CSplitterWnd:MFC分割窗口 MFC的CControlBar体系结构 CMiniFrameWnd MFC的MRU文件链表实现 结语 第10章 MFC的DLL与线程 理解状态 MFC的DLL MFC线程 结语 下一章 第11章 用MFC实现COM MFC和OLE COM 何为COM类 COM接口 GUID 剖析IUnknown接口 COM对象服务器 拥有多个接口的COM类 MFCCOM类 使用MFC创建CoMath MFCCOM和接口映射宏 使用MFC的CoMath类 完成服务器的设计 MFC对类厂的支持 结语 第12章 统一数据传输和MFC 历史回顾 重要的结构 IDataObject接口 OLE剪贴板 MFC的IDataObjeot类 延迟供应 深入了解MFC的IDataObject类 OLE拖放 结语 第13章 使用MFC实现OLE文档 OLE文档101 MFC对OLE文档的支持 使用MFC实现OLE文档服务器 容器朋艮务器的协调工作 使条目无效 保存容器的文档 装载OLE文档 结语 第14章 MFC与自动化 自动化的历史 自动化的功能 使用MFC实现自动化应用程序 自动化的工作机制 COM接口与自动化 实现自动化的另外一种方法:使用类型信息 MFC与自动化 结语:使用“MFC方式”的结果 第15章OLE控件 VBX及其缺陷 OLE控件 写一个OLE控件 在工程里使用OLE控件 它是如何工作的 MFC和OLE控件的容器 OLE控件的生存周期 OLE连接 OLE控件的事件 MFC如何处理事件 技巧:在一个视图中加入一个事件接收器 OLE控件的属性页 结语 附录A MFC源代码导读 MFC编码技术 探索MFC的工具 MFC源代码指南 愉快的旅途 附录B 本书的示例代码 术语表

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值