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