这是我参与「第五届青训营」伴学笔记创作活动的第 6 天
go语言的不像其他的一些高级语言如Java,.Net等需要运行在虚拟机上,而是直接编译出二进制文件运行即可。这样就有着不需要运行时虚拟机的空间占用,方便打包成较小的容器镜像部署的优势,同时由于不需要虚拟机解释执行,使用的是AOT(Ahead Of Time)编译优化运行效率也会有一定的优势(虽然现在的虚拟机JIT,Just In Time执行期间的优化也比较成熟)。go语言的编译遵循不允许有循环依赖的引入策略,所有文件编译后静态链接,减少了对运行时库的依赖,同时go语言的编译速度相较于饱受诟病的C++是十分快的,也正因为其编译速度比较快才有了上面在编译二进制执行程序时,可以使用源码编译静态链接的方式。
那么,go语言是如何让自己的编译速度较快的呢?当然一部分原因要归功于其语法特性相对较少,毕竟直到1.19版本才正式加入现代编程语言广为接纳的泛型语法特性,另一个原因就是其手写的编译器代码足够高效了。go语言的编译器采用手写的词法解析器Scanner和手写的语法分析器Parser(虽然目前市面上大部分编程语言的编译器都采取手写递归下降的方法进行语法解析的),Parser会将源文件构建成AST(Abstract Syntax Tree),接下来会对AST做类型检查、变量确定、函数inline、逃逸分析、closure重写、操作改写最后生成用于进一步编译优化的SSA(Static Single Assignment),做完机器无关和机器相关优化后的SSA将会用于生成汇编语言最后到相应的机器码,将机器码链接加入其他执行相关的二进制内容最后生成程序的可执行二进制文件。
这里先讲一下go语言中也是编译原理中相对来说最简单的词法分析Lexer或Tokenizer,在go中这部分代码存在GOROOT的src/go/Scanner和src/go/Token中一个用于读取源文件生成相应的顺序token流,一个是定义好的token和一些必要的token间的关系(比较典型的就是操作符的优先级关系Precedence)。Scanner通过Scan方法返回指定文件当前位置的token并前移,整体遵循状态机的执行方式,通过一个大的switch block分出当前读入的token的大致种类,是一个字面量(literal)还是关键字(keyword)还是标识符(identifier)还是操作符(operator),像是操作符类型的读入字符还有检查这个字符是否是一个多字符操作符的前缀(如+=、/=、==这样的)如果是这样的字符还需要调用特定编写的switch1、switch2...函数做操作符的进一步转换。
go语言的另一大特点是行末的分号操作符可以通过换行、注释或EOF判断省略,这样也意味着,需要Scanner要有一定的上下文感知能力,专门判断当遇到一个换行或、注释或EOF时,是否需要在其前面插入分号操作符。这也是为什么go语言的花括号书写是强制行末的,否则分号的自动插入判断可能会在前面的表达式或声明前添加分号导致语义错误。
另一个我注意到的点是go语言的位操作运算放弃了单独表示按位非得专属操作符,而是给出了一个多字符双目操作符&^按位与非也就是一个mask操作,而之前在类C语言中使用的单目操作符~被用作为泛型的类型带来泛化的操作符,按位非操作符现在与异或操作符^共用,当^单独出现在一个变量的前方时代表这个操作是一个按位非操作符,这点与负号和减法操作符的语义关系类似。这也揭示了在词法分析阶段我们是无法判断这类语义歧义的关系的在这一步二者返回的token类型是一致的,需要在语法语义的分析时才能判断其具体操作含义。