深入挖掘分析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"
.  }
}

词法分析

如前所述,分析通常从将文本传递给词典开始,然后获取tokentoken是一个带有分配并有识别意义的字符串。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()内部调用的)。

scanner/scanner.go

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的情况下,它会继续前进,直到出现""。

scanner/scanner.go

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()会重复解析声明到文件的最后。

parser/parser.go

for p.tok != token.EOF {
	decls = append(decls, p.parseDecl(declStart))
}

下面我们看一下parser.parseDecl

parser.parseDecl()解析声明语法的方法,返回ast.Decl,即代表 Go 源代码中声明的语法树的节点。

parser/parser.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()。

parser/parser.go

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中写一个解释器》。

posted @ 2020-09-10 11:52  大海星  阅读(740)  评论(0编辑  收藏  举报