跟vczh看实例学编译原理——三:Tinymoe与无歧义语法分析

文章中引用的代码均来自https://github.com/vczh/tinymoehtml

 

看了前面的三篇文章,你们应该基本对Tinymoe的代码有一个初步的感受了。在正确分析"print sum from 1 to 100"以前,咱们首先得分析"phrase sum from (lower bound) to (upper bound)"这样的声明。Tinymoe的函数声明又不少关于block和sentence的配置,不过这里并不打算将全部细节,我会将重点放在如何写一个针对无歧义语法的递归降低语法分析器上。因此咱们这里不会涉及sentence和block的什么category和cps的配置。 git

 

虽然"print sum from 1 to 100"没法用无歧义的语法分析的方法作出来,可是咱们能够借用对"phrase sum from (lower bound) to (upper bound)"的语法分析的结果,动态构造可以分析"print sum from 1 to 100"的语法分析器。这种说法看起来好像很高大上,可是其实并无什么特别难的技巧。关于"构造"的问题我将在下一篇文章《跟vczh看实例学编译原理——三:Tinymoe与有歧义语法分析》详细介绍。 github

 

在我以前的博客里我曾经写过《如何手写语法分析器》,这篇文章讲了一些简单的写递归降低语法分析器的规则,尽管不少人来信说这篇文章帮他们解决了不少问题,但实际上细节还不够丰富,用来对编程语言作语法分析的话,仍是会以为复杂性过高。这篇文章也同时做为《如何手写语法分析器》的补充。好了,咱们开始进入无歧义语法分析的主题吧。 正则表达式

 

咱们须要的第一个函数是用来读token并判断其内容是否是咱们但愿看到的东西。这个函数比较特别,因此单独拿出来说。在词法分析里面咱们已经把文件分行,每一行一个CodeToken的列表。可是因为一个函数声明独占一行,所以在这里咱们只须要对每一行进行分析。咱们判断这一行是否以cps、category、symbol、type、phrase、sentence或block开头,若是是那Tinymoe就认为这必定是一个声明,不然就是普通的代码。因此这里假设咱们找到了一行代码以上面的这些token做为开头,因而咱们就要进入语法分析的环节。做为整个分析器的基础,咱们须要一个ConsumeToken的函数: express

 

 

做为一个纯粹的C++11的项目,咱们应该使用STL的迭代器。其实在写语法分析器的时候,基于迭代器的代码也比基于"token在数组里的下表"的代码要简单得多。这个函数所作的内容是这样的,它查看it指向的那个token,若是token的类型跟tokenType描述的同样,他就it++而后返回true;不然就是用content和ownerToken来产生一个错误信息加入errors列表里,而后返回false。固然,若是传进去的参数it自己就等于end,那天然要产生一个错误。天然,函数体也十分简单: 编程

 

那对于标识符和数字怎么办呢?明眼人确定一眼就看出来,这是给检查符号用的,譬如左括号、右括号、冒号和关键字等。在声明里面咱们是不须要太复杂的东西的,所以咱们还须要两外一个函数来输入标识符。Tinymoe事实上有两个针对标识符的语法分析函数,第一个是读入标识符,第二个不只要读入标识符还要判断是否到了行末不然报错: 数组

 

在这里我须要强调一个重点,在写语法分析器的时候,函数的各式必定要整齐划一。Tinymoe的语法分析函数有两个格式,分别是针对parse一行的一个部分,和parse一个文件的一些行的。ParseToEnd和ParseToFarest就属于parse一行的一个部分的函数。这种函数的格式以下: 编程语言

  1. 返回值必定是语法树的一个节点。在这里咱们以share_ptr<SymbolName>做为标识符的节点。一个标识符能够含有多个标识符token,譬如说the Chinese people、the real Tinymoe programmer什么的。所以咱们能够简单地推测SymbolName里面有一个vector<CodeToken>。这个定义能够在TinymoeLexicalAnalyzer.h的最后一部分找到。
  2. 前两个参数分别是iterator&和指向末尾的iterator。末尾一般指"从这里开始咱们不但愿这个parser函数来读",固然这一般就意味着行末。咱们把"末尾"这个概念抽取出来,在某些状况下能够获得极大的好处。
  3. 最后一个token必定是vector<CodeError>&,用来存放过程当中遇到的错误。

 

除了函数格式之外,咱们还须要全部的函数都遵循某些前置条件和后置条件。在语法分析里,若是你试图分析一个结构可是不幸出现了错误,这个时候,你有可能能够返回一个语法树的节点,你也有可能什么都返回不了。因而这里就有两种状况: ide

  1. 你能够返回,那就证实虽然输入的代码有错误,可是你成功的进行了错误恢复——实际上就是说,你以为你本身能够猜想这份代码的做者实际是要表达什么意思——因而你要移动第一个iterator,让他指向你第一个还没读到的token,以便后续的语法分析过程继续进行下去。
  2. 你不能返回,那就证实你恢复不了,所以第一个iterator不能动。由于这个函数有可能只是为了测试一下当前的输入是否知足一个什么结构。既然他不是,并且你恢复不出来——也就是你猜想做者原本也不打算在这里写某个结构——所以iterator不能动,表示你什么都没有读。

 

当你根据这样的格式写了不少语法分析函数以后,你会发现你能够很容易用简单结构的语法分析函数,拼凑出一个复杂的语法分析函数。可是因为Tinymoe的声明并无一个编程语言那么复杂,因此这种嵌套结构出现的次数并很少,因而咱们这里先跳过关于嵌套的讨论,等到后面具体分析"函数指针类型的参数"的时候天然会涉及到。 函数

 

说了这么多,我以为也应该上ParseToEnd和ParseToFarest的代码了。首先是ParseToEnd:

 

咱们很快就能够发现,其实语法分析器里面绝大多数篇幅的代码都是关于错误处理的,真正处理正确代码的部分其实不多。ParseToEnd作的事情很少,他就是从it开始一直读到end的位置,把全部不是标识符的token都扔掉,而后把全部遇到的标识符token都连起来做为一个完整的标识符。也就是说,ParseToEnd遇到相似"the real 100 Tinymoe programmer"的时候,他会返回"the real Tinymoe programmer",而后在"100"的地方报一个错误。

 

ParseToFarest的逻辑差很少:

 

只是当这个函数遇到"the real 100 Tinymoe programmer"的时候,会返回"the real",而后把光标移动到"100",可是没有报错。

 

看了这几个基本的函数以后,咱们能够进入正题了。作语法分析器,固然仍是从文法开始。跟上一篇文章同样,咱们来尝试直接构造一下文法。可是语法分析器跟词法分析器的文法的区别是,词法分析其的文法能够 "定义函数"和"调用函数"。

 

首先,咱们来看symbol的文法:

SymbolName ::= <identifier> { <identifier> }

Symbol ::= "symbol" SymbolName

 

其次,就是type的声明。type是多行的,不过咱们这里只关心开头的同样:

Type ::= "type" SymbolName [ ":" SymbolName ]

 

在这里,中括号表明无关紧要,大括号表明重复0次或以上。如今让咱们来看函数的声明。函数的生命略为复杂:

 

Function ::= ("phrase" | "sentence" | "block") { SymbolName | "(" Argument ")" } [ ":" SymbolName ]

Argument ::= ["list" | "expression" | "argument" | "assignable"] SymbolName

Argument ::= SymbolName

Argument ::= Function

 

Declaration ::= Symbol | Type | Function

 

在这里咱们看到Function递归了本身,这是由于函数的参数能够是另外一个函数。为了让这个参数调用起来更加漂亮一点,你能够把参数写成函数的形式,譬如说:

pharse (the number) is odd : odd numbers

    return the number % 2 == 1

end

 

print all (phrase (the number) is wanted) in (numbers)

    repeat with the number in all numbers

        if the number is wanted

            print the number

        end

    end

end

 

print main

    print all odd numbers in array of (1, 2, 3, 4, 5, 6, 7, 8, 9, 10)

end

 

咱们给"(the number) is odd"这个判断一个数字是否是奇数的函数,起了一个别名叫"odd numbers",这个别名不能被调用,可是他等价于一个只读的变量保存着奇数函数的函数指针。因而咱们就能够把它传递给"print all (…) in (…)"这个函数的第一个参数了。第一个参数声明成函数,因此咱们能够在print函数内直接调用这个参数指向的odd numbers函数。

 

事实上Tinymoe的SymbolName是能够包含关键字的,可是我为了避免让它写的太长,因而我就简单的写成了上面的那条式子。那Argument是否能够包含关键字呢?答案固然是能够的,只是当它以list、expression、argument、assignable、phrase、sentence、block开始的时候,咱们强行认为他有额外的意义。

 

如今一个Tinymoe的声明的第一行都由Declaration来定义。当咱们识别出一个正确的Declaration以后,咱们就能够根据分析的结果来对后面的行进行分析。譬如说symbol后面没有东西,因而就这么完了。type后面都是成员函数,因此咱们一直找到"end"为止。函数的函数体就更复杂了,因此咱们会直接跳到下一个看起来像Declaration的东西——也就是以symbol、type、phrase、sentence、block、cps、category开始的行。这些步骤都很简单,因此问题的重点就是,如何根据Declaration的文法来处理输入的字符串

 

为了让文法能够真正的运行,咱们须要把它作成状态机。根据以前的描述,这个状态及仍然须要有"定义函数"和"执行函数"的能力。咱们能够先伪装他们是正则表达式,而后把整个状态机画出来。这个时候,"函数"自己咱们把它当作是一个跟标识符无关的输入,而后就能够获得下面的状态机:

 

 

这样咱们的状态机就暂时完成了。可是如今还不能直接把它转换成代码,由于当咱们遇到一个输入,而咱们能够选择调用函数,并且能够用的函数还不止一个的时候,那应该怎么办呢?答案就是要检查咱们的文法是否是有歧义。

 

文法的歧义是一个颇有意思的问题。在咱们真的实践一个编译器的时候,咱们会遇到三种歧义:

  1. 文法自己就是有歧义的,譬如说C++著名的A* B;的问题。当A是一个变量的时候,这是一个乘法表达式。当A是一个类型的时候,这是一个变量声明语句。若是咱们在语法分析的时候不知道A到底指向的是什么东西,那咱们根本无从得知这一句话到底要取什么意思,因而要么返回错误,要么两个结果一块儿返回。这就是问法自己固有的歧义。
  2. 文法自己没有歧义,可是在分析的过程当中却没法在走每一步的时候都可以算出惟一的"下一个状态"。譬如说C#著名的A<B>C;问题。当A是一个变量的时候,这个语句是不成立的,由于C#的一个语句的根节点不能是一个操做符(这里是">")。当A是一个类型的时候,这是一个变量声明语句。从结果来看,这并无歧义,可是当咱们读完A<B>的时候仍然不知道这个语句的结构究竟是取哪种。实际上B做为类型参数,他也能够有B<C>这样的结构,所以这个B能够是任意长的。也就是说咱们只有在">"结束以后再多读几个字符才能获得正确的判断。譬如说C是"(1)",那咱们知道A应该是一个模板函数。若是C是一个名字,A多半应该是一个类型。所以咱们在作语法分析的时候,只能两种状况一块儿考虑,并行处理。最后若是哪个状况分析不下去了,就简单的扔掉,剩下的就是咱们所须要的。
  3. 文法自己没有歧义,并且分析的过程当中只要你每一步都日后多看最多N个token,酒能够算出惟一的"下一个状态"究竟是什么。这个想必你们都很熟悉,由于这个N就是LookAhead。所谓的LL(1)、SLR(1)、LR(1)、LALR(1)什么的,这个1其实就是N=1的状况。N固然不必定是1,他也能够是一个很大的数字,譬如说8。一个文法的LookAhead是多少,这是文法自己固有的属性。一个LR(2)的状态机你非要在语法分析的时候只LookAhead一个token,那也会遇到第二种歧义的状况。若是C语言没有typedef的话,那他就是一个带有LookAhead的没有歧义的语言了。

 

看一眼咱们刚才写出来的文法,明显就是LookAhead=0的状况,并且连左递归都没有,写起来确定很容易。那接下来咱们要作的就是给"函数"算first set。一个函数的first set,顾名思义就是,他的第一个token均可以是什么。SymbolName、Symbol、Type、Function都不用看了,由于他们的文法第一个输入都是token,那就是他们的first set。最后就剩下Argument。Argument的第一个token除了list、expression、argument和assignable之外,还有Function。所以Argument的first set就是这些token加上Function的first set。若是文法有左递归的话,也能够用相似的方法作,只要咱们在函数A->B->C->…->A的时候,知道A正在计算因而返回空集就能够了。固然,只有左递归才会遇到这种状况。

 

而后咱们检查一下每个状态,能够发现,任何一个状态出去的全部边,他接受的token或者函数的first set都是没有交集的。譬如Argument的0状态,第一条边接受的token、第二条边接受的SymbolName的first set,和第三条边接受的Function的first set,是没有交集的,因此咱们就能够判定,这个文法必定没有歧义。按照上次状态机到代码的写法,咱们能够机械的写出代码了。写代码的时候,咱们把每个文法的函数,都写成一个C++的函数。每到一个状态的时候,咱们看一下当前的token是什么,而后再决定走哪条边。若是选中的边是token边,那咱们就跳过一个token。若是选中的边是函数边,那咱们不跳过token,转而调用那个函数,让函数本身去跳token。《如何手写语法分析器》用的也是同样的方法,若是对这个过程不清楚的,能够再看一遍这个文章。

 

因而咱们到了定义语法树的时候了。幸运的是,咱们能够直接从文法上看到语法树的形状,而后稍微作一点微调就能够了。咱们把每个函数都当作一个类,而后使用下面的规则:

  1. 对于A、A B、A B C等的状况,咱们转换成class里面有若干个成员变量。
  2. 对于A | B的状况,咱们把它作成继承,A的语法树和B的语法树继承自一个基类,而后这个基类的指针就放在class里面作成员变量。
  3. 对于{ A },或者A { A }的状况,那个成员变量就是一个vector。
  4. 对于[ A ]的状况,咱们就当A看,区别只是,这个成员变量能够填nullptr。

 

对于每个函数,要不要用shared_ptr来装则见仁见智。因而咱们能够直接经过上面的文法获得咱们所须要的语法树:

 

首先是SymbolName:

 

其次是Symbol:

 

而后是Type:

 

接下来是Argument:

 

最后是Function:

 

你们能够看到,在Argument那里,同时出去的三条边就组成了三个子类,都继承自FunctionFragment。图中红色的部分就是Tinymoe源代码里在上述的文法里出现的那部分。至于为何还有多出来的部分,实际上是由于这里的文法是为了叙述方便简化过的。至于Tinymoe关于函数声明的全部语法能够分别看下面的四个github的wiki page:

https://github.com/vczh/tinymoe/wiki/Phrases,-Sentences-and-Blocks

https://github.com/vczh/tinymoe/wiki/Manipulating-Functions

https://github.com/vczh/tinymoe/wiki/Category

https://github.com/vczh/tinymoe/wiki/State-and-Continuation

 

在本章的末尾,我将向你们展现Tinymoe关于函数声明的那一个Parse函数。文章已经把全部关键的知识点都讲了,具体怎么作你们能够上https://github.com/vczh/tinymoe 阅读源代码来学习。

 

首先是咱们的函数头:

 

回想一下咱们以前讲到的关于语法分析函数的格式:

  1. 返回值必定是语法树的一个节点。在这里咱们以share_ptr<SymbolName>做为标识符的节点。一个标识符能够含有多个标识符token,譬如说the Chinese people、the real Tinymoe programmer什么的。所以咱们能够简单地推测SymbolName里面有一个vector<CodeToken>。这个定义能够在TinymoeLexicalAnalyzer.h的最后一部分找到。
  2. 前两个参数分别是iterator&和指向末尾的iterator。末尾一般指"从这里开始咱们不但愿这个parser函数来读",固然这一般就意味着行末。咱们把"末尾"这个概念抽取出来,在某些状况下能够获得极大的好处。
  3. 最后一个token必定是vector<CodeError>&,用来存放过程当中遇到的错误。

 

咱们能够清楚地看到这个函数知足上文提出来的三个要求。剩下来的参数有两个,第一个是decl,若是不为空那表明调用函数的人已经帮你吧语法树给new出来了,你应该直接使用它。领一个参数ownerToken则是为了产生语法错误使用的。而后咱们看代码:

 

第一步,咱们判断输入是否为空,而后根据须要报错:

 

第二步,根据第一个token来肯定函数的形式是phrase、sentence仍是block,并记录在成员变量type里:

 

第三步是一个循环,咱们根据当前的token(还记不记得以前说过,要先看一下token是什么,而后再决定走哪条边?)来决定咱们接下来要分析的,是ArgumentFragment的两个子类(分别是VariableArgumentFragment和FunctionArgumentFragment),仍是普通的函数名的一部分,仍是说函数已经结束了,遇到了一个冒号,所以开始分析别名:

 

最后就不贴了,是检查格式是否知足语义的一些代码,譬如说block的函数名必须由参数开始啦,或者phrase的参数不能是argument和assignable等。

 

这篇文章就到此结束了,但愿你们在看了这片文章以后,配合wiki关于语法的全面描述,已经知道如何对Tinymoe的声明部分进行语法分析。紧接着就是下一篇文章——Tinymoe与带歧义语法分析了,会让你们明白,想对诸如"print sum from 1 to 100"这样的代码作语法分析,也不须要多复杂的手段就能够作出来。

相关文章
相关标签/搜索