4、IDoom3Tokenizer词法解析器接口实现(TypeScript图形渲染实战2D架构设计与实现)

基于Canvas2D的2D篇:京东有售

这段时间有点忙,今天开始又空闲了,那就继续我们的TypeScript之旅!
1、总览:TypeScript图形渲染实战(2D架构设计和实现)详介
2、TypeScript图形渲染实战2D架构设计与实现:第2章 使用TypeScript实现Doom3词法解析器(1)
3、TypeScript图形渲染实战2D架构设计与实现:第2章 使用TypeScript实现Doom3词法解析器(2:Token与Tokenizer)
4、TypeScript图形渲染实战2D架构设计与实现:第2章 使用TypeScript实现Doom3词法解析器(3:IDoom3Token接口的实现)

正文:

2.2.5 Doom3Tokenzier处理数字和空白符

  首先声明一下,我们的IDoom3Tokenizer词法解析器仅支持ASCII编码字符串的解析,不支持UNICODE编码字符串的解析(换句话说,我们的词法解析器不支持中文解析),实际上Doom3引擎文本格式文件也仅支持ASCII编码的字符串。
  然后再想强调一点,像Java的JDK,C#的.NET Framework或C语言的crt(C语言运行库)都内置了强大的ASCII字符处理函数,但是在TypeScript或JavaScript中处理ASCII字符的一些操作需要自己来实现,那么我们就先来实现两个简单但是必须的ASCII字符处理函数,具体代码如下:

// 接口实现使用implements关键字
class Doom3Tokenizer implements IDoom3Tokenizer {
    // 使用了初始化表达式方式初始化字符串数组
    private _digits : string [ ] = [ "0" , "1" , "2" , "3" , "4" , "5" , "6" , "7" , "8" , "9" ] ;
    private _whiteSpaces : string [ ] = [ " " , "\t" , "\v" , "\n" ] ;
    
    //判断某个字符是不是数字
    private _isDigit ( c : string ) : boolean {
        for ( let i : number = 0 ; i < this . _digits . length ; i++ ) {
            if ( c === this. _digits [ i ] ) {
                return true ;
            }
        }
        return false ;
    }
   //判断某个字符是不是空白符
   //一般我们将空格符(" "),水平制表符(\t),垂直制表符(\v)以及换行符(\n)统称为空白符
    private _isWhitespace ( c : string ) : boolean {
        for ( let i : number = 0 ; i < this . _whiteSpaces . length ; i++ ) {
            if ( c === this . _whiteSpaces [ i ] ) {
                return true ;
            }
        }
        return false;
    }
}

2.2.6 IDoom3Tokenizer接口方法实现

  接着我们来看一下要解析字符串时所需要的一些方法,具体代码如下所示:

//要解析的字符串,使用Doom3Tokenizer字符串来初始化变量
private _source : string = " Doom3Tokenizer " ;
private _currIdx : number = 0 ;
//实现公开的接口方法,设置要解析的字符串,并且重置当前索引
public setSource ( source : string ) : void {
    this . _source = source ;
    this . _currIdx = 0 ;
}
//实现公开的接口方法,不改变要解析的字符串,仅重置当前索引
public reset ( ) : void {
    this . _currIdx = 0 ;
}

2.2.7 Doom3Tokenizer字符处理私有方法

  一旦我们通过setSource方法设置好要解析的源字符串或者调用reset方法进行重新解析字符串时,则需要一些操作来获取当前字符或探测下一个字符,我们可以使用这几个成员方法,代码如下所示:

//获得当前的索引指向的char,并且将索引加1,后移一位
//后++特点是返回当前的索引,并将索引加1
//这样的话,_getChar返回的是当前要处理的char,而索引指向的是下一个要处理的char
private _getChar ( ) : string {
    //数组越界检查
    if ( this._currIdx >= 0 && this . _currIdx < this . _source . length ) {
        return this . _source . charAt ( this . _currIdx ++ ) ;
    }
    return "" ;
}

//探测下一个字符是什么
//很微妙的后++操作
private _peekChar ( ): string {
    //数组越界检查,与_getChar区别是并没移动当前索引
    if ( this . _currIdx >= 0 && this . _currIdx < this . _source.length ) {
       return this . _source . charAt ( this . _currIdx  ) ;
    }
    return "" ;
}

private _ungetChar ( ) : void {
    //将索引前移1位,前减减操作符
    if ( this . _currIdx > 0 ) {
        -- this . _currIdx ;
    }      
}

  到此为止,我们构建了IDoom3Tokenizer词法解析器最小的运行环境,我们可以设置(setSource)或重置(reset)要解析的数据源,我们可以正向的获取(_getChar)当前字符,或探测(_peekChar)下一个字符,也可以反向归还(_ungetChar)一个字符。我们还可以知道当前字符是数字字符(_isDigit)或者是空白符(_isWhiteSpace)。下一小节我们将进入Token解析阶段。

2.2.8 核心的getNextToken方法

  IDoom3Tokenizer的getNextToken方法是一个相对复杂的实现,其工作原理就是一个有限状态机(Finite State Machine,简称FSM)。所谓有限状态机就是指状态是有限的,并且根据当前的状态,来执行某个操作。那么我们来看一下getNextToken这个有限状态机有:

  • 哪几个状态(即有限的状态数量)?
  • 每个状态的开始条件是什么?
  • 每个状态的结束条件是什么?
  • 在某个状态下要做什么(操作)?
    我们带着上面的问题来看一下getNextToken的源码,具体代码如下所示:
public getNextToken ( tok : IDoom3Token ) : boolean {
//使用as关键字将IDoom3Token 向下转型为Doom3Token类型
    let token : Doom3Token = tok as Doom3Token ;
    //初始化为空字符串
    let c : string = "" ;
    //重用token,每次调用reset函数时,将token的索引重置为0
    //避免发生内存重新分配
    token . reset ( ) ;
    do {
        // 第一步:跳过所有的空白字符,返回第一个可显示的字符
        //开始条件:当前字符是空白符
        c = this . _skipWhitespace ( );
        // 第二步:判断非空白字符的第一个字符是什么
        if ( c === '/' && this . _peekChar ( ) === '/' ) {
            // 开始条件:如果是//开头,则跳过单行注释中的所有字符
            c = this . _skipComments0 ( ) ;
        } else if ( c === '/' && this . _peekChar ( ) === '*' ) {
            //开始条件:如果是/*开头的字符,则跳过多行注释中的所有字符
            c = this . _skipComments1 ( ) ;
        } else if ( this . _isDigit( c ) || c === '-' || ( c === '.' && this . _isDigit( this . _peekChar ( ) ) ) ) {
            //开始条件:如果当前字符是(数字)或是(符号)或者(以点号且数字开头)
            //则返回到上一个字符索引处,因为第一个字符被消费掉了,而_getNumber会重新处理数字情况,这样需要恢复到数字解析的原始状态
            this . _ungetChar ( ) ;
            this . _getNumber ( token ) ;
            return true ;
        } else if ( c === '\"' || c === '\'' ) {
            //开始条件:如果以\"或\'开头的字符,例如'origin'或'Body'
            this . _getSubstring ( token , c ) ;
            return true ;
        } else if ( c.length > 0 ) {
            //开始条件:排除上述所有的条件并且在确保数据源没有解析完成的情况下
//返回到上一个字符索引处,因为_getString会重新处理相关情况
            this . _ungetChar ();
            this . _getString ( token ) ;
            return true ;
        }
    } while ( c . length > 0 ) ; 
    return false ;
}

  这段代码的关键点都在注释里面,其中状态的开始条件都已标注出来。状态的结束条件都注释在对应的状态处理函数中。
  我们来看一下这段代码中的向下转型相关内容。上面将IDoom3Token类型使用as操作符向下转型为Doom3Token是因为_getNumber / _getSubstring / _getString这三个方法的输出参数类型是Doom3Token类而不是IDoom3Token接口,因此需要从IDoom3Token向下转型到Doom3Token。在TypeScript也可以使用< >来进行类型转换,具体代码如下所示:

let  token : Doom3Token = < Doom3Token > tok ;

2.2.9 跳过不需处理的空白符和注释

  在getNextToken函数中,我们可以看到我们要处理的、有限的六种状态对应的操作:_skipWhitespace、_skipComments0、_skipComments1、_getNumber、_getSubstring以及 _getString,并且在注释中都备注出了状态的开始条件。
  那么我们来看一下三个用来处理跳过无用或空白字符的方法实现,具体代码如下所示:

//跳过所有的空白字符,将当前索引指向非空白字符
private _skipWhitespace ( ) : string {
    let c : string = "" ;
    do {
         c = this . _getChar ( ) ; //移动当前索引
         //结束条件:解析全部完成或者或当前字符不是空白符
       } while ( c . length > 0 && this . _isWhitespace( c ) ) ;
        
     // 返回的是正常的非空白字符
    return c ;
}
//跳过单行注释中的所有字符
private _skipComments0 ( ) : string {
    let c : string = "" ;
    do {
        c = this . _getChar ( ) ;
      //结束条件: 数据源解析全部完成或者遇到换行符
    } while ( c.length > 0 && c !== '\n' ) ;
    //此时返回的是“\n”字符
    return c ;
}
//跳过多行注释中的所有字符
private _skipComments1 ( ) : string {
    //进入本函数时,当前索引是/字符
   let c : string = "" ;
    // 1、先读取*号
   c = this . _getChar ( ) ;
    // 2、然后读取所有非* /这两个符号结尾的所有字符
    do {
        c = this . _getChar ( ) ;
        
        //结束条件: 数据源解析全部完成或者当前字符为*且下一个字符是/,也就是以*/结尾
    } while ( c . length > 0 && ( c !== '*' || this . _peekChar ( ) !== '/' ) ) ;
    // 3. 由于上面读取到*字符就停止了,因此我们要将/也读取(消费)掉
    c = this . _getChar ( ) ;
    //此时返回的应该是“/”字符
    return c ;
}

  在注释中详细标注了针对每个状态的操作以及结束条件。

2.2.10 实现_getNumber方法解析数字类型

  接下来我们来看一下IDoom3Tokenizer词法解析器中最复杂的一个解析方法,具体代码如下所示:

private _getNumber ( token: Doom3Token ) : void {
    let val : number = 0.0 ;
    let isFloat : boolean = false ; // 是不是浮点数
    let scaleValue : number = 0.1 ; // 缩放的倍数
        
    //获取当前的字符(当前可能的值是[数字,小数点,负号] )
    //目前不支持+3.14类似的表示
    //如果 - 3.14这种情况,由于负号和数字之间有空格,所以目前会解析成[ '-' , 3.14 ]这两个token
    //目前支持例如:[ 3.14 , -3.14 ,  .14  , -.14 , 3.  , -3.  ]的表示
    let c : string = this . _getChar ( ) ;
    //预先判断是不是负数
    let isNegate : boolean = ( c === '-' ) ; // 是不是负数
    let consumed : boolean = false ;
    //获得0的ascii编码,使用了字符串的charCodeAt实列方法
    let ascii0 = "0" . charCodeAt ( 0 ) ;
    // 3.14 -3.14 .13 -.13 3. -3.
    // 只能进来三种类型的字符 :  [  -  . 数字 ]
    do {
        // 将当前的字符添加到token中去
        token . addChar ( c ) ;
        // 如果当前的字符是.的话,设置为浮点数类型
        if ( c === '.' ) {
            isFloat = true ;
        } else if ( c !== '-' ) {
            // 10进制从字符到浮点数的转换算法
            // 否则如果不是-符号的话,说明是数字(代码运行到这里已经将点和负号操作符都排斥掉了,仅可能是数字)
                
            //这里肯定是数字了,我们获取当前的数字字符的ascii编码
            let ascii : number = c . charCodeAt ( 0 ) ;
            //将当前数字的ascii编码减去"0"的ascii编码的算法其实就是进行字符串-数字的类型转换算法
            let vc : number = ( ascii - ascii0 ) ;
            if ( ! isFloat ) // 整数部分算法,10倍递增,因为10进制
                val = 10 * val + vc ;
            else {
                // 小数部分算法
                val = val + scaleValue * vc ;
                //10倍递减
                scaleValue *= 0.1 ;
             }
        } /* else { // 运行到这段代码时,当前的变量c肯定为负号 
console.log ( " 运行到此处的只能是 : " + c ) ;
}*/
       //上面循环中的代码没有消费字符,之所以使用consumed变量,是为了探测下一个字符
        if ( consumed === true )
            this . _getChar ( ) ;
       //获得下一个字符后,才设置consumed为true
        c = this . _peekChar() ;
        consumed = true ;
     //结束条件:数据源解析全部完成或下一个字符既不是数字也不是小数点(如果是浮点数表示的话)
    } while (c . length > 0 && ( this . _isDigit ( c ) || ( ! isFloat && c === '.' ) ) ) ;
    //如果是负数的话,要取反
    if ( isNegate ) {
        val = - val ;
    }
        
    //设置数字值和NUMBER类型
    token.setVal ( val ) ;
}

  上面这段代码还是比较复杂的,要理解这段代码,最好的方式就是使用一个具有典型性的例子,我们来看一下如下代码:

et input:string = " [ 3.14 , -3.14 ,  .14  , -.14 , 3.  , -3. , +3.14 ] " ;
//我们使用setSource重新设置数据源
tokenizer . setSource ( input ) ;
while ( tokenizer . getNextToken ( token ) ) {
    if ( token . type === ETokenType . NUMBER ) {
        console . log ( "NUMBER : " + token . getFloat () ) ;
    }
    else {
        console . log( "STRING : " + token . getString ( ) ) ;
    }
}

  运行代码后的结果如图2.3所示:

  • 左右中括号以及逗号作为STRING类型的Token正常的解析出来。
  • [3.14 , -3.14 , .14 , -.14 , 3. , -3.]表示方式也正常解析出来。
      +3.14这种形式无法正确解析,如果想要支持正号(+)解析操作,也不难,毕竟我们已经完成了负号(-)解析,处理流程类似,这个问题就交给各位读者去解决。


    图2.3 不支持的数字解析格式

2.2.11 实现_getSubstring方法解析子字符串

  在2.1.1小节中,我们也提到过,Doom3文本文件格式中的标识符是由带一对单引号或双引号的字符串组成的,因此我们也需要一个方法来解析这种情况,让我们来看一下是如何实现的,具体代码如下所示:

private _getSubstring ( token : Doom3Token, endChar: string ) : void {
    let end : boolean = false ;
    let c : string = "" ;
    token . setType ( ETokenType.STRING ) ;
    do {
        // 获取字符
        c = this . _getChar ( ) ;
        //如果当前字符是结束符(要么是\",要么是\')
        if ( c === endChar ) {
            end = true ;  // 结束符
        }
        else {
            token . addChar( c ) ;
        }
            
   //结束条件: 数据源解析全部完成或遇到换行符(子串不能多行表示)或是结束符号(要么是\",要么是\')
    } while ( c . length > 0 && c !== '\n' && ! end ) ;
}

2.2.12 实现_getString方法解析字符串

  我们来看一下正常的字符串是如何解析的,实现代码如下所示:

// 进入该函数,说明肯定不是数字,不是单行注释,不是多行注释,也不是子字符串
// 进入该函数只有两种类型的字符串,即不带双引号或单引号的字符串以及specialChar
private _getString ( token: Doom3Token ): void {
    // 获取当前字符,因为前面已经判断为字符串了
    let c : string = this . _getChar ( ) ;
    token . setType ( ETokenType . STRING ) ;
    // 进入循环
    do {
        //将当前的char添加到token中
        token . addChar ( c ) ;
          
        if ( ! this . _isSpecialChar ( c ) ) {
            c = this . _getChar ( ) ; // 只有不是特殊操作符号的字符,才调用_getChar移动当前索引
        }
        //如果this . _isSpecialChar ( c )为true,不会调用_getChar函数,并且满足了跳出while循环的条件
       //结束条件:数据源解析全部完成或下一个是空白符或者当前字符是特殊符号
     } while ( c . length > 0 && ! this._isWhitespace ( c ) && ! this._isSpecialChar ( c ) ) ;
}

  代码注释比较详细,各位读者可以了解一下。这里会看到,和子字符串不同的一点是,我们的_getString会将一些特殊的字符(标点符号)作为单独的Token返回,具体有哪些特殊的字符,其实依赖于你的决策。在默认情况下,我们的实现代码如下所示:

// 我们将左右大中小括号以及点号逗号都当作单独的Token进行处理
// 如果想要增加更多的标点符号作为token,可以在本函数中进行添加
private _isSpecialChar ( c : string ) : boolean {
    switch ( c ) {
        case '(' :
            return true ;
        case ')' :
            return true ;
        case '[' :
            return true ;
        case ']' :
            return true ;
        case '{' :
            return true ;
        case '}' :
            return true ;
        case ',' :
            return true ;
        case '.' :
            return true ;
    }
    return false ;
}

  Doom3文本文件词法解析器的源码都演示完毕,最好的研究源码方式是断点调试,大家可以去本书备注的网站下载本章的源码进行调试。

2.2.13 IDoom3Tokenizer词法解析器状态总结表

  根据上面的源码,我们总结了一张状态表格,如下表2.1所示。


词法解析状态表

推荐阅读更多精彩内容