You cannot select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

22 KiB

02词法分析识别Token也可以很简单吗

你好,我是宫文学。

上一节课我们用了很简单的方法就实现了语法分析。但当时我们省略了词法分析的任务使用了一个Token的列表作为语法分析阶段的输入而这个Token列表呢就是词法分析的结果。

其实编译器的第一项工作就是词法分析也是你实现一门计算机语言的头一项基本功。今天我们就来补补课学习一下怎么实现词法分析功能词法分析也就是把程序从字符串转换成Token串的过程。

词法分析难不难呢我们来对比一下语法分析的结果是一棵AST树而词法分析的结果是一个列表。直观上看列表就要比树结构简单一些所以你大概会猜想到词法分析应该会更简单一些。

那么,具体来说,词法分析要用什么算法呢?词法是不是也像语法一样有规则?词法规则又是如何表达的?这一节课,我会带着你实现一个词法分析器,来帮你掌握这些技能。

在这里,我有个好消息告诉你。你在上一节课学到的语法分析的技能,很多可以用在词法分析中,这会大大降低你的学习难度。好了,我们开始了。

词法分析的任务

你已经知道词法分析的任务就是把程序从字符串转变成Token串那它该怎么实现呢我们这里先不讲具体的算法先来看看下面这张示意图分析一下我们人类的大脑是如何把这个字符串断成一个个Token的
图片
你可能首先会想到借助字符串中的空白字符包括空格、回车、换行把这个字符串截成一段段的每一段作为一个Token行不行

按照这个方法function关键字可以被单独识别出来。但是你看我们还有一些圆括号、花括号等等这些符号跟前一个单词之间并没有空格或回车我们怎么把它们断开呢

OK你可以说凡是遇到圆括号、花括号、加号、减号、点号等这些符号我们把它们单独作为Token识别出来就好了。比如对于cat.weight这样的对象属性访问的场景点符号就是一个单独的Token。

但是你马上会发现这个规则仍然不能处理所有的情况例如对于一个浮点数的字面量“3.14”的情况点符号是浮点数的一部分不能作为单独的Token。我稍微解释一下这里的字面量Literal是指写在程序中的常量值包括整数值、浮点数值、字符串等。

此外,还有一些难处理的情况,比如像“==”、“+=”、“-=”、“- -”、“&&”这些由两个或两个以上字符构成的运算符,程序处理时是要跟“=”、“+”、“-”等区分开的。

再比如在JavaScript/TypeScript中十六进制的字面量用“0x”开头里面有a到f的字母比如0x1F4八进制的字面量用“0”开头后面跟0~7的数字而二进制的字面量用“0b”开头后面跟着0或1。

所以,你可以看到,做词法分析需要考虑的情况还挺多,不是用简单的一两个规则就能解决的,我们必须寻找一种系统性的解决方法。

在这里,为了让你对词法分析的任务有更全面的了解,我梳理了各种不同的处理工作,你可以看看下面这张表:
图片
那么,如何用系统性的方法进行词法分析呢?

借助这节课一开头的提示,我们试一下能否用语法分析的方法来处理词法,也就是说像做语法分析一样我们要先用一个规则来描述每个Token的词法然后让程序基于词法规则来做处理。

词法规则

同语法规则一样,我们可以用正则表达式来描述词法。在这里,标识符的规则是用字母开头,后面的字符可以是字母、数字或下划线,标识符的词法规则可以写成下面这样:

Identifier: [a-zA-Z_][a-zA-Z0-9_]* ;

实际上JavaScript的标识符是允许使用合法的Unicode字符的我们这里做了简化。

看上去词法规则跟上一节学过的语法规则没什么不同嘛只不过词法的构成要素是字符而语法的构成要素是Token。

表面上是这样其实这里还是有一点不同的。实际上词法规则使用的是正则文法Formal Grammar而语法规则使用的是上下文无关文法Context-free GammarCFG。正则文法是上下文无关文法的一个子集至于对这两者差别的深入分析我们还是放到后面的课上这里我们先专注于完成词法分析功能。

我们再写一下前面讨论过的浮点数字面量的词法规则,这里同样是精简的版本,省略了指数部分、二进制、八进制以及十六进制的内容:

DecimalLiteral: IntegerLiteral '.' [0-9]* 
              | '.' [0-9]+
              | IntegerLiteral 
              ;
IntegerLiteral: '0' | [1-9] [0-9]* ;

对于上面这个DecimalLiteral词法规则我们总结一下有这几个特点

  • 一个合法的浮点数可以是好几种格式3.14、.14、3等等都行
  • 整数部分要么只有一个0要么是1~9开头的数字
  • 可以没有小数点前的整数部分,但这时候小数点后至少要有一位数字,否则这就只剩了一个点号了;
  • 也可以完全没有小数部分,只有整数部分。

好了目前我们已经知道如何用词法规则来描述不同的Token了。接下来我们要做的就是用程序实现这些词法规则然后生成Token。

用程序实现词法分析

上节课我们在讲语法分析的时候,提到了递归下降算法。这个算法比较让人喜欢的一点是,程序结构基本上就是对语法规则的一对一翻译。

其实词法分析程序也是一样的比如我们要识别一个浮点数我们照样可以根据上述DecimalLiteral的几条规则一条条地匹配过去。

首先,我们匹配第一条规则,就是既有整数部分又有小数部分的情况;如果匹配不上,就尝试第二条规则,也就是以小数点开头的情况;如果还匹配不上,就尝试第三条,即只有整数部分的情况。只要这三条匹配里有一条成功,就意味着我们匹配浮点数成功。

我们来看看具体的程序实现:

if (this.isDigit(ch)){
    this.stream.next();
    let ch1 = this.stream.peek();
    let literal:string = '';
    //首先解析整数部分
    if(ch == '0'){//暂不支持八进制、二进制、十六进制
        if (!(ch1>='1' && ch1<='9')){
            literal = '0'; //整数部分只有0
        }
        else {报编译错误}
    }
    else if(ch>='1' && ch<='9'){
        literal += ch;
        while(this.isDigit(ch1)){
            ch = this.stream.next();
            literal += ch;
            ch1 = this.stream.peek();
        }
    }
    //解析小数部分
    if (ch1 == '.'){
        literal += '.';
        this.stream.next();
        ch1 = this.stream.peek();
        while(this.isDigit(ch1)){
            ch = this.stream.next();
            literal += ch;
            ch1 = this.stream.peek();
        }
        return {kind:TokenKind.DecimalLiteral, text:literal};
    }
    else{
        //返回一个整型直面量
        return {kind:TokenKind.IntegerLiteral, text:literal};
    }
}
//解析以.开头的小数,要求后面至少有一个数字
else if (ch == '.'){
    this.stream.next();
    let ch1 = this.stream.peek();
    if (this.isDigit(ch1)){
        //小数字面量
        let literal = '.';
        while(this.isDigit(ch1)){
            ch = this.stream.next();
            literal += ch;
            ch1 = this.stream.peek();
        }
        return {kind:TokenKind.DecimalLiteral, text:literal};
    }
    else{
        ...
    }
}

如果浮点数匹配不成功呢也没关系。其实计算机语言的词法规则分为很多条有匹配浮点数的也有匹配标识符的还有匹配运算符的等等。我们可以把这些词法规则依次匹配过去。只要某个规则匹配成功了我们就算识别出了一种Token。

这么看来,词法分析的过程,就是依次匹配不同的词法规则的过程。匹配成功以后就把这个Token从字符串中截下来再去尝试匹配下一个Token。

这里有两个细节你要注意一下一是当扫描到注释的时候直接去掉并不生成Token第二是对于空白字符的处理也是一样直接去掉。所以整体的处理逻辑如下

while (!EOF){
  跳过空白字符;
  如果是注释,丢弃掉;
  尝试匹配词法规则1
  不成功则回溯尝试匹配词法规则2
  不成功则回溯尝试匹配词法规则3
  ...
  直到成功匹配一个词法规则;
}

你根据这个逻辑,就可以写出整个词法分析程序了!

不过,你可能很快就发现了一个问题,我们的词法规则很多,可能有几十条甚至上百条。如果每次都从头开始依次尝试,会造成很多浪费,词法分析的性能也会很低,那我们有没有什么办法提速呢?

接下来,我们就把算法优化一下。

有限自动机:提升词法分析性能

怎么优化呢你可能马上会想到一个好办法虽然词法规则很多但大部分Token都可以通过开头的第一个字符区分开。

  • 如果第一个字符是-号那么只可能是三种Token- --=和-
  • 如果第一个字符是一个字母,那么只可能是标识符或各种关键字;
  • 如果第一个字符是0那么必然是十六进制字面量、八进制字面量或者二进制字面量。

你看,通过预读一个字符,程序可以马上缩小选择范围,使得性能大大提升。

尝到甜头以后我们马上可以想到如果需要的话能不能再继续预读第二个字符进一步确定是哪个Token呢

当然是可以的。比如,对于- -、-=和-这三个Token我们可以再往下预读一个字符。如果是-号,那么就收获一个- -Token如果是=,那么就收获一个-=Token除了这两种之外后面不管是什么字符我们都把-号单独作为一个Token提取出来。

我们把这个判断过程画成一个状态迁移图,这个状态迁移图我们叫做一个有限自动机。
图片
恭喜你到这里你已经在不知不觉间实现出了教科书级的方法也就是通过有限自动机Finite-State AutomataFSA来做词法分析了。

有限自动机可以分为两种。如果一个有限自动机针对每个输入都会发生一次确定的迁移这种有限自动机就被叫做确定性有限自动机Determinisitic Finite-State Automata, DFA

与之对应的是另一种有限自动机叫做非确定性的有限自动机Non-deterministic Finite-State AutomataNFA。它在某些状态下针对一个输入可能会迁移到不止一个状态或者在没有任何输入的情况下也会从一个状态迁移到另一个状态。

不过任何一个NFA都可以通过一个算法转换成DFA这个算法你可以参见《编译原理之美》中的算法篇。

言归正传我们刚才已经画出了能识别三种Token的有限自动机我们还可以把这个有限自动机扩大像下图一样增加对多行注释、/、/=、标识符、关键字和空白字符的处理能力。
图片
你还可以继续扩展这个图直到它能够提取所有的Token为止。有了这个大图之后你就可以照着这个图写程序了。我这里给出了一个程序片段供你参考。

//从字符串流中获取一个新Token。
private getAToken():Token{
    this.skipWhiteSpaces();
    if (this.stream.eof()){
        return {kind:TokenKind.EOF, text:""};
    }
    else{
        //预读一个Token
        let ch:string = this.stream.peek();
        //处理标识符(包含关键字)
        if (this.isLetter(ch) || ch == '_'){
            return this.parseIdentifer();
        }
        //处理字符串字面量
        else if (ch == '"'){
            return this.parseStringLiteral();
        }
        else if (ch == '(' || ch == ')' || ch == '{' || ch == '}' || ch == '[' || ch == ']' || 
                 ch == ',' || ch == ';' || ch == ':' || ch == '?' || ch == '@'){
            this.stream.next();
            return {kind:TokenKind.Seperator,text:ch};
        }
        //处理多行注释、/、/=
        else if (ch == '/'){
            this.stream.next();
            let ch1 = this.stream.peek();
            if (ch1 == '*'){
                this.skipMultipleLineComments();
                return this.getAToken();
            }
            else if (ch1 == '/'){
                this.skipSingleLineComment();
                return this.getAToken();
            }
            else if (ch1 == '='){
                this.stream.next();
                return {kind:TokenKind.Operator,text:'/='};
            }
            else{
                return {kind:TokenKind.Operator,text:'/'};
            }
        }
        //处理+、+=、++
        else if (ch == '+'){
            this.stream.next();
            let ch1 = this.stream.peek();
            if (ch1 == '+'){
                this.stream.next();
                return {kind:TokenKind.Operator,text:'++'};
            }else if (ch1 == '='){
                this.stream.next();
                return {kind:TokenKind.Operator,text:'+='};
            }
            else{
                return {kind:TokenKind.Operator,text:'+'};
            }
        }
        //省略了更多的情况……
        else{ 
            //暂时去掉不能识别的字符
            console.log("Unrecognized pattern meeting ': " +ch+"', at" + this.stream.line + " col: " + this.stream.col);
            this.stream.next();
            return this.getAToken();
        }
    }
}

如果你仔细看上面的图3你会发现我的标识符和关键字都是统一处理的因为标识符的词法其实已经包含了关键字。做词法分析的一个最佳实践,就是先把标识符和关键字统一提取出来,然后再从里面把关键字单独提取出来就行了。

好了前面我们通过“预读”的思路升级了词法分析的算法使之能够正确地提取出所有的Token。在这个过程中我们已经不知不觉摸索出了编译原理教科书中都会讲的有限自动机算法。

可是这还没完。你可能会想到,采用有限自动机的情况下,程序每次都可以根据下一个输入,准确地确定接下来应该执行的逻辑,完全规避了回溯的问题。那我们是不是也可以用这个思路来提升语法分析的效率呢?好想法,我们现在就来试试看。

思路迁移:提升语法分析效率

我们先来回顾一下在语法分析中,回溯是怎么发生的,以及它会造成怎样的性能上的损失。

在语法分析的递归下降算法中,当一个语法规则由多个子规则构成时,我们采用的是挨个试的方法来进行匹配。比如,首先试一下某语句是否是一个函数声明,接着试一下它是否是一个函数调用。

在匹配某个语法规则失败的时候我们需要把已经消化掉的Token还回去恢复到没做匹配之前的状态这个过程叫做回溯Backtracking

由于整个算法是递归下降的某一级的匹配失败也可能会导致上一级的失败从而导致逐级回溯。比如我们用下面的表达式的语法规则去匹配2+3*5的时候就会发生逐级回溯的现象

//加法表达式,可以是一个乘法表达式,或者是一个乘法表达式+另一个加法表达式
add : mul 
    | mul '+' add
    ;
//乘法表达式,可以是一个基础表达式,或者是一个基本表达式*另一个乘法表达式    
mul : pri 
    | pri '*' mul 
    ;
//基础表达式,可以是一个整形字面量,或者是括号括起来的一个加法表达式
pri : IntLiteral 
    | '(' add ')'
    ;

匹配过程是这样的:

2+3*5符合add规则吗
  ->2+3*5符合mul规则吗
    ->2+3*5符合pri规则吗
      ->2+3*5符合IntLiteral吗不符合因为它显然不只是一个整数常量
      ->2+3*5符合'('add')'规则吗不符合因为第一个Token不是'('
    ->回溯到mul去尝试第二个选项
    ->2+3*5符合pri '*' mul吗
      ->匹配一个pri
        ->匹配一个IntLiteral成功
      ->再匹配一个'*',失败,遇到的是'+'
  ->回溯到add去尝试第二个选项
  ->2+3*5符合mul '+' add吗
    ->匹配一个mul
      ->匹配一个pri
        ->匹配一个IntLiteral,成功
    ->匹配一个'+',成功
    ->匹配一个add
    接下来又回遇到一开头使用add规则的那种回溯的情形
    ...        

在这个例子中我们还只是采用了3级嵌套的语法如果语法的层次更多的话由回溯造成的无用功就更多。从这里你应该能体会到回溯的危害了。

不过受到词法分析中预读字符的启发我们在语法分析中也可以通过预读一个Token可以减少尝试的次数。比如如果第一个Token是“function”关键字那肯定是要匹配一个函数声明这就不用考虑其他选项了这不就可以提高性能了吗

确实是这样的。在语法分析中我们经常可以遇到有多个语法规则可供选择的情况。比如在上一节的例子中statement就有两种可能性这还是简化的情况在完整的语法规则中statement有20多种可能性像if语句、各种循环语句、各种声明等都属于statement。在这种情况下预读所带来的性能改善就非常可观了

图片

为了通过预读实现性能的优化我们需要知道每个语法规则开头的Token都可能有哪些。比如if语句永远是以if开头的这个比较简单。但有的语法规则就比较复杂一些比如表达式语句它本身就是由很多子规则构成的所以其第一个Token有很多可能

++i;   //第一个Token是++
foo(); //第一个Token是一个标识符
"Hello World"; //第一个Token是一个字面量 
(2+3); //第一个Token是括号       

这怎么办呢也好办。我们把表达式语句所有可能出现的第一个Token的集合叫做First集合。

对于statement来说我们可以求出每个子规则的First集合只要所有的这些集合都没有交集那么我们总是可以通过预读一个Token来决定采用哪条子规则的。

如果整个语法规则都只需要预读一个Token就可以实现那么我们把这个语法规则叫做LL(1)文法而我们刚才说到的通过预读来唯一确定分支路径的算法就是LL(1)算法,这个算法避免了由于回溯而导致的性能开销。

不过对于LL两个字母的含义我们留到下节课再讲你现在只要记住这种算法名称叫做LL算法就好了。LL(1)中的1意思是需要预读一个Token。

另外LL算法还有一种特殊情况需要处理比如语句块中可以有一个语句列表statementLIst也可能是一个空语句块

block
    : '{' statementList? '}'
    ;
statementList
    : statement+
    ;

我们在解析语句块的时候,取出了“{”以后后面可能是一个语句列表statementList也有可能直接遇到“}”,这就意味着这是一个空语句块,也就是“{}”。

那这个时候呢我们在程序里要多加一个判断如果预读的Token在statementList的First集合里那么就要去解析一个statementList而如果遇到的是“}”,那就没必要了。

由于这个“}”是出现在statemtnList之后的我们可以说“}”属于statementList的Follow集合。我们根据statemntList的First和Follow集合就能解决语法规则中statementList后面跟一个?号的情况。除了?号,在处理*号和+号的时候也都会需要用到Follow集合。

总结起来完整的LL算法需要用到每个语法规则的First和Follow集合来确定我们应该采用哪个子规则的分支从而避免回溯实现性能上的提升。

课程小结

好了,这一节课就先到这里了。这节课,我们不仅学到了如何做词法分析,还把语法分析的技能又提升了一级。

总结一下词法分析也可以使用EBNF来描述词法规则并且也可以使用类似递归下降的算法。不过通过预读字符我们可以降低尝试的次数。最终通过构造一个有限自动机我们可以对每一个输入都做一个确定的状态转移从而高效地实现词法分析。

接着我们把“预读”的思维放到了语法分析中的递归下降算法上又介绍了First集合和Follow集合的概念借助这两个集合我们不需要回溯也能够实现语法分析从而大大提高了语法分析的效率。

思考题

最后我给你出了一个思考题。你看,我们在程序里需要处理负数字面量,比如-3。你认为负数的负号应该是作为运算符来处理分别解析成一个-号和一个字面量3还是把-3整体作为一个字面量识别出来呢为什么

欢迎你和我一起学习编程语言,也欢迎你将这门课分享给更多对编程语言感兴趣的朋友。我是宫文学,我们下节课见。

资源链接

这节课的示例代码在这里!