Golang词法分析剖析

前言

Go编译器一般缩写为小写的gc(go compiler),需要和大写的GC(垃圾回收)进行区分。Go编译器执行流程可细化为多个阶段,包括词法解析、语法解析、抽象语法树构建、类型检查、变量捕获、函数内联、逃逸分析、闭包重写、遍历并编译函数、SSA生成、机器码生成,如图所示:

图片.png

这篇文章主要讲解词法分析的步骤和细节。

1. 词法分析与Token

在词法解析阶段,Go编译器会扫描输入的Go源文件,并将其符号化为Token。

Token是编程语言中最小的具有独立含义的词法单元。Token不仅仅包含关键字,还包含用户自定义的标识符、运算符、分隔符和注释等。每个Token对应的词法单元有三个属性是比较重要的:首先是Token本身的值表示词法单元的类型,其次是Token在源代码中源代码文本形式,最后是Token出现的位置。在所有的Token中,注释和分号是两种比较特殊的Token:普通的注释一般不影响程序的语义,因此很多时候可以忽略注释;而Go语言中经常在行尾自动添加分号Token,而分号是分隔语句的词法单元,因此自动添加分号导致了Go语言左花括弧不能单独一行等细微的语法差异。

1.1 Token概念

Go语言中主要有标识符、关键字、运算符和分隔符等类型等 Token 组成。

标识符是指Go语言对各种变量、方法、函数等命名时使用的字符序列,标识符由若干个字母、下划线_、和数字组成,且第一个字符必须是字母。通俗的讲就是凡可以自己定义的名称都可以叫做标识符。注意美元符号$并不属于字母,因此标识符中不能包含美元符号。

关键字即是被Go语言赋予了特殊含义的标识符,也可以称为保留字。关键字用于引导特殊的语法结构,不能将关键字作为独立的标识符。下面是Go语言定义的25个关键字:

break        default      func         interface    select
case         defer        go           map          struct
chan         else         goto         package      switch
const        fallthrough  if           range        type
continue     for          import       return       var
复制代码

之所以刻意地将Go语言中的关键字保持的这么少,是为了简化在编译过程中的代码解析。

除了标识符和关键字,Token 还包含运算符和分隔符。下面是Go语言定义的47个符号:

+    &     +=    &=     &&    ==    !=    (    )
-    |     -=    |=     ||    <     <=    [    ]
*    ^     *=    ^=     <-    >     >=    {    }
/    <<    /=    <<=    ++    =     :=    ,    ;
%    >>    %=    >>=    --    !     ...   .    :
     &^          &^=
复制代码

当然,除了用户自定义的标识符、25个关键字、47个运算和分隔符号,程序中还有一些面值、注释和空白符组成。要解析一个Go语言程序,第一步就是要解析这些 Token。

1.2 Token定义

go/token包中,Token 被定义为一种枚举值,不同值的 Token 表示不同类型的词法记号:

// Token is the set of lexical tokens of the Go programming language.
type Token int
复制代码

所有的 Token 被分为四类:特殊类型的 Token、基础面值对应的 Token、运算符 Token 和关键字。

图片.png

特殊类型的 Token 有错误、文件结束和注释三种:

// The list of tokens.
const (
	// Special tokens
	ILLEGAL Token = iota
	EOF
	COMMENT
复制代码

遇到不能识别的 Token,也就是源文件中存在词法错误,统一返回 ILLEGAL,这样可以简化词法分析时的错误处理。遇到 EOF 表示该.go文件已经被遍历完成。COMMENT 表示源代码中的注释。

然后是基础面值对应的 Token 类型:Go语言规范定义的基础面值主要有整数、浮点数和复数面值类型,此外还有字符和字符串面值类型。需要注意的是,在Go语言规范中布尔类型的true和false并不在基础面值之内。但是为了方便词法解析,go/token包将 true 和 false 等对应的标识符也作为面值 Token 一类。

下面是面值类 Token 列表:

    literal_beg
    // Identifiers and basic type literals
    // (these tokens stand for classes of literals)
    IDENT  // main
    INT    // 12345
    FLOAT  // 123.45
    IMAG   // 123.45i
    CHAR   // 'a'
    STRING // "abc"
    literal_end
复制代码

其中 literal_beg 和 literal_end 是私有的类型,主要用于表示面值类型 Token 的值域范围,因此判断一个 Token 的值在 literal_beg 和 literal_end 之间就可以确定是面值类型。IDENT 标识一个标识符和某些基本类型字面量, 比如方法名称, 类型名称, 变量名称,常量、关键字都不属于这个类型。bool类型的面值true和false是归为IDENT的

运算符和分隔符符类型的 Token 数量最多,下面是 Token 列表:

    operator_beg
    // Operators and delimiters
    ADD // +
    SUB // -
    MUL // *
    QUO // /
    REM // %

    AND     // &
    OR      // |
    XOR     // ^
    SHL     // <<
    SHR     // >>
    AND_NOT // &^

    ADD_ASSIGN // +=
    SUB_ASSIGN // -=
    MUL_ASSIGN // *=
    QUO_ASSIGN // /=
    REM_ASSIGN // %=

    AND_ASSIGN     // &=
    OR_ASSIGN      // |=
    XOR_ASSIGN     // ^=
    SHL_ASSIGN     // <<=
    SHR_ASSIGN     // >>=
    AND_NOT_ASSIGN // &^=

    LAND  // &&
    LOR   // ||
    ARROW // <-
    INC   // ++
    DEC   // --

    EQL    // ==
    LSS    // <
    GTR    // >
    ASSIGN // =
    NOT    // !

    NEQ      // !=
    LEQ      // <=
    GEQ      // >=
    DEFINE   // :=
    ELLIPSIS // ...

    LPAREN // (
    LBRACK // [
    LBRACE // {
    COMMA  // ,
    PERIOD // .

    RPAREN    // )
    RBRACK    // ]
    RBRACE    // }
    SEMICOLON // ;
    COLON     // :
    operator_end
复制代码

运算符主要有普通的加减乘除等算术运算符号,此外还有逻辑运算、位运算符和比较运算等二元运算(其中二元运算还和赋值运算再次组合)。除了二元运算之外,还有少量的一元运算符号:比如正负号、取地址符号、管道的读取等。而分隔符主要是小括弧、中括弧、大括弧,以及逗号、点号、分号和冒号组成。

而Go语言的关键字刚好对应25个关键字类型的 Token:

    keyword_beg
    // Keywords
    BREAK
    CASE
    CHAN
    CONST
    CONTINUE

    DEFAULT
    DEFER
    ELSE
    FALLTHROUGH
    FOR

    FUNC
    GO
    GOTO
    IF
    IMPORT

    INTERFACE
    MAP
    PACKAGE
    RANGE
    RETURN

    SELECT
    STRUCT
    SWITCH
    TYPE
    VAR
    keyword_end
)
复制代码

从词法分析角度看,关键字和普通的标识符并无差别。但是25个关键字一般都是不同语法结构的开头 Token,通过将这些特殊的 Token 定义为关键字可以简化语法解析的工作。

所有的 Token 还被放进一个 string 切片中,方便利用 xxx_beg 和 xxx_end 下标快速判断 Token 类型:

var tokens = [...]string{
   ILLEGAL: "ILLEGAL",

   EOF:     "EOF",
   COMMENT: "COMMENT",

   IDENT:  "IDENT",
   INT:    "INT",
   FLOAT:  "FLOAT",
   IMAG:   "IMAG",
   CHAR:   "CHAR",
   STRING: "STRING",

   ADD: "+",
   SUB: "-",
   MUL: "*",
   QUO: "/",
   REM: "%",

   // 太多了,省略...
   // ...
   
   SELECT: "select",
   STRUCT: "struct",
   SWITCH: "switch",
   TYPE:   "type",
   VAR:    "var",

   TILDE: "~",
}
复制代码

Token 对于编程语言而言就像26个字母对于英文一样重要,它是组成更复杂的逻辑代码的基础单元,因此我们需要熟悉 Token 的特性和分类。

1.3 FileSet和File

Go语言本身,它是由多个文件组成包,然后多个包链接为一个可执行文件,所以单个包对应的多个文件可以看作是Go语言的基本编译单元。因此go/token包还定义了 FileSet 和 File 对象,用于描述文件集和文件。

下面是 FileSet 和 File 的结构定义(都位于src\go\token\position.go):

type FileSet struct {
   mutex sync.RWMutex // protects the file set
   base  int          // base offset for the next file
   files []*File      // list of files in the order added to the set
   last  *File        // cache of last file looked up
}

type File struct {
   set  *FileSet
   name string // file name as provided to AddFile
   base int    // Pos value range for this file is [base...base+size]
   size int    // file size as provided to AddFile

   // lines and infos are protected by mutex
   mutex sync.Mutex
   lines []int // lines contains the offset of the first character for each line (the first entry is always 0)
   infos []lineInfo
}
复制代码

涉及到的 lineInfo 类型结构:

type lineInfo struct {
   Offset       int
   Filename     string
   Line, Column int
}
复制代码

这里简单介绍一下 FileSet 和 File 结构体字段:

FileSet:

  • files 是一个切片,保存着一个文件集合下所有的 File
  • base 是下一个 File 的偏移量,比如当前 FileSet 的 files 切片中只有一个 File,那么 FileSet 的base就是1+File.size+1,第一个1是因为 base 的计算从1开始而不是0,而第二个1是因为 EOF 占了,前面说过 EOF 是用于表示文件的词法分析结束的。详细的计算后面会解释。

File:

  • name 是文件名
  • base 表示每个 File 的基准偏移量
  • size 表示文件源码的字节长度
  • lines 是一个 int 切片,保存了文件中每一行的第一个字符的偏移量,lines可以用来计算出 Token 的行号和列号
  • infos 是个 lineInfo 类型的切片,lineInfo 结构用于存储每个 Token 的行号和列号

到这里应该对 FileSet 和 File 有个大体印象了。FileSet 可以理解为把 File 的内容字节按顺序存放的一个大数组,而某个文件 File 则属于数组的一个区间[base,base+size]中,base 是文件的第一个字节在大数组中的位置,size 是这个文件的大小。

FileSet 和 File 对象的对应关系如图所示: QQ截图20211213011055.png

可以看到图中多了个 Pos,范围是整个大数组。没错,Pos 是 Position 的缩写,表示整个数组的下标位置。

每个 File 主要由文件名、base 和 size 三个信息组成。其中 base 对应 File 在 FileSet 中的 Pos 索引位置,因此 base 和 base+size 定义了 File 在 FileSet 数组中的开始和结束位置。在每个 File 内部可以通过 offset 定位下标索引,通过 offset+File.base 可以将 File 内部的 offset 转换为 Pos 位置。因为 Pos 是 FileSet 的全局偏移量,反之也可以通过 Pos 查询对应的 File,以及对应 File 内部的offset。后面会详细说明。

词法分析的每个 Token 位置信息就是由 Pos 定义,通过 Pos 和对应的 FileSet 可以轻松查询到对应的 File。然后在通过 File 对应的源文件和 offset 计算出对应的行号和列号(实现中 File 只是保存了每行的开始位置,并没有包含原始的源代码数据)。Pos 底层是 int 类型,它和指针的语义类似,因此0也类似空指针被定义为 NoPos,表示无效的 Pos。

1.4 尝试解析

词法分析通常是一个一个字符的读取输入的代码, 通过当前读取到的字符, 搭配一个解析词法的状态机来决定当前读取到的 Token 的类型。有时, 一个字符并不能提供足够的信息来做出这种判断, 此时就需要预先读取下一个或多个字符来辅助词法分析器做出判断。

Go语言标准库go/scanner包提供了 Scanner 实现 Token 扫描,它是在 FileSet 和 File 抽象文件集合基础上进行词法分析。主要有三个重要的方法:Init(),Next() 和 Scan()。 Init方法用于初始化扫描器,Next 方法用于寻找下一个字符,Scan 方法最为核心,用于扫描代码返回 Token,scan 方法有三个返回值,分别表示 Token 的位置 、Token 值和 Token 的源代码文本表示。

我们可以在src\go\scanner\example_test.go中找到测试用例,稍作修改:

import (
   "fmt"
   "go/scanner"
   "go/token"
)

func main() {
   var src = []byte("println(\"hello world\")\n")
   var src1= []byte("println(\"thank you\")")
   src=append(src,src1...)
   var fset = token.NewFileSet()
   var file = fset.AddFile("hello.go", fset.Base(), len(src))
   var s scanner.Scanner
   s.Init(file, src, nil, scanner.ScanComments)

   for {
      pos, tok, lit := s.Scan()
      if tok == token.EOF {
         break
      }
      fmt.Printf("%s\t%s\t%q\t\n", fset.Position(pos), tok, lit)
   }
}
复制代码

其中 src 是要分析的代码。然后通过token.NewFileSet()创建一个文件集,Token 的位置信息必须通过文件集定位,并且需要通过文件集创建扫描器的 Init 方法需要的 File 参数。

然后的fset.AddFile方法调用向 fset 文件集添加一个新的文件,文件名为“hello.go”,文件的长度就是 src 要分析代码的长度。

然后创建 scanner.Scanner 对象,并且调用 Init 方法初始化扫描器。Init 的第一个参数就是刚刚添加到 fset 的文件对象,第二个参数是要分析的代码,第三个 nil 参数表示没有自定义的错误处理函数,最后的 scanner.ScanComments 参数表示不用忽略注释 Token。

因为要解析的代码中有多个 Token,因此我们在一个 for 循环调用s.Scan()依次解析新的 Token。如果返回的是 token.EOF 表示扫描到了文件末尾,否则打印扫描返回的结果。打印前,我们需要将扫描器返回的 pos参数转换为更详细的带文件名和行列号的位置信息,可以通过fset.Position(pos)方法完成。

以上程序运行的输出如下:

hello.go:1:1	IDENT	"println"	
hello.go:1:8	(	""	
hello.go:1:9	STRING	"\"hello world\""	
hello.go:1:22	)	""	
hello.go:1:23	;	"\n"	
hello.go:2:1	IDENT	"println"	
hello.go:2:8	(	""	
hello.go:2:9	STRING	"\"thank you\""	
hello.go:2:20	)	""	
hello.go:2:21	;	"\n"
复制代码

输出结果的第一列表示 Token 所在的文件和行列号,中间一列表示 Token 的值,最后一列表示 Token 对应的面值。

1.5 加深理解

如果之前没有接触过编译的话,相信到这里大概率还是一头雾水,比如随着词法分析的进行,这么多的 offset(offset,rdOffset,lineOffset) 的变化关系是怎样的?Pos 又是怎么变化的?Pos 究竟是怎么转变为 offset?上面说过可以将 Pos 通过fset.Position(pos)方法转换为更详细的带文件名和行列号的位置信息,这又是怎么转换的呢?

直接 debug 上面程序看看 scanner 的执行细节,嫌麻烦的话看我梳理的 debug 结果就行。

首先来看 scanner 的结构:

// A Scanner holds the scanner's internal state while processing
// a given text. It can be allocated as part of another data
// structure but must be initialized via Init before use.
//
type Scanner struct {
   // immutable state
   file *token.File  // source file handle
   dir  string       // directory portion of file.Name()
   src  []byte       // source
   err  ErrorHandler // error reporting; or nil
   mode Mode         // scanning mode

   // 词法分析器使用的核心变量
   ch         rune // 当前字符
   offset     int  // 字符偏移量
   rdOffset   int  // 可以理解为ch的偏移量
   lineOffset int  // 当前的行偏移量
   insertSemi bool // 在换行前插入分号

   // public state - ok to modify
   ErrorCount int // number of errors encountered
}
复制代码

debug 只需要关注 ch、offset、rdOffset、lineOffset 和 fset.Position(pos) 方法返回的 pos,tok,lit 三个返回值就行,结果如下:

println:        ch=40
                offset=7
                rdOffset=8
                lineOffset=0
                pos=1
                tok=IDENT(4)
                lit="println"

(               ch=34
                offset=8
                rdOffset=9
                lineOffset=0
                pos=8
                tok=LPAREN(49)
                lit=""

"hello world":  ch=41
                offset=21
                rdOffset=22
                lineOffset=0
                pos=9
                tok=STRING(9)
                lit=""hello world""

)               ch=10
                offset=22
                rdOffset=23
                lineOffset=0
                pos=22
                tok=RPAREN(54)
                lit=""
                
\n              ch=112
                offset=22
                rdOffset=24
                lineOffset=23
                pos=23
                tok=SEMICOLON(57)
                lit="\n"
                
println:        ch=40
                offset=30
                rdOffset=31
                lineOffset=23
                pos=24
                tok=IDENT(4)
                lit="println"
......

\n              ch=-1
                offset=43
                rdOffset=43
                lineOffset=23
                pos=44
                tok=SEMICOLON(57)
                lit="\n"

文件末尾         ch=-1
                offset=43
                rdOffset=43
                lineOffset=23
                pos=44
                tok=EOF(1)
                lit=""
复制代码

可以发现好几个细节:

  • 遍历完当前 Token 后,ch 就会指向下一个字符
  • 在解析完文件之前,rdOffset 总是比 offset 多1
  • 有好几个 Token 打印出来的 lit是“”,原因是某些单字符的 Token 看 tok 已经知道具体是什么了,比如{}等等,就不需要再赋值给 lit 了,如果是一些标识符或者一些基本类型,那肯定要用 lit 来表示出具体的值。比如当读取到的 Token 是 INT 时,我们就需要使用返回值 lit 来取得读取到的到底是 0 还是 1000 或者其他的合法整数值。

至此,FileSet,File,offset,pos 的定义和联系应该是理清楚了。

接着看一下 Pos 是怎么转变为行号列号的。

文件的 Token 的行列信息是存储在 Position 这个结构体中的:

type Position struct {
   Filename string // filename, if any
   Offset   int    // offset, starting at 0
   Line     int    // line number, starting at 1
   Column   int    // column number, starting at 1 (byte count)
}
复制代码

从测试程序中可以看到是通过fset.Position(pos)方法将传入的 Pos 转换为 Position 结构:

func (s *FileSet) Position(p Pos) (pos Position) {
   return s.PositionFor(p, true)
}

func (s *FileSet) PositionFor(p Pos, adjusted bool) (pos Position) {
   if p != NoPos {
      if f := s.file(p); f != nil {
         return f.position(p, adjusted)
      }
   }
   return
}

func (f *File) position(p Pos, adjusted bool) (pos Position) {
   // 如果前面还没看懂Pos和offset的关系的,到这里肯定恍然大悟了吧
   offset := int(p) - f.base
   pos.Offset = offset
   // 核心函数
   pos.Filename, pos.Line, pos.Column = f.unpack(offset, adjusted)
   return
}
复制代码

position方法中可以发现,其实计算出行列号,真正靠的不是 Pos,而是用Pos计算出来的 offset!!

继续往下看:

func (f *File) unpack(offset int, adjusted bool) (filename string, line, column int) {
   f.mutex.Lock()
   defer f.mutex.Unlock()
   filename = f.name
   if i := searchInts(f.lines, offset); i >= 0 {
      line, column = i+1, offset-f.lines[i]+1
   }
   if adjusted && len(f.infos) > 0 {
      //省略
   }
   return
}
复制代码

因为 adjusted 入参和//line注解有关,先不看,只关注普通情况,发现是 searchInts 方法返回了行列号。等下,searchInts 方法的入参是 f.lines 和 offset,f.lines 前面我们介绍过了,我们再来看看这个字段的定义:

lines []int // lines contains the offset of the first character for each line (the first entry is always 0)
复制代码

lines 存储的是每行第一个字符的偏移量。我们可以猜想一下计算 Token 行列号为什么需要这个字段? 假设 lines 的值为 [0,10,20,30,40],offset 为22,根据 lines 的定义我们不难想到,只要将 offset在 lines 中遍历比较一遍,当找到 lines[i]<offset<lines[j] 的时候,行号就等于i,而列号就是offset-lines[i](是不是像最简单的那种二分搜索!!)。

看看 searchInts 是不是这样实现:

func searchInts(a []int, x int) int {
   i, j := 0, len(a)
   for i < j {
      h := i + (j-i)/2 // avoid overflow when computing h
      // i ≤ h < j
      if a[h] <= x {
         i = h + 1
      } else {
         j = h
      }
   }
   return i - 1
}
复制代码

还真是这样哈哈哈哈,被我猜中了。

1.6 继续深入

最后梳理一下上面程序的整个执行流程和 scan 的一些逻辑,这里再贴一下程序代码,免得往上翻。

func main() {
   var src = ...
   var fset = token.NewFileSet()
   var file = fset.AddFile("hello.go", fset.Base(), len(src))
   var s scanner.Scanner
   s.Init(file, src, nil, scanner.ScanComments)

   for {
      pos, tok, lit := s.Scan()
      if tok == token.EOF {
         break
      }
      // Printf
   }
}
复制代码

1.6.1 NewFileSet()

NewFileSet 用于创建一个FileSet

// NewFileSet creates a new file set.
func NewFileSet() *FileSet {
   return &FileSet{
      base: 1, // 0 == NoPos
   }
}
复制代码

FileSet 的 base 初始值是1,而不是0,base 的0值用一个别名NoPos来表示。base 等于NoPos表示根本就没有文件和行信息了。

const NoPos Pos = 0
复制代码

1.6.2 AddFile

AddFile 用于给 FileSet 添加 File,下面代码的旁枝末节去掉了:

func (s *FileSet) AddFile(filename string, base, size int) *File {
   s.mutex.Lock()
   defer s.mutex.Unlock()
   if base < 0 {
      base = s.base
   }
   // 省略base的一些大小判断
   // 创建了一个File,并初始化内部字段,其中base值是1,因为是把FileSet的base
   // 传给了AddFile:var file = fset.AddFile("hello.go", fset.Base(), len(src))
   f := &File{set: s, name: filename, base: base, size: size, lines: []int{0}}
   // 其实就是1+size+1,第一个1是base初始值,第二个1是EOF占位
   base += size + 1 // +1 because EOF also has a position
   // 省略base的一些大小判断
   // add the file to the file set
   s.base = base
   s.files = append(s.files, f)
   s.last = f
   return f
}
复制代码

至此扫描前的准备就做好了。

1.6.3 Init

Init 初始化 scanner

func (s *Scanner) Init(file *token.File, src []byte, err ErrorHandler, mode Mode) {
   if file.Size() != len(src) {
      panic(fmt.Sprintf("file size (%d) does not match src len (%d)", file.Size(), len(src)))
   }
   s.file = file
   s.dir, _ = filepath.Split(file.Name())
   s.src = src
   s.err = err
   s.mode = mode

   s.ch = ' '
   s.offset = 0
   s.rdOffset = 0
   s.lineOffset = 0
   s.insertSemi = false
   s.ErrorCount = 0

   // 取下一个要解析的字符
   s.next()
   if s.ch == bom {
      s.next() // ignore BOM at file beginning
   }
}
复制代码

1.6.4 Next

Next 把下一个字符读进 scanner.ch

// 因为Golang支持utf-8编码的源程序,所以Next方法读取的是下一个字符而不是字节
// 这个方法没有返回值,而是更新了scanner类型中的相关变量(ch,offset等)
func (s *Scanner) next() {
   if s.rdOffset < len(s.src) {
      s.offset = s.rdOffset
      if s.ch == '\n' {
         s.lineOffset = s.offset
         s.file.AddLine(s.offset)
      }
      // 用rune来取utf8字符
      r, w := rune(s.src[s.rdOffset]), 1
      switch {
      case r == 0:
         s.error(s.offset, "illegal character NUL")
      case r >= utf8.RuneSelf:
         // 不是ASCII码
         // utf8.DecodeRune方法可以将字节转换为rune类型,返回值r是一个rune,w是r的长度
         r, w = utf8.DecodeRune(s.src[s.rdOffset:])
         if r == utf8.RuneError && w == 1 {
            s.error(s.offset, "illegal UTF-8 encoding")
         } else if r == bom && s.offset > 0 {
            s.error(s.offset, "illegal byte order mark")
         }
      }
      s.rdOffset += w
      s.ch = r
   } else {
      s.offset = len(s.src)
      if s.ch == '\n' {
         s.lineOffset = s.offset
         s.file.AddLine(s.offset)
      }
      // 如果读取到文件末尾,就会将ch置为-1
      s.ch = -1 // EOF
   }
}
复制代码

1.6.5 scan

Scan 方法就是词法分析器的核心实现了, 正如上面所说, 它是一个状态机。对于 Scan 的每一次调用,都返回一个 Token。

func (s *Scanner) Scan() (pos token.Pos, tok token.Token, lit string) {
scanAgain:
   // 将空行空格过滤掉
   s.skipWhitespace()

   // 当前Token的起始点
   pos = s.file.Pos(s.offset)

   // insertSemi是判断是否在换行前插入
   insertSemi := false
   // 判断当前 token 的类型, 根据当前字符 ch.
   switch ch := s.ch; {
   // 如果当前读取到的是一个字母(a-z, A-Z或者utf8编码的字母), 就将它解析为标识(Identifier) token
   // 当然这个标识符可能是一个关键字,因此使用 token.Lookup 来判断当前标识符是否是关键字
   case isLetter(ch):
      // 扫描出整个标识符
      lit = s.scanIdentifier()
      if len(lit) > 1 {
         // 判断当前标识符是否是关键字
         tok = token.Lookup(lit)
         switch tok {
         case token.IDENT, token.BREAK, token.CONTINUE, token.FALLTHROUGH, token.RETURN:
            insertSemi = true
         }
      } else {
         insertSemi = true
         tok = token.IDENT
      }
   // 如果当前读取到的是一个数字, 那就将他解析为数字, 具体是 INT, FLOAT 在scanNumber方法中进行判断
   case isDecimal(ch) || ch == '.' && isDecimal(rune(s.peek())):
      insertSemi = true
      tok, lit = s.scanNumber()
   default:
      // 将 s.ch 更新为下一个字符, 后面会依赖于下一个ch来判断当前 token 类型
      s.next()
      // 此处的 ch 是 s.ch 的前一个字符
      switch ch {
      case -1:
         if s.insertSemi {
            s.insertSemi = false // EOF 把 insertSemi 消耗掉
            return pos, token.SEMICOLON, "\n"
         }
         tok = token.EOF
      case '\n':
         s.insertSemi = false // 换行把 insertSemi 消耗掉
         return pos, token.SEMICOLON, "\n"
      // 当前 token 是 string. 形式如 "abc..."
      case '"':
         insertSemi = true
         tok = token.STRING
         lit = s.scanString()
      // 当前 token 是 char. 形式如 'a'   
      case '\'':
         insertSemi = true
         tok = token.CHAR
         lit = s.scanRune()
      
      // 当前 token 是 raw string. 形式如 `abc...`
      case '`':
         insertSemi = true
         tok = token.STRING
         lit = s.scanRawString()
      // 遇到 ':', 具体token 类型将和下一个字符有关。如果下一个字符是 '=', 那么当前 token 将是 ":=",
      // 否则就是简单的 ':',这里也解释了前面为什么要先执行next
      case ':':
         tok = s.switch2(token.COLON, token.DEFINE)
      // 当前 ch 是 '.', 且 s.ch 是数字, 那么我们目前所处的 token 的格式为 "*.1"形式, 只能是小数.
      case '.':
         tok = token.PERIOD
         if s.ch == '.' && s.peek() == '.' {
            s.next()
            s.next() // consume last '.'
            tok = token.ELLIPSIS
         }
      case ',':
         tok = token.COMMA
      case ';':
         tok = token.SEMICOLON
         lit = ";"
      case '(':
         tok = token.LPAREN
      case ')':
         insertSemi = true
         tok = token.RPAREN
      case '[':
         tok = token.LBRACK
      case ']':
         insertSemi = true
         tok = token.RBRACK
      case '{':
         tok = token.LBRACE
      case '}':
         insertSemi = true
         tok = token.RBRACE
      // 如果当前 ch 是 '+', 那么所有可能结果是 '+', '+=', '++',具体 token 类型仍然取决于 s.ch 的值
      case '+':
         tok = s.switch3(token.ADD, token.ADD_ASSIGN, '+', token.INC)
         if tok == token.INC {
            insertSemi = true
         }
      case '-':
         tok = s.switch3(token.SUB, token.SUB_ASSIGN, '-', token.DEC)
         if tok == token.DEC {
            insertSemi = true
         }
      case '*':
         tok = s.switch2(token.MUL, token.MUL_ASSIGN)
      case '/':
         // 如果当前 ch 是 '/', 且 s.ch 是 '/' 或者 '*', 则当前 token 是注释.
         if s.ch == '/' || s.ch == '*' {
            if s.insertSemi && s.findLineEnd() {
               s.ch = '/'
               s.offset = s.file.Offset(pos)
               s.rdOffset = s.offset + 1
               s.insertSemi = false
               return pos, token.SEMICOLON, "\n"
            }
            comment := s.scanComment()
            if s.mode&ScanComments == 0 {
               s.insertSemi = false
               goto scanAgain
            }
            tok = token.COMMENT
            lit = comment
         } else {
            // 如果不是注释, 那么可能结果为除法操作符或者'/='
            tok = s.switch2(token.QUO, token.QUO_ASSIGN)
         }
      // 可能结果为取模操作符或者 '%=' 操作符
      case '%':
         tok = s.switch2(token.REM, token.REM_ASSIGN)
      case '^':
         tok = s.switch2(token.XOR, token.XOR_ASSIGN)
      case '<':
         if s.ch == '-' {
            s.next()
            tok = token.ARROW
         } else {
            tok = s.switch4(token.LSS, token.LEQ, '<', token.SHL, token.SHL_ASSIGN)
         }
      case '>':
         tok = s.switch4(token.GTR, token.GEQ, '>', token.SHR, token.SHR_ASSIGN)
      case '=':
         tok = s.switch2(token.ASSIGN, token.EQL)
      case '!':
         tok = s.switch2(token.NOT, token.NEQ)
      case '&':
         if s.ch == '^' {
            s.next()
            tok = s.switch2(token.AND_NOT, token.AND_NOT_ASSIGN)
         } else {
            tok = s.switch3(token.AND, token.AND_ASSIGN, '&', token.LAND)
         }
      case '|':
         tok = s.switch3(token.OR, token.OR_ASSIGN, '|', token.LOR)
      default:
         if ch != bom {
            s.errorf(s.file.Offset(pos), "illegal character %#U", ch)
         }
         insertSemi = s.insertSemi
         tok = token.ILLEGAL
         lit = string(ch)
      }
   }
   if s.mode&dontInsertSemis == 0 {
      s.insertSemi = insertSemi
   }

   return
}
复制代码

1.7 小结

词法分析可以简单的理解为将源代码按一定的转换规则,翻译为字符序列的过程。比如用规则来翻译如下的源代码:

package main

import ( 
    "fmt" 
) 

func main() { 
    fmt.Println("Hello") 
}
复制代码

则输出的 Token 序列为:

PACKAGE  IDENT

IMPORT  LPAREN
    QUOTE IDENT QUOTE
RPAREN

FUNC  IDENT LPAREN RPAREN  LBRACE
    IDENT DOT IDENT LPAREN QUOTE IDENT QUOTE RPAREN
RBRACE
复制代码

当然,这种纯字符的 Token 对后续的分析帮助不大,后面还会基于 Token 继续分析。

至此,对于词法分析的探索就告一段落了,后面有空再写写语法分析,抽象树,类型检查什么的。(一下子码这么多字好累。。逃)

参考:

gitee.com/amell/go-as… blog.csdn.net/zhaoruixian…

猜你喜欢

转载自juejin.im/post/7041610948196433957
今日推荐