词法分析和语法分析是同一个阶段。
词法分析
源代码在计算机『眼中』其实是一团乱麻,一个由字符组成的、无法被理解的字符串,所有的字符在计算器看来并没有什么区别,为了理解这些字符我们需要做的第一件事情就是将字符串分组,这能够降低理解字符串的成本,简化源代码的分析过程。
make(chan int)
哪怕是不懂编程的人看到上述文本的第一反应也应该会将上述字符串分成几个部分 - make、chan、int 和括号,这个凭直觉分解文本的过程就是词法分析,词法分析是将字符序列转换为标记(token)序列的过程。
lex(词法生成器)
lex 是用于生成词法分析器的工具,lex 生成的代码能够将一个文件中的字符分解成 Token 序列,很多语言在设计早期都会使用它快速设计出原型。词法分析作为具有固定模式的任务,出现这种更抽象的工具必然的,lex 作为一个代码生成器,使用了类似 C 语言的语法,我们将 lex 理解为正则匹配的生成器,它会使用正则匹配扫描输入的字符流,下面是一个 lex 文件的示例:
%{
#include <stdio.h>
%}
%%
package printf("PACKAGE ");
import printf("IMPORT ");
\. printf("DOT ");
\{ printf("LBRACE ");
\} printf("RBRACE ");
\( printf("LPAREN ");
\) printf("RPAREN ");
\" printf("QUOTE ");
\n printf("\n");
[0-9]+ printf("NUMBER ");
[a-zA-Z_]+ printf("IDENT ");
%%
这个定义好的文件能够解析 package 和 import 关键字、常见的特殊字符、数字以及标识符,虽然这里的规则可能有一些简陋和不完善,但是用来解析下面的这一段代码还是比较轻松的:
package main
import (
"fmt"
)
func main() {
fmt.Println("Hello")
}
.l 结尾的 lex 代码并不能直接运行,我们首先需要通过 lex 命令将上面的 simplego.l 展开成 C 语言代码,这里可以直接执行如下所示的命令编译并打印文件中的内容:
$ lex simplego.l
$ cat lex.yy.c
...
int yylex (void) {
...
while ( 1 ) {
...
yy_match:
do {
register YY_CHAR yy_c = yy_ec[YY_SC_TO_UI(*yy_cp)];
if ( yy_accept[yy_current_state] ) {
(yy_last_accepting_state) = yy_current_state;
(yy_last_accepting_cpos) = yy_cp;
}
while ( yy_chk[yy_base[yy_current_state] + yy_c] != yy_current_state ) {
yy_current_state = (int) yy_def[yy_current_state];
if ( yy_current_state >= 30 )
yy_c = yy_meta[(unsigned int) yy_c];
}
yy_current_state = yy_nxt[yy_base[yy_current_state] + (unsigned int) yy_c];
++yy_cp;
} while ( yy_base[yy_current_state] != 37 );
...
do_action:
switch ( yy_act )
case 0:
...
case 1:
YY_RULE_SETUP
printf("PACKAGE ");
YY_BREAK
...
}
lex.yy.c的前 600 行基本都是宏和函数的声明和定义,后面生成的代码大都为 yylex 这个函数服务的,这个函数使用 有限自动机(Deterministic Finite Automaton、DFA)的程序结构来分析输入的字符流,上述代码中 while 循环就是这个有限自动机的主体,你如果仔细看这个文件生成的代码会发现当前的文件中并不存在 main 函数,main 函数是在 liblex 库中定义的,所以在编译时其实需要添加额外的 -ll 选项:
$ cc lex.yy.c -o simplego -ll
$ cat main.go | ./simplego
当我们将 C 语言代码通过 gcc 编译成二进制代码之后,就可以使用管道将上面提到的 Go 语言代码作为输入传递到生成的词法分析器中,这个词法分析器会打印出如下的内容:
PACKAGE IDENT
IMPORT LPAREN
QUOTE IDENT QUOTE
RPAREN
IDENT IDENT LPAREN RPAREN LBRACE
IDENT DOT IDENT LPAREN QUOTE IDENT QUOTE RPAREN
RBRACE
从上面的输出我们能够看到 Go 源代码的影子,lex 生成的词法分析器 lexer 通过正则匹配的方式将机器原本很难理解的字符串进行分解成很多的 Token,有利于后面的处理。
从 .l 文件到二进制:

到这里我们已经为各位读者展示了从定义 .l 文件、使用 lex 将 .l 文件编译成 C 语言代码以及二进制的全过程,而最后生成的词法分析器也能够将简单的 Go 语言代码进行转换成 Token 序列。
Go
Go 语言的词法解析是通过 src/cmd/compile/internal/syntax/scanner.go文件中的 cmd/compile/internal/syntax.scanner结构体实现的,这个结构体会持有当前扫描的数据源文件、启用的模式和当前被扫描到的 Token:
type scanner struct {
source
mode uint
nlsemi bool
// current token, valid after calling next()
line, col uint
blank bool // line is blank up to col
tok token
lit string // valid if tok is _Name, _Literal, or _Semi ("semicolon", "newline", or "EOF"); may be malformed if bad is true
bad bool // valid if tok is _Literal, true if a syntax error occurred, lit may be malformed
kind LitKind // valid if tok is _Literal
op Operator // valid if tok is _Operator, _AssignOp, or _IncOp
prec int // valid if tok is _Operator, _AssignOp, or _IncOp
}
src/cmd/compile/internal/syntax/tokens.go 文件中定义了 Go 语言中支持的全部 Token 类型,所有的 token 类型都是正整数,你可以在这个文件中找到一些常见 Token 的定义,例如:操作符、括号和关键字等:
const (
_ token = iota
_EOF // EOF
// operators and operations
_Operator // op
...
// delimiters
_Lparen // (
_Lbrack // [
...
// keywords
_Break // break
...
_Type // type
_Var // var
tokenCount //
)
从 Go 语言中定义的 Token 类型,我们可以将语言中的元素分成几个不同的类别,分别是名称和字面量、操作符、分隔符和关键字。词法分析主要是由 cmd/compile/internal/syntax.scanner 这个结构体中的 cmd/compile/internal/syntax.scanner.next 方法驱动,这个 250 行函数的主体是一个 switch/case 结构:
func (s *scanner) next() {
...
s.stop()
startLine, startCol := s.pos()
for s.ch == ' ' || s.ch == '\t' || s.ch == '\n' && !nlsemi || s.ch == '\r' {
s.nextch()
}
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:
s.tok = _EOF
case '0', '1', '2', '3', '4', '5', '6', '7', '8', '9':
s.number(false)
...
}
}
cmd/compile/internal/syntax.scanner每次都会通过 cmd/compile/internal/syntax.source.nextch函数获取文件中最近的未被解析的字符,然后根据当前字符的不同执行不同的 case,如果遇到了空格和换行符这些空白字符会直接跳过,如果当前字符是 0 就会执行 cmd/compile/internal/syntax.scanner.number方法尝试匹配一个数字。
func (s *scanner) number(seenPoint bool) {
kind := IntLit
base := 10 // number base
digsep := 0
invalid := -1 // index of invalid digit in literal, or < 0
s.kind = IntLit
if !seenPoint {
digsep |= s.digits(base, &invalid)
}
s.setLit(kind, ok)
}
func (s *scanner) digits(base int, invalid *int) (digsep int) {
max := rune('0' + base)
for isDecimal(s.ch) || s.ch == '_' {
ds := 1
if s.ch == '_' {
ds = 2
} else if s.ch >= max && *invalid < 0 {
_, col := s.pos()
*invalid = int(col - s.col) // record invalid rune index
}
digsep |= ds
s.nextch()
}
return
}
上述的 cmd/compile/internal/syntax.scanner.number 方法省略了很多的代码,包括如何匹配浮点数、指数和复数,我们只是简单看一下词法分析匹配整数的逻辑:在 for 循环中不断获取最新的字符,将字符通过 cmd/compile/internal/syntax.source.nextch 方法追加到 cmd/compile/internal/syntax.scanner持有的缓冲区中;
当前包中的词法分析器 cmd/compile/internal/syntax.scanner也只是为上层提供了 cmd/compile/internal/syntax.scanner.next方法,词法解析的过程都是惰性的,只有在上层的解析器需要时才会调用 cmd/compile/internal/syntax.scanner.next 获取最新的 Token。
Go 语言的词法元素相对来说还是比较简单,使用这种巨大的 switch/case 进行词法解析也比较方便和顺手,早期的 Go 语言虽然使用 lex 这种工具来生成词法解析器,但是最后还是使用 Go 来实现词法分析器,用自己写的词法分析器来解析自己。
本文要点:lex、有限自动机、Go词法