深入挖掘分析Go代码
微信公众号:[double12gzh]
关注容器技术、关注
Kubernetes
。问题或建议,请公众号留言。
写在前面
本文基于GoLang 1.14
在语法层面对源代码进行分析,可以通过多种方式帮助你进行编码。为此,几乎总是先将文本转换成AST,以便在大多数语言中更容易处理。
可能有些人知道,Go有一个强大的包go/parser,有了它,你可以比较容易地将源代码转换为AST。然而,我不禁对它的工作原理充满了好奇,我意识到只有开始阅读API的实现才能满足我的好奇心。
在本文中,我将通过阅读它的API实现,带大家了解它是如何转换的。
即使是对Go语言不熟悉的人,也建议可以看一下本文,因为这是一篇足够通用的文章,通读本文,您可以了解编程语言的分析方法。
这篇文章也是了解编译器和解释器的第一步,同时也是深入研究静态分析的第一步。
AST
先说说阅读实现所需要的一些知识吧。什么是AST(Abstract Syntax Tree)?根据维基百科的介绍。
在计算机科学中,抽象语法树(AST),或者仅仅是语法树,是用编程语言编写的源代码的抽象语法结构的树状表示。树的每个节点都表示源代码中出现的一个构造。
大多数编译器和解释器都使用AST作为源代码的内部表示,AST通常会省略语法树中的分号、换行字符、白空格、大括号、方括号和圆括号等。
用AST可以做什么?
- 源代码分析
- 代码生成
- 可改写
如何生成AST
纯文本对我们来说是很直接的,但从机器角度来看,应该是没有什么比这更难处理的了。因此,你必须先用一个词法分析器
对文本进行词法分析。一般的流程是把它传给一个解析器,然后检索AST。
我想在这里指出,没有一种通用的AST格式可以被任何解析器使用。例如,在GoLang中,x+2用以下格式表示:
*ast.BinaryExpr {
. X: *ast.Ident {
. . NamePos: 1
. . Name: "x"
. . Obj: *ast.Object {
. . . Kind: bad
. . . Name: ""
. . }
. }
. OpPos: 3
. Op: +
. Y: *ast.BasicLit {
. . ValuePos: 5
. . Kind: INT
. . Value: "2"
. }
}
词法分析
如前所述,分析通常从将文本传递给词典开始,然后获取token
。token
是一个带有分配并有识别意义的字符串。go/scanner.Scanner
负责Go中的lexer。
识别的意义是什么?接着往下看。
比方说你写了下面的这段代码:
package main
const s = "foo"
下面就是你把它标记化后的结果:
PACKAGE(package)
IDENT(main)
CONST(const)
IDENT(s)
ASSIGN(=)
STRING("foo")
EOF()
GoLang中所支持的所有的
token
请参考token package
揭开解析API的面纱
为了将GoLang代码生成其对应的AST,我们可以简单的调用:go/parser.ParseFile
,例如:
fset := token.NewFileSet()
f, _ := parser.ParseFile(fset, "foo.go", nil, parser.ParseComments)
通过前一篇文章的学习,我们已经搞清楚了转换的步骤,那么我们就来实际阅读一下该方法的内部实现吧!
Scanner.Scan()
这是词法分析的一种方法,那么,Go如何进行词法分析呢?
如前所述,go/scanner.Scanner
负责Go中的词法分析器。因此,首先让我们仔细看看那个Scanner.Scan()
方法(它是由parser.ParseFile()
内部调用的)。
func (s *Scanner) Scan() (pos token.Pos, tok token.Token, lit string) {
... // Omission
switch ch := s.ch; {
case isLetter(ch):
lit = s.scanIdentifier()
if len(lit) > 1 {
// keywords are longer than one letter - avoid lookup otherwise
tok = token.Lookup(lit)
switch tok {
case token.IDENT, token.BREAK, token.CONTINUE, token.FALLTHROUGH, token.RETURN:
insertSemi = true
}
} else {
... // Omission
}
ch
是当前由Scanner持有的字符。Scanner.Scan()通过调用Scanner.next()前进到下一个字符,只要它可以作为标识符名称,就会并填充ch
。
上面的代码是针对ch
是字母的情况,一旦遇到不能作为标识符的字符,它就会暂停前进,然后确定标识符的类型。
根据字符的不同,有不同的方法来确定单个令牌的起点和终点。例如,在String
的情况下,它会继续前进,直到出现""。
case '"':
insertSemi = true
tok = token.STRING
lit = s.scanString()
func (s *Scanner) scanString() string {
// '"' opening already consumed
offs := s.offset - 1
for {
ch := s.ch
if ch == '\n' || ch < 0 {
s.error(offs, "string literal not terminated")
break
}
s.next()
if ch == '"' {
break
}
if ch == '\\' {
s.scanEscape('"')
}
}
return string(s.src[offs:s.offset])
}
最后,Scanner.Scan()
将会返回一个被认证过的token
。
分析
在看解析文件之前,我们先来看看Go中的文件结构。根据《Go编程语言规范》:每个源文件都由一个包子句组成,定义它所属的包,后面是一组可能是空的导入声明,声明它想使用的包的内容,后面是一组可能是空的函数、类型、变量和常量的声明。
也就是说,其结构是:
- 一个包子句
- 导入声明
- 顶层声明
在解析完包子句和导入声明后,parser.parseFile()
会重复解析声明到文件的最后。
for p.tok != token.EOF {
decls = append(decls, p.parseDecl(declStart))
}
下面我们看一下parser.parseDecl
。
parser.parseDecl()解析声明语法的方法,返回ast.Decl
,即代表 Go 源代码中声明的语法树的节点。
func (p *parser) parseDecl(sync map[token.Token]bool) ast.Decl {
if p.trace {
defer un(trace(p, "Declaration"))
}
var f parseSpecFunction
switch p.tok {
case token.CONST, token.VAR:
f = p.parseValueSpec
case token.TYPE:
f = p.parseTypeSpec
case token.FUNC:
return p.parseFuncDecl()
default:
pos := p.pos
p.errorExpected(pos, "declaration")
p.advance(sync)
return &ast.BadDecl{From: pos, To: p.pos}
}
return p.parseGenDecl(p.tok, f)
}
它通过token
,对每个关键字进行不同的处理。让我们深入了解一下parseFuncDecl()。
if p.tok == token.LPAREN {
recv = p.parseParameters(scope, false)
}
ident := p.parseIdent()
params, results := p.parseSignature(scope)
var body *ast.BlockStmt
if p.tok == token.LBRACE {
body = p.parseBody(scope)
p.expectSemi()
} else if p.tok == token.SEMICOLON {
p.next()
在内部,它通过调用Scanner.Scan()
来处理token(我们在前面已经详细看过了)。
- token.LPAREN代表
(
,所以你可以看到一旦找到(
,它就开始解析参数。 - token.LBRACE代表
{
,所以你可以看到一旦找到{
,它就开始解析函数主体。
写在后面
通过上面的分析,让我觉得自己和以前看似陌生的编译器和解释器更接近了,后面我也在想要不要写一个《在Go中写一个编译器》和《在Go中写一个解释器》。