1. 程式人生 > 實用技巧 >深入挖掘分析Go程式碼

深入挖掘分析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中寫一個直譯器》。