我的编译器 扫描器

自动扫描器
 
 
       先来说一下基本的扫描器。扫描器的目的是把右字母或字组成的文字、文章拆成一个一个的词。例如:
#include <iostream.h>
void main()
{
       cout<<”Hello world!”<<endl;
}
老掉牙的例子了,让我们来看一看经过扫描器之后这段代码会成什么样子。(从左到右,从上到下)
void
main
(
)
{
cout
<< 
“Hello world!”
<< 
endl
;
}
这里预处理指令没有算数,因为编译器根本不知道有预处理器这个东西(因为编译器处理的代码实际上就是预处理器处理过的代码,而其内部的代码这里省略)。
这下代码四分五裂了。void和main为什么成为两个词呢?因为它们中间有空格。
                     ——空格是扫描器中的分隔符
 
main和(又为什么分开呢?答案很显然——(不是字符不能组成词。
                     ——要区别词和标点
 
那(和)呢?因为没有“()”这个符号,相似的问题:<<。是的,这是作为一个符号出现的,因为它有意义。但是为什么不是“<”、“<”呢?这里就涉及到最长匹配原则,由此以消除前面的二义性问题。
                     ——最长匹配原则
 
       在平时的扫描器中,一般有两个类:一个负责从代码文件中读出字符流,而另一个则将读出的字符流划分成一个一个的单词即Token。一个Token通常记录有表示该Token的字符串、行号、列号以及一个可选的类型。为了输出调试以及展示的方便,我们的类型使用了字符串而非数字来表示,这样的缺点就是比较占空间,并且处理起来比较慢(主要原因在于直接使用数字表示时并不需要输出,因而不需要类型名表,因而省空间)。扫描器的任务就是将代码解释为Token流。与其对应的是3型语言,也就是正则表达式。根据正则表达式我们可以有规划的将分析工作转换为程序代码。这个过程有手工实现的也有使用工具自动实现的。在这次编译器的设计和实现过程中,这两种方法我们都将使用。因为我们的目标就是建立一个自动的扫描器。这样在实现这个自动扫描器时,我们只能使用手工方式来实现,待到自动扫描器实现之后就可以使用自动方式来设计扫描器了。手工扫描部分见C3编译器的相关章节。这里将只讨论使用C3编译器的编译结果来实现的自动扫描器。
 
 
 
       首先,我们要定义一种格式,使得按照这种格式编写的C3代码经过编译之后,我们现在编写的自动扫描器不用任何修改即可使用。否则换一个扫描器语法就要重新编写整个扫描器,这样“自动”等于一句空话。现定义标准如下:
Operator操作符段:将所有的操作符放在这里,每一条规则都是Label型规则。如果是字符串型的操作符(如VB所使用的And/Or之类)则要求在关键字区段中定义相同的规则。又由于C3并不允许使用相同的规则名,所以使用不同的前缀来区分。操作符区段的推荐前缀为"OP_"。
 
KeyWord关键字段:将所有的关键字放在这里,每一条规则都是Label型规则。关键字区段的推荐前缀为"KW_"。
 
Comment注释段:最多只能有3条规则,分别为:1.LineComment,表示行注释符。2.BlockCommentStart,表示区块注释的开始符号。3.BlockCommentEnd,表示区块注释的结束符号。3个规则都是可选的,如果没有或不全(区块注释只有一个符号),那么相应的注释规则将被禁用,而如果3个规则都被禁用,那么将只有空白的跳过起作用。
 
Other其它规则段:这里的规则为不是简单的字符串的规则,如标识符的识别,就不能使用单一的字符串表示。这里规则使用.NET的正则表达式语言,但为了与EBNF兼容,将其写为一个字符串,也即一个Label型的规则。
 
 
 
       下面来讨论一下我们的自动扫描器的实现。在自动扫描器的设计中,为了功能的实现及维护的方便,使用了分离式的设计。即将扫描器的单个功能项独立出来作为一个类,并设计接口,为IManager,并给与默认实现Manager。要实现新的功能项只需要继承Manager,并在扫描器初始化时将新的Manager添加到合适的位置。这里需要注意的一点就是由于前面的Manager匹配的话就不再匹配后边的,所以在1.2版中造成了天然的优先级问题。这样如果没办法安排确定的优先级的话,那么就只好将两个Manager合并。这样,功能分割的优势就不复存在了。于是我构想按照分析器的填充式的设计方案,从CodeReader读出字符,并填充各个Manager。这样各个Manager并行执行,最后一个匹配占用唯一的匹配槽,完成最长匹配。按照这个方案,对于注释问题比较好解决,就是检查是不是注释开始字符,如果是,开始工作,不是,那就把自己标记为不活动,以让扫描器知道不要再向我填充字符了。对于Operator和KeyWord,可以使用字符树来处理,非常快且方便。但是,忽略了一点,也就是1.3版扫描器失败的原因:不能解决字符边界问题,不能保证操作符和关键字的结束位置为一个Token的结束。如Not和Nothing,上一个扫描器由于操作符优先级高于关键字,导致Nothing会被分为两部分:Not和hing,而实际上这显然没有在边界上。字符树能够保证Nothing不被识别为Not,但是如果有一个单词为NothingWrong那么,将被识别为Nothing和Wrong,显然又错了。主要问题是字符树没有LookAhead字符,不能够预测后边的情况。对于扫描器规则(Other区的规则)而言,可以保证字符边界问题,所以在上一个设计中将关键字的匹配放在扫描器规则ID匹配之后(每一个关键字都是合法的ID,在语言设计角度),检查是不是关键字,如果是关键字就修改Token,否则Pass。实际上像VB这样的语言,操作符亦有可能是字符串,所以也应当考虑在内。这样字符串型的操作符应该放在那个优先级上就成了问题。最后,解决的方案为检查操作符表,将字符串型的操作符留给后面与关键字一同检查。这样Manager实际上就只剩了3个:处理空白和注释的White;处理符号型操作符的SymbolOperator;以及处理扫描器规则和关键字(包括字符串型的操作符)的OtherRule。
 
       在1.2版以前的设计中,扫描器规则是由一个专门编写的代码来运行的。好处就是行为可控,且速度还算比较快。而改为使用.NET标准的正则表达式后,由于在C3语言转换为.NET正则表达式语言上遇到了困难,所以,这里实现了一个非标准(语义上)的C3编译器。它依然使用标准C3语法,只是对扫描器规则语法的语义作了变更:扫描器规则也成为了Label型的规则,字符串内容为该规则的.NET正则表达式,在C3编译过程中将自动在正则表达式前面添加"/G",以使扫描工作连续。
 
       由于使用了.NET正则表达式,使CodeReader的作用进一步下降,现在只是为了维护行号和列号,处理回车问题,大小写问题。
 
在具体的结构使用功能分离的方式,Scanner类只负责管理及对外的接口。而具体功能则由各个Manager类来实现。虽然这样会比较慢,但我们的目的不是做一个商业编译器,至少现在不是。只要能够得到正确的结果,而且也不是太慢(慢上一个数量级是可以接受的,而且至少要达到相对于代码长度的时间复杂度为O(n))。
左图即为扫描器的基本结构。CodeReader作为扫描器的一个属性,负责读取字符,进行行列的计数。而Scanner自身则为一个集合类,管理各个Manager,对于扫描器唯一的出口GetToken函数则是一次调用各个Manager来实现Token的识别,如果有Manager识别了Token并返回True表示已经识别出有效的Token,GetToken函数发现返回为True,则返回该Manager所生成的Token。否则,使用下一个Manager进行匹配工作。如果都没有匹配,则说明代码的词法有问题。而每个Manager都可以有其特定的对于速度的优化措施,如SymbolOperator,使用了一个符号预测集,首先读入一个字符,看是不是有某一个操作符以此字符开始,如果有进行匹配,如果没有直接返回False,这样就避免了相对较慢的正则表达式的匹配操作;又,添加一个操作符类型的速查表,迅速找到匹配出的操作符类型。当前还存在的一个问题就是不是每次匹配都需要跳过空白。如QID的匹配ID("."ID)*,其中点与标识符之间并不存在空白,但在这种处理方式下其间即使有空白也无所谓,最多造成速度慢一点。
 
 
 
       下面我来说明一下自动扫描器的运行过程。
       由于扫描器的构造函数需要一个CodeReader作为参数,所以应该先构造一个CodeReader,使用要编译的语言的代码作为参数。这样扫描器就可以通过CodeReader和Grammar获取所需要的所有的信息。接下来就是加载需要使用的Managers,由于已经提供了White、SymbolOperator、OtherRule,所以扫描器就提供了一个LoadDefualt函数加载这些默认的Manager。这时,扫描器的结构就已经是上面的图上所呈现的结构了。这种将功能与管理分开的方式十分有利于功能的扩充,需要添加功能只需要添加一个Manager或继承并改写一个Manager,然后使用自己的加载函数加载需要的Manager,来运行即可。下面就是加载代码:
       Public Function InitScanner(ByVal Code As String,ByVal Gr As Grammar) As Scanner
              Dim CR As New CodeReader(Code)
              Dim SC As New Scanner(CR,Gr)
              SC.LoadDeafult()
              Return SC
       End Function
       那么,各个Manager怎么就知道该干什么呢?比如SymbolOperator,它怎么就知道那些符号是该由它处理的符号型的符号或关键字?仔细看一下Manager的结构就能够明白:Manager有一个Initialize函数,它就是用来初始化各个Manager的,在这个函数中,SC将所有的分析器的语法规则传递给每个Manager,而各个Manager查找对自己有用的信息,并进行一些处理以备后用,这样各个Manager就得到了想要的信息。而另外一个函数Invoke就是由扫描器来调用的接口,扫描器通过将CodeReader按照优先级顺序传递给各个Manager,而Managers则通过协作的方式依次对读入的字符进行处理,直到得到一个合法的Token为止,这时扫描器得知已经合法返回,就不再向后边传递CodeReader了也就是说这次匹配操作完成了。当然,优先级高的Manager可能会处理一些东西从而造成后边的Manager可能看不到某些东西,比如默认的Manager中优先级最高的White,它将跳过所有空白包括注释,这样后边的Manager看到的将是剔除了所有空白和注释(逻辑上看到的是将这些空白换成了空字符串的分隔符)。这样后边的Manager也就不用再处理空白问题——当然这也就是为什么使用这种结构的初衷。当进入SymbolOperator后,SO将使用在初始化阶段建立的符号速查表,如果在,就返回Token,不在,则扫描器将继续传递给OtherRule。在这次的实现中使用.NET的正则表达式来进行计算以简化操作。在其返回结果后,还将检查是否是关键字或字符型操作符,以改变返回的Token的类型。
 
 
       这时候扫描器就可以接受输出了。使用扫描器对分析器的唯一接口我们就可以轻易地进行测试。
       对于整个编译器来说,扫描器唯一的接口就是GetToken。分析器通过GetToken函数不停的从代码中读入Token。同时也使我们可以通过这唯一的接口来测试以下扫描器的正确性。
Public Sub TestScanner(ByVal Code As String,ByVal Gr As Grammar)
       Dim CR As New CodeReader(Code)
       Dim SC As New Scanner(CR,Gr)
       SC.LoadDefault()
       Dim t As Token
       t=SC.GetToken()
       While Not IsNothing(t)
              Console.WriteLine(t.ToString)
              t=SC.GetToken()
       End While
End Sub
这段测试程序将输出扫描器输出的所有的Token。而实际上分析器所用到的方式与这个是差不多的,都是先初始化然后再由一个循环处理所有的Token序列——只不过具体的处理方式就由分析器来决定了。从这段测试代码也可以看出这个自动扫描器的使用还是非常简单的。
       下面就是使用上面的测试代码并使用C-的Hello world程序来测试:
代码如下:
void main()
{
    printf("Hello world!");
}
Token如下:
TN_void             (Line:1,Col:1):          void
ID                  (Line:1,Col:6):          main
QuoteLeft           (Line:1,Col:10):         (
QuoteRight          (Line:1,Col:11):         )
BigQL               (Line:2,Col:1):          {
ID                  (Line:3,Col:2):          printf
QuoteLeft           (Line:3,Col:8):          (
String              (Line:3,Col:9):          "Hello world!"
QuoteRight          (Line:3,Col:23):         )
EndLine             (Line:3,Col:24):         ;
BigQR               (Line:4,Col:1):          }
共11个Token,3行。
 
而下图就是ParseTreeViewer(详细说明见后边的软件包说明)的运行结果,从中可以清楚地看到各个Token以及所组成的分析树。
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值