EBNFParser的原理解析以及使用教程

EBNFParser的原理解析以及使用教程

NightyNightNightyNight

(题图的来源,图上就有

前段时间有人在我答案底下说看不懂,所以我今天来讲一下我是怎么写成某个Parser框架的。

在此之前,我说一点别的话。

非常感谢CPython项目,深入阅读了Python码源的一部分,我从中获益良多。

虽然我并没有学过编译原理,但我想,我从这份源代码里得到的东西,应该能够囊括很多编译相关的东西,教会了我许多关于编程语言设计、C和Python的令人智熄的操作。当然还有一些非思想层面的的收获,比如,阅读Python码源的课后作业就是我的Flowpython,作为一个完全兼容CPython3.5和CPython3.6的解释器,它提供了一些强力而有用的语法(糖),应该说是个非常实用的东西。

向开源世界的一切贡献者(包括我自己hhh)致敬!

然后我们来看看EBNFParser效果。


EBNFParser的作用可不仅仅是快速写出一门语言的前端,平常像解析json啊,yaml啊,xml啊这种数据文件,也是可以秒着玩的。更有甚者,你要是觉得正则表达式用着不够自由,你可以通过折中执行效率和功能来写一个用起来更顺手的正则引擎。

我的github项目有一个简短的介绍,让你用几行代码写一个Lisp的Parser。

EBNFParser主页

当然,要解析完整的Lisp, 不考虑注释,应该需要5行。

较为完整的Lisp语法解析文件

在完成EBNFParser的稳定版本后,除开上面的Lisp,我给出的示例还有以下几门语言的。

  1. Python表达式
    Python的全部语法在CPython的Grammar文件里有定义,你按照这个来,完成整个Python的解析仅仅是小菜一碟。
  2. 旧版本的EEBNF语言自省
    我所使用的EBNF语法并不是标准的,为了区别我打算暂时称呼其为EEBNF, 它比起标准的EBNF更像是一门编程语言,扩展能力更加强大。此处的旧版本的EEBNF缺少了一些新的非常有用的语法,比如AST的过滤,以及取消字面量Parser对自动生成的Tokenizer的影响(后者对于实现类似Java的嵌套多重注释(/*.../*...*/...*/)非常方便)。
  3. Extra Py
    这是一门还未诞生的语言,我创造它的意图是解决使用科学计算语言的混乱现状。除开能力碾压其它的Julia,其他科学计算语言的特性都非常接近,而且都有些不太令人愉悦的地方。而Extra Py是一门语法及特性更加合理(内部结构的一致性,在这一点上Matlab非常的糟糕)的语言,应该可以符合很多人的口味。
    同时,因为Extra Py非常的合理、优雅以及简单,解析到其他语言的方法可能非常得灵活。非常欢迎有兴趣和能力的朋友参与Extra Py的后端开发,这会是又一个有趣又有用的开源项目
    未来,如果Extra Py如期出现,我打算弄一个科学计算主题的公共项目,该项目的Extra Py代码轻易地编译或解释到R、 Python、Matlab、Julia、C++甚至Mathematica(甚至如果你会写Perl和Fortran...),接受任何人符合标准的、任何科学领域的代码。
    我并不是要去取代这些科学计算语言,我要做的是让新手学会一门能很快转向这些语言的轻量级语言。然后,很多时候,库的编写者只需要写写Extra Py,就可以发布多门语言的库了(按照R语言的三方库代码质量,我相信即便是简单的代码转换生成,也算得上是质量上乘的库了,更何况我相信未来的后端处理者们一定有很多骚操作的,比如我会把Extra Py的代码编译到Cython然后使用gcc优化,就非常棒了)。
    这是我的宏愿,暂且说这么多。
  4. EBNFParser.tests.Cm 以及 Cm-lang项目
    这是一门非常非常非常非常非常优雅的、高性能的、静态编译的语言。虽然它暂时还没发布。它的创建者是伟大的后宫王,世界Online的主角 @大笨蛋千里冰封
    我有一个愿望,就是让小括号可以远离这门可以表现得像C一样迅捷的语言。虽然我暂时并没有想到一个没有语意冲突又没有小括号的解决方案。
    Cm应该是EBNFParser的第一个挑战,事实上我是为了给Cm写Parser(在此控诉某号称图形学帝球,有商业级音乐才能的无良be)才写的EBNFParser。
    Cm的语法优雅,但设计上有相当程度的复杂可能是它吸取了kotlin、scala、rust的很多特点。
let $ = i:i32 -> (f:[i32=>i32]) -> f(i)  // 我倾向于可以不要分号,Parser不需要...
var x = 1;  // 但是大佬近期考sat,关于分号的事情未下结论。
until ({x>1})  //其实, until是个函数哦,你可以用这种方式造DSL啊
{
   doSomething
}   

来个类型标注的完全版(虽然你实际上一定不需要写这么多),但我觉得真是超好看...

let F:[[i32=>i32]=>i32=>i32] = (f:[i32=>i32])=>[i32=>i32]->{
      (x:i32)=>i32 -> f(x)*x
} :[[i32=>i32]=>i32=>i32]  //好吧这个尾标注我抄了一下scala
// 这个类型标注是我想出来的哇哈哈,详见我的Squirrel语言,那是我搞编译器的开始...
// https://github.com/thautwarm/SquirrelLanguage

好的,效果预览完毕。

让我们进入EBNFParser的原理解析。


模式匹配

在早些的时候,我非常恨Python为什么没有模式匹配。于是呢,我造了一个轮子。

thautwarm/Stardust 看ReadMe的Pattern Matching部分。

这个轮子还是挺强的,比如一个对象,你可以用简单的语法匹配它是否有某些成员或者,对这些成员的值进行匹配。同时你可以去匹配像列表啊、元组啊、字典啊这样的结构。总之万事万物都可以匹配。

不到两百行代码,还是很短的(但当时代码太丑了…)。

然后在我写这个Parser框架的时候,经过半天的瞎搞(我已经忘了那半天我在想什么策略了...),我的脑袋里涌出了两个东西,一个是Python的Grammar文件(当时flowpython项目已经完成),一个是我曾经给Python写的那个模式匹配扩展。

来我们看看EBNF是如何定义语法的。(具体语法我参照的是Python的Grammar文件和我数理逻辑书上的BNF语法...)

lambdaDef ::= 'lambda' argList ':' test

啊!这不就是个模式匹配嘛。。

来一个token好的词组

['lambda',
'x',',','y',
':',
'x','+','y',...]

你看看你看看。看不出来的话,我们来个模式匹配。

我用Python的部分语法做一个可爱的例子。

   好的,现在先把词组交到我们的lambdaDef小可爱手上。
    哇!第一个匹配成功,正好是我们要的 lambda 啊!
    好的,lambdaDef同学把lambda放到一个叫做AST的东西的第一个位置。
    继续...
    啊,下一个单元好像需要别人的帮助了,lambdaDef同学查了查自己的模式表,找到
一个熟悉的名字。
    "argList同学在吗?"lambdaDef大声地朝某索引区域喊道。
    "啊?找我嘛?姐姐你把当前的词组递给我一下,对了,把当前解析到第几个词也说一下."
    索引区域里传来argList同学的声音,她把自己的一个方法委托到某个lambdaDef也
能碰到的地方。
    lambdaDef见状,急忙将词组和已解析词数1放到了argList同学的委托里。
    之后,argList同学那里发生了很多事情,她和argDef同学、Name同学以及Test老
师一起处理了词组,得到了一个结果(如下)。
----------------------------
    argList[argDef[Name[x]]]
----------------------------
// 注: argList ::= argDef (',' argDef)*
//     argDef  ::= Name ['=' Test]
//     Test为Python表达式语法的最高级节点,能表述一切表达式。

同时,这个结果使得已解析词数变成了4。
    当然,这些事情,lambdaDef同学是不知道的,她看着argList同学满脸大汗地,
把结果和已解析词数交给了自己。
    "谢谢你了,"lambdaDef同学把这个结果放到了AST的第二个位置,然后更新了已解析
词数,"辛苦啦... 不过,我听说我们的努力会被更高层的人给扔掉了。他们说选择我们一开
始就错了呢。"
    "啊咧,为什么要说这个呢,我们最高层的人是Test老师吧,不过我听说,有时候,较低
层的Atom妹妹也会把Test老师的工作扔掉呢。她说有些时候, 左括号匹配了,Test老师
也递交了结果,但是之后却没有跟着一个右括号呢。"
// Atom的简化定义(去除列表解析等) ::= ... | '(' Test ')' | ...
    "啊,是这样呀,而且我发现,其实我们也会做同样的事情呀。"lambdaDef稍微有些
释然.
    "哼,上次那个垃圾程序员以为自己在写JS, 写了一个 lambda x=>x,然后lambdaDef
姐姐就把我和大家辛苦合作完成的东西给扔掉了呢!"

// 以上节选自某存在于我脑海的书 <<ParserGenerator娘的日常>>
    

好的,我觉得自己真有才呀。。

上面那个故事,基本讲清了所有的解析方法,最后lambdaDef和argList两位同学的对话,还稍微提到了一个我没有说但很重要的东西:回溯。

为什么需要回溯?(修订:这里不准确,回溯和处理死结有关,但并不完全为死结处理服务)

因为除开语法错误和歧义消除,还有死结的情况。

CppType ::= CppType '(' CppType* ')' | Name

很经典的死结是左递归。

我们怎么解决死结的呢?

死结与周期

如果,我们把每次解析出的新结果,和当前解析的词数对应起来,构成一个元组。用一个东西按顺序存储这些元组。

来,看官们,想想看,出现死结时,如果你打印最新的元组,会出现什么样的情况。

Name      ParsedCount
...       ...
CppType   50
CppType   50
CppType   50
CppType   50
CppType   50
...

为什么呢?

因为死结必然什么都没做。

我们看一个聪明的CppType。

// CppType ::= CppType '(' CppType* ')' | Name
CppType : 哈,我拿到词组和已解析数啦。打印一个。
Name => CppType, ParsedCount => 50
CppType : 好的,第一个就是我自己呀。继续。
CppType : 哈,我拿到词组和已解析数啦。打印一个。
Name => CppType, ParsedCount => 50
CppType : 好的,第一个就是我自己呀。继续——
CppType : 不对,这话我说过一样的,"Name => CppType, ParsedCount => 50".
CppType : 我去,玩我呢这是。。赶紧跳过。

好的,左递归问题解决了。应该说所有的死结问题都解决了。

但是左递归解析有时候还是需要的(暂时的EBNFParser还是会跳过,虽然我打补丁地很容易解决它,但出于性能考虑,我将加入新语法,使得可以指定某个Parser 能进行左递归解析。全局支持左递归会导致不必要的性能损失)。

我把我的想法告诉CppType,它真聪明,一学就懂。

// CppType ::= CppType '(' CppType* ')' | Name
CppType : 哈,我拿到词组和已解析数啦。打印一个。计数1.
Name => CppType, ParsedCount => 50
CppType : 好的,第一个就是我自己呀。继续。
CppType : 哈,我拿到词组和已解析数啦。打印一个。计数2大于1,发现重复.
Name => CppType, ParsedCount => 50
CppType : 按照红教主的指示,我现在应该处理保留计数1时的自己,然后对
当前计数2的自己,去解析后面其他的情况,解析失败就报个解析失败——计数2时
解析后面的情况失败的话,计数1时的自己就跳过左递归去看后面的情况。
...

是不是很简单呢?

原理暂时就讲到这里,我觉得差不多了呀。


基本使用

我想你已经看到项目上那个骚气的pypi了。

pip install -U EBNFParser

就ok了。

当然,因为Python3.6以下的版本不支持字符串插值,写起来比较不爽,我现在就只支持Python3.6+的Python。不需要任何语言及标准库以外的依赖。

不久的未来,EBNFParser会优化性能,还会渐渐推出无数新语言的版本。第二门支持EBNFParser的语言将会是C#,其实你如果不用自动代码生成,已经可以在C#手写ebnf来用parser了。

好的,我们看主页的第一个例子。

按照这个简介写一个Lisp的解析器就大概知道怎么用了。

然后介绍一下我的EEBNF的语法。

字面量Parser

l := R'<正则表达式>' 
# 该正则表达式将会按顺序加入token构造器

l := '字符串'  
# 该字符串会按顺序加入token构造器,
# 但所有的非R模式的字面量Parser的影响在R模式的Parser之前

l := K'<正则表达式>'
# 该Parser不对token做任何贡献.

AstParser

# 声明 l指代任意字面量Parser

X ::= l | l X l 
# 抽象语法书的Parser 可以由任意Parser 由 并运算符 | 组合。有先后顺序。
# 其中出现的字面量Parser将按出现顺序和字面量Parser模式, 去贡献token。

# 声明 y 是任意Parser(字面量或者Ast的)
X ::= y*
# *表示y至少出现0次,最多出现无穷次

X ::= y+
# +表示y至少出现1次,最多出现无穷次

X ::= y{n}
# 表示y至少出现n次,最多出现无穷次

X ::= y{n m}
# 表示y至少出现n次,最多出现m次

X ::= [y] z
# 表示 y 可能出现,等价于 X ::= y{0 1} z

X ::= (y z)+
X ::= (y z)*
X ::= (y z){1}
X ::= (y z){1 5}
# 把(y z)当成整体

过滤

Stmts Throw Newline ::=  (Newline* Stmt* Newline*)*
# 解析之后, Stmts不含有Newline  

暂时就这么多。

然后说明一下Ast的结构。

看里面的Ast类

现在可以支持Dump到JSON或者SExpr。

现在,尝试用EBNFParser去写一点好玩的东西吧。

造语言的话,前端太枯燥无味。解决好Parser,后面都是在做有趣的分析。

祝为梦想奋斗的各位晚安。


(添一句,EBNFParser的错误提示还是有点智能的呀...

(EEBNF默认不支持注释语法和多行语句,但有可选项。例子详见项目主页的testCm.sh文件。

文章被以下专栏收录
7 条评论
推荐阅读