Go语言编译器的阶段
简介
在经典编译原理中,一般将编译器分为编译器前端、优化器和编译器后端。这种被称为三阶段编译器。
- 编译器前端:主要专注于理解源程序、扫描解析源程序并进行精准的语义表达。
- 编译器中间阶段(IR):可能有多个阶段,编译器会使用多个IR阶段、多种数据表示代码,并且进行优化。
- 编译器后端:专注于生成特定目标机器上的程序。
Go语言编译器一般缩写为小写的gc,大写的GC是垃圾回收。
Go语言编译器的执行流程可细化为多个 阶段,包括词法解析、语法解析、抽象语法树构建、类型检查、变量捕获、函数内联、逃逸分析、闭包重写、遍历函数、SSA生成、机器码生成。
词法解析
在词法解析阶段,Go语言编译器会扫描输入的Go源文件,并将其符号 (token)化。 这些token实质上是用iota声明的整数, 定义在syntax/tokens.go中。 代码中声明的标识符、关键字、运算符和分隔符等字符串都可以转化为对应的符号。
token是编程语言中最小的具有独立含义的词法单元。
Token不仅仅包含关键字,还包含用户自定义的标识符、运算符、分隔符和注释等。
每个Token对应的词法单元有三个属性是比较重要的:首先是Token本身的值表示词法单元的类型,其次是Token在源代码中源代码文本形式,最后是Token出现的位置。在所有的Token中,注释和分号是两种比较特殊的Token:普通的注释一般不影响程序的语义,因此很多时候可以忽略注释;而Go语言中经常在行尾自动添加分号Token,而分号是分隔语句的词法单元,因此自动添加分号导致了Go语言左花括弧不能单独一行等细微的语法差异。
Token语法
Go语言中主要有标识符、关键字、运算符和分隔符等类型等Token组成。其中标识符的语法定义如下:
identifier = letter { letter | unicode_digit } .
letter = unicode_letter | "_" .
其中identifier表示标识符,由字母和数字组成,开头第一个字符必须是字母。需要注意的是下划线也是作为字母,因此可以用下划线作为标识符。不过美元符号$并不属于字母,因此标识符中不能包含美元符号。
在标识符中有一类特殊的标识符被定义为关键字。关键字用于引导特殊的语法结构,不能将关键字作为独立的标识符()。下面是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
除了标识符和关键字,Token还包含运算符和分隔符。下面是Go语言定义的47个符号:
+ & += &= && == != ( )
- | -= |= || < <= [ ]
* ^ *= ^= <- > >= { }
/ << /= <<= ++ = := , ;
% >> %= >>= -- ! ... . :
&^ &^=
当然,除了用户自定义的标识符、25个关键字、47个运算和分隔符号,程序中还有一些面值、注释和空白符组成。要解析一个Go语言程序,第一步就是要解析这些Token。
Token定义
在go/token包中,Token被定义为一种枚举值,不同值的Token表示不同类型的词法记号:
// Token is the set of lexical tokens of the Go programming language.
type Token int
所有的Token被分为四类:特殊类型的Token、基础面值对应的Token、运算符Token和关键字。
特殊类型的Token有错误、文件结束和注释三种:
// The list of tokens.
const (
// Special tokens
ILLEGAL Token = iota
EOF
COMMENT
遇到不能识别的Token统一返回ILLEGAL,这样可以简化词法分析时的错误处理。
FileSet和File
在定义Token之后,其实我们可以通过手工方式对源代码进行简单的词法分析了。不过如果希望以后能够复用词法分析的代码,则需要小心设计和源代码部分相关的接口。参考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
}
这里简单介绍一下 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 对象的对应关系如图所示:
可以看到图中多了个 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。
尝试解析
词法分析通常是一个一个字符的读取输入的代码, 通过当前读取到的字符, 搭配一个解析词法的状态机来决定当前读取到的 Token 的类型。有时, 一个字符并不能提供足够的信息来做出这种判断, 此时就需要预先读取下一个或多个字符来辅助词法分析器做出判断。
Go语言标准库go/scanner包提供了 Scanner 实现 Token 扫描,它是在 FileSet 和 File 抽象文件集合基础上进行词法分析。主要有三个重要的方法:Init(),Next() 和 Scan()。 Init方法用于初始化扫描器,Next 方法用于寻找下一个字符,Scan 方法最为核心,用于扫描代码返回 Token,scan 方法有三个返回值,分别表示 Token 的位置 、Token 值和 Token 的源代码文本表示。
func ExampleScanner_Scan() {
// src is the input that we want to tokenize.
src := []byte("cos(x) + 1i*sin(x) // Euler")
// Initialize the scanner.
var s scanner.Scanner
fset := token.NewFileSet() // positions are relative to fset
file := fset.AddFile("", fset.Base(), len(src)) // register input "file"
s.Init(file, src, nil /* no error handler */, scanner.ScanComments)
// Repeated calls to Scan yield the token sequence found in the input.
for {
pos, tok, lit := s.Scan()
if tok == token.EOF {
break
}
fmt.Printf("%s\t%s\t%q\n", fset.Position(pos), tok, lit)
}
// output:
// 1:1 IDENT "cos"
// 1:4 ( ""
// 1:5 IDENT "x"
// 1:6 ) ""
// 1:8 + ""
// 1:10 IMAG "1i"
// 1:12 * ""
// 1:13 IDENT "sin"
// 1:16 ( ""
// 1:17 IDENT "x"
// 1:18 ) ""
// 1:20 ; "\n"
// 1:20 COMMENT "// Euler"
}
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)方法完成。
总结
词法分析可以简单的理解为将源代码按一定的转换规则,翻译为字符序列的过程。
比如用规则来翻译如下的源代码:
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