写点什么

Go 编译原理系列 3(词法分析)

作者:书旅
  • 2022 年 1 月 02 日
  • 本文字数:8515 字

    阅读完需:约 28 分钟

Go编译原理系列3(词法分析)

前言

在上一篇文章中,介绍了词法分析中的核心技术,有穷自动机(DFA),以及两个常见的词法分析器的使用及工作原理。在这个基础上去看 Go 的词法分析源码会轻松许多


本文主要包含以下内容:


  1. Go 编译的入口文件,以及在编译入口文件中做了哪些事情

  2. 词法分析处在 Go 编译的什么位置,以及详细过程是什么样的

  3. 写一个测试的 go 源文件,对这个源文件进行词法分析,并获取到词法分析的结果

源码分析

Go 的编译入口

为了能更清楚的了解 Go 的编译过程是如何走到词法分析这一步的,这里先介绍 Go 的编译入口文件在什么地方,以及大致做了哪些事情


Go的编译入口文件在:src/cmd/compile/main.go -> gc.Main(archInit)
复制代码


进入到 gc.Main(archInit)这个函数,这个函数内容比较长,它前边部分做的事情主要是获取命令行传入的参数并更新编译选项和配置。然后你会看到下边这行代码


lines := parseFiles(flag.Args())
复制代码


这就是词法分析和语法分析的入口,它会对传入的文件进行词法和语法分析,得到的是一棵语法树,后边就会将它构建成抽象语法树,然后对它进行类型检查等操作,会在后边的文章中分享


打开 parseFiles(flag.Args())文件,可以看到如下内容(我省略了后边部分的代码,主要看词法分析这块的内容):


func parseFiles(filenames []string) uint {  noders := make([]*noder, 0, len(filenames))  // Limit the number of simultaneously open files.  sem := make(chan struct{}, runtime.GOMAXPROCS(0)+10)
for _, filename := range filenames { p := &noder{ basemap: make(map[*syntax.PosBase]*src.PosBase), err: make(chan syntax.Error), } noders = append(noders, p)
go func(filename string) { sem <- struct{}{} defer func() { <-sem }() defer close(p.err) base := syntax.NewFileBase(filename)
f, err := os.Open(filename) if err != nil { p.error(syntax.Error{Msg: err.Error()}) return } defer f.Close()
p.file, _ = syntax.Parse(base, f, p.error, p.pragma, syntax.CheckBranches) // errors are tracked via p.error }(filename) } ......}
复制代码


我们知道,在 Go 的编译过程中,最终每一个源文件,都会被解析成一棵语法树,从上边代码的前几行就能看出来,它首先会创建多个协程去编译源文件,但是每次是有限制打开的源文件数的


sem := make(chan struct{}, runtime.GOMAXPROCS(0)+10)
复制代码


然后遍历源文件,并起多个协程对文件进行词法和语法分析,主要体现在 for 循环和 go func 这块。在 go func 中可以看到,它会先初始化源文件的信息,主要是记录源文件的名称、行、列的信息,目的是为了在进行词法和语法分析的过程中,如果遇到错误,能够报告出现错误的位置。主要包含以下几个结构体


type PosBase struct {  pos       Pos  filename  string  line, col uint32}
type Pos struct { base *PosBase line, col uint32}
复制代码


后边就是打开源文件,开始初始化语法分析器,之所以初始化的是语法分析器的原因是,你会发现,Go 的编译过程中,词法分析和语法分析是放在一起的,在进行语法分析器初始化的过程中,其实也初始化了词法分析器,我们可以进入 go fun 中的 syntax.Parse 函数


func Parse(base *PosBase, src io.Reader, errh ErrorHandler, pragh PragmaHandler, mode Mode) (_ *File, first error) {  defer func() {    if p := recover(); p != nil {      if err, ok := p.(Error); ok {        first = err        return      }      panic(p)    }  }()
var p parser p.init(base, src, errh, pragh, mode) //初始化操作 p.next() // 词法解析器对源文件进行解析,转换成全部由token组成的源文件 return p.fileOrNil(), p.first //语法解析器对上边的token文件进行语法解析}
复制代码


可以看到调用了语法分析的初始化操作:


p.init(base, src, errh, pragh, mode)
复制代码


进入到 p.init 中去,我们会看见这么一行代码,它就是对词法分析器的初始化


p.scanner.init(...这里是初始化词法分析器的参数)
复制代码


可以看到语法分析器是用的 p.scanner 调用词法分析器的 init 方法。看一下语法分析器的结构就明白了,在语法分析器的结构体中,嵌入了词法分析器的结构体(本文内容主要是介绍词法分析器,所以在这里先不介绍语法分析器的各个结构体字段的含义,在介绍语法分析的文章中会详细介绍)


//语法分析结构体type parser struct {  file  *PosBase   errh  ErrorHandler  mode  Mode  pragh PragmaHandler  scanner //嵌入了词法分析器
base *PosBase first error errcnt int pragma Pragma
fnest int xnest int indent []byte}
复制代码


搞清楚了语法分析和词法分析的关系,下边就开始看词法分析的具体过程

词法分析过程

词法分析的代码位置在:


src/cmd/compile/internal/syntax/scanner.go
复制代码


词法分析器是通过一个结构体来实现的,它的结构如下:


type scanner struct {  source //source也是一个结构体,它主要记录的是要进行词法分析的源文件的信息,比如内容的字节数组,当前扫描到的字符以及位置等(因为我们知道词法分析过程是对源文件进行从左往右的逐字符扫描的)  mode   uint  //控制是否解析注释  nlsemi bool // if set '\n' and EOF translate to ';'
// current token, valid after calling next() line, col uint //当前扫描的字符的位置,初始值都是0 blank bool // line is blank up to col(词法分析过程中用不到,语法分析过程会用到) tok token // 当前匹配出来的字符串对应的TOKEN(记录了Go中支持的所有Token类型) lit string // Token的源代码文本表示,比如从源文件中识别出了if,那它的TOKEN就是_If,它的lit就是if bad bool // 如果出现了语法错误,获得的lit可能就是不正确的 kind LitKind // 如果匹配到的字符串是一个值类型,这个变量就是标识它属于哪种值类型,比如是INT还是FLOAT还是RUNE类型等 op Operator // 跟kind差不多,它是标识识别出的TOKEN如果是操作符的话,是哪种操作符) prec int // valid if tok is _Operator, _AssignOp, or _IncOp}
type source struct { in io.Reader errh func(line, col uint, msg string)
buf []byte // 源文件内容的字节数组 ioerr error // 文件读取的错误信息 b, r, e int // buffer indices (see comment above) line, col uint // 当前扫描到的字符的位置信息 ch rune // 当前扫描到的字符 chw int // width of ch}
复制代码


知道了词法解析器的结构体各个字段的含义之后,下边了解一下 Go 中有哪些类型 Token

Token

Token 是编程语言中最小的具有独立含义的词法单元,Token 主要包含关键字、自定义的标识符、运算符、分隔符、注释等,这些都可以在:src/cmd/compile/internal/syntax/tokens.go 中看到,我下边截取了一部分(这些 Token 都是以常量的形式存在的)


const (  _    token = iota  _EOF       // EOF
// names and literals _Name // name _Literal // literal
// operators and operations // _Operator is excluding '*' (_Star) _Operator // op _AssignOp // op= _IncOp // opop _Assign // = ......
// delimiters _Lparen // ( _Lbrack // [ _Lbrace // { _Rparen // ) _Rbrack // ] _Rbrace // } ......
// keywords _Break // break _Case // case _Chan // chan _Const // const _Continue // continue _Default // default _Defer // defer ......
// empty line comment to exclude it from .String tokenCount //)
复制代码


每个词法 Token 对应的词法单元,最重要的三个属性就是:词法单元类型Token 在源代码中的文本形式Token 出现的位置。注释和分号是两种比较特殊的 Token,普通的注释一般不影响程序的语义,因此很多时候可以忽略注释(scanner 结构体中的 mode 字段,就是标识是否解析注释)


所有的 Token 被分为四类:


  1. 特殊类型的 Token。比如:_EOF

  2. 基础面值对应的 Token。比如:IntLitFloatLitImagLit

  3. 运算符。比如:Add* // +Sub* // -、*Mul* // *

  4. 关键字。比如:_Break* // break_Case* // case

词法分析实现

在词法分析部分,包含两个核心的方法,一个是nextch(),一个是next()


我们知道,词法分析过程是从源文件中逐字符的读取,nextch()函数就是不断的从左往右逐字符读取源文件的内容


以下为 nextch()函数的部分代码,它主要是获取下一个未处理的字符,并更新扫描的位置信息


func (s *source) nextch() {redo:  s.col += uint(s.chw)  if s.ch == '\n' {    s.line++    s.col = 0  }
// fast common case: at least one ASCII character if s.ch = rune(s.buf[s.r]); s.ch < sentinel { s.r++ s.chw = 1 if s.ch == 0 { s.error("invalid NUL character") goto redo } return }
// slower general case: add more bytes to buffer if we don't have a full rune for s.e-s.r < utf8.UTFMax && !utf8.FullRune(s.buf[s.r:s.e]) && s.ioerr == nil { s.fill() }
// EOF if s.r == s.e { if s.ioerr != io.EOF { // ensure we never start with a '/' (e.g., rooted path) in the error message s.error("I/O error: " + s.ioerr.Error()) s.ioerr = nil } s.ch = -1 s.chw = 0 return }
......}
复制代码


而 next()函数,就是根据扫描的字符来通过上一篇中介绍的确定有穷自动机的思想,分割出字符串,并匹配相应的 token。next()函数的部分核心代码如下:


func (s *scanner) next() {  nlsemi := s.nlsemi  s.nlsemi = false
redo: // skip white space s.stop() startLine, startCol := s.pos() for s.ch == ' ' || s.ch == '\t' || s.ch == '\n' && !nlsemi || s.ch == '\r' { s.nextch() }
// token start s.line, s.col = s.pos() s.blank = s.line > startLine || startCol == colbase s.start() if isLetter(s.ch) || s.ch >= utf8.RuneSelf && s.atIdentChar(true) { s.nextch() s.ident() return }
switch s.ch { case -1: if nlsemi { s.lit = "EOF" s.tok = _Semi break } s.tok = _EOF
case '\n': s.nextch() s.lit = "newline" s.tok = _Semi
case '0', '1', '2', '3', '4', '5', '6', '7', '8', '9': s.number(false)
case '"': s.stdString()......}
复制代码


完整的描述这两个方法所做的事情就是:


  1. 词法分析器每次通过 nextch()函数来获取最新的未解析的字符

  2. 根据扫描到的字符,next()函数会去判断当前扫描到的字符属于那种类型,比如当前扫描到了一个 a 字符,那么它就会去尝试匹配一个标识符类型,也就是 next()函数中调用的 s.ident()方法(并且会判断这个标识符是不是一个关键字)

  3. 如果扫描到的字符是一个数字字符,那就会去尝试匹配一个基础面值类型(比如*IntLitFloatLitImagLit*)

  4. next()识别出一个 token 之后就会传递给语法分析器,然后语法分析器通过调用词法分析器的 next()函数,继续获取下一个 token(所以你会发现,词法分析器并不是一次性的将整个源文件都翻译成 token 之后,再提供给语法分析器,而是语法分析器自己需要一个就通过词法分析器的 next()函数获取一个


我们可以在 next()函数中看到这么一行代码


for s.ch == ' ' || s.ch == '\t' || s.ch == '\n' && !nlsemi || s.ch == '\r' {    s.nextch()  }
复制代码


它是过滤掉源文件中的空格、制表符、换行符等


关于它是如何识别出的一个字符串是基础面值,还是字符串的,可以看里边的ident()number()stdString()这些方法的内部实现,这里就不粘代码了,其实思想就是前一篇文章中介绍的确定有穷自动机


下边我从 Go 编译器的入口开始,画一下词法分析这一块的流程图,方便把介绍的内容串起来



可能看完源码之后,对词法解析器还是没有一个太清晰的了解。下边就通过 Go 为我们提供的测试文件或者标准库来实际的用一下词法分析器,看它到底是如何工作的

测试词法分析过程

测试词法分析有两种方式,你可以直接编译执行 Go 提供的词法分析器测试文件,或者 Go 提供的标准库


词法分析器测试文件地址:src/cmd/compile/internal/syntax/scanner_test.goGo提供的词法分析器标准库地址:src/go/scanner/scanner.go
复制代码


下边我会自己写一个源文件,并将它传递给词法分析器,看它是如何解析的,以及解析的结果是什么样的

通过测试文件测试词法分析器

我们可以直接编译执行 src/cmd/compile/internal/syntax/scanner_test.go 中的TestScanner方法,该方法的源码如下(代码中有注释):


func TestScanner(t *testing.T) {  if testing.Short() {    t.Skip("skipping test in short mode")  }
filename := *src_ // can be changed via -src flag //这里你可以选择一个你自己想解析的源文件的绝对路径 src, err := os.Open("/Users/shulv/studySpace/GolangProject/src/data_structure_algorithm/SourceCode/Token/aa.go") if err != nil { t.Fatal(err) } defer src.Close()
var s scanner s.init(src, errh, 0) //初始化词法解析器 for { s.next() //获取token(next函数里边会调用nextch()方法,不断获取下一个字符,直到匹配到一个token) if s.tok == _EOF { break } if !testing.Verbose() { continue } switch s.tok { //获取到的token值 case _Name, _Literal: //标识符或基础面值 //打印出文件名、行、列、token以及token对应的源文件中的文本 fmt.Printf("%s:%d:%d: %s => %s\n", filename, s.line, s.col, s.tok, s.lit) case _Operator: fmt.Printf("%s:%d:%d: %s => %s (prec = %d)\n", filename, s.line, s.col, s.tok, s.op, s.prec) default: fmt.Printf("%s:%d:%d: %s\n", filename, s.line, s.col, s.tok) } }}
复制代码


该测试函数首先会打开你的源文件,将源文件的内容传递给词法分析器的初始化函数。然后通过一个死循环,不断的去调用 next()函数获取 token,直到遇到结束符_EOF,则跳出循环


我要给词法解析器解析的文件内容如下:


package Token
import "fmt"
func testScanner() { a := 666 if a == 666 { fmt.Println("Learning Scanner") }}
复制代码


然后通过以下命令来将测试方法跑起来(你可以打印更多的信息,sacnner 结构体的字段,你都可以打印出来看看):


# cd /usr/local/go/src/cmd/compile/internal/syntax# go test -v -run="TestScanner"
打印结果:=== RUN TestScannerparser.go:1:1: packageparser.go:1:9: name => Tokenparser.go:1:14: ;parser.go:3:1: importparser.go:3:8: literal => "fmt"parser.go:3:13: ;parser.go:5:1: funcparser.go:5:6: name => testScannerparser.go:5:17: (parser.go:5:18: )parser.go:5:21: {parser.go:6:2: name => aparser.go:6:4: :=parser.go:6:7: literal => 666parser.go:6:10: ;parser.go:7:2: ifparser.go:7:5: name => aparser.go:7:7: op => == (prec = 3)parser.go:7:10: literal => 666parser.go:7:14: {parser.go:8:3: name => fmtparser.go:8:6: .parser.go:8:7: name => Printlnparser.go:8:14: (parser.go:8:15: literal => "Learning Scanner"parser.go:8:33: )parser.go:8:34: ;parser.go:9:2: }parser.go:9:3: ;parser.go:10:1: }parser.go:10:2: ;--- PASS: TestScanner (0.00s)PASSok cmd/compile/internal/syntax 0.007s
复制代码

通过标准库测试词法分析器

另一种测试方法就是通过 Go 提供的标准库,我这里演示一下如何用标准库中的方法来测试词法分析器


你需要写一段代码来调用标准库中的方法,来实现一个词法分析过程,示例如下:


package Token
import ( "fmt" "go/scanner" "go/token")
func TestScanner1() { src := []byte("cos(x)+2i*sin(x) //Comment") //我要解析的内容(当然你也可以用一个文件内容的字节数组) //初始化scanner var s scanner.Scanner fset := token.NewFileSet() //初始化一个文件集(我在下边会解释这个) file := fset.AddFile("", fset.Base(), len(src)) //向字符集中加入一个文件 s.Init(file, src, nil, scanner.ScanComments) //第三个参数是mode,我传的是ScanComments,表示需要解析注释,一般情况下是可以不解析注释的 //扫描 for { pos, tok, lit := s.Scan() //就相当于next()函数 if tok == token.EOF { break } fmt.Printf("%s\t%s\t%q\n", fset.Position(pos), tok, lit) //fset.Position(pos):获取位置信息 }}
复制代码


执行以上代码,得到如下结果:


1:1     IDENT   "cos"1:4     (       ""1:5     IDENT   "x"1:6     )       ""1:7     +       ""1:8     IMAG    "2i"1:10    *       ""1:11    IDENT   "sin"1:14    (       ""1:15    IDENT   "x"1:16    )       ""1:18    ;       "\n"1:18    COMMENT "//Comment"
复制代码


你会发现标准库中使用的方法和测试文件中的完全不一样。这是因为标准库中是单独的实现了一套词法分析器,而并没有去复用 go 编译中词法分析器的代码,我理解这是因为 go 编译器中的代码是不能否作为公用的方法给外部使用的,出于安全考虑,它里边的方法必须保持私有


如果你去看标准库中词法分析器的实现,发现和 go 编译中的实现不太一样,但是核心思想是一样的(比如字符的扫描、token 的识别)。不一样的地方在于要解析的文件的处理,我们知道,在 Go 语言中,由多个文件组成包,然后多个包链接为一个可执行文件,所以单个包对应的多个文件可以看作是 Go 语言的基本编译单元。因此 Go 提供的词法解析器中还定义了 FileSet 和 File 对象,用于描述文件集和文件


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)(行包含每行第一个字符的偏移量(第一个条目始终为0)) infos []lineInfo}
复制代码


作用其实就是记录解析的文件的信息的,就和词法解析器 scanner 结构体中的 source 结构体作用差不多,区别就是,我们知道 go 编译器是创建多个协程并发的对多个文件进行编译,而标准库中通过文件集来存放多个待解析的文件,你会发现 FileSet 的结构体中有一个存放待解析文件的一维数组


下边简单的介绍一下 FileSet 和 File 的关系,以及它是如何计算出 Token 的位置信息的

FileSet 和 File

FileSet 和 File 的对应关系如图所示:



图片来源:go-ast-book


图中 Pos 类型表示数组的下标位置。FileSet 中的每个 File 元素对应底层数组的一个区间,不同的 File 之间没有交集,相邻的 File 之间可能存在填充空间


每个 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


来源:go-ast-book


通过 FileSet 和 File 的关系能看出来,Go 标准库中的词法分析器通过文件集的这种方式,来实现对多个源文件的解析

总结

本文主要是从 Go 编译的入口文件开始,逐步的介绍了 go 编译中词法分析的源码部分实现,并且通过测试文件和 Go 提供的词法分析器标准库,对词法分析器进行了实际的测试和使用。相信看完之后能对 Go 的词法分析过程,有个比较清晰的认识


词法分析部分比较简单,涉及的核心内容较少,真正难的在后边的语法分析和抽象语法树部分,感兴趣的小伙伴请继续关注

发布于: 2 小时前
用户头像

书旅

关注

公众号:IT猿圈 2019.04.11 加入

还未添加个人简介

评论

发布
暂无评论
Go编译原理系列3(词法分析)