這篇文章是在看完 [[Go 語言設計與實現]] 前兩章———詞法/語法分析後的總結,作者儘量站在「巨集觀」的角度,說一下 Go 語言中的詞法分析和語法分析,儘量不涉及具體的原始碼探索。
詞法分析
詞法分析(lexical analysis)是 電腦科學 中將字元序列轉換為標記(token)序列的過程——詞法分析 - 維基百科,自由的百科全書。
圖 1.0 詞法分析
可以簡單的理解為將原始碼按一定的轉換規則,翻譯為字元序列的過程。
如給定的如下轉換規則:
%%
package printf("PACKAGE ");
import printf("IMPORT ");
func printf("FUNC ");
\. 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
翻譯為 PACKAGE
,其他類似;若用該規則來翻譯如下的原始碼:
package main
import (
"fmt"
)
func main() {
fmt.Println("Hello")
}
則輸出的 Token 序列為:
PACKAGE IDENT
IMPORT LPAREN
QUOTE IDENT QUOTE
RPAREN
FUNC IDENT LPAREN RPAREN LBRACE
IDENT DOT IDENT LPAREN QUOTE IDENT QUOTE RPAREN
RBRACE
輸出的 Token 序列已經看不出是什麼語言了;當然,這種純字元的 Token 對後續的分析幫助不大,一般分析器都會把他封裝為一個結構體,以包含更多資訊。
type tokenx struct {
tok token // 解析的 Token 序列(如上的 IDENT)
lim string // 原始值(如 main)
}
語法分析
圖 2.0 語法分析
語法分析是將詞法分析的輸出當作輸入,按照給定的語法格式進行分析並確定其語法結構的一種過程。
舉個列子,假設輸入序列為 S,對應的語法規則為:
S -> E + F
其含義為輸入序列 S 可以表示為兩個子項 E、F 相加;而 E 可以表示為:
E -> E + F
-> F
其含義為 E 可以分解為兩個子項 E、F 相加;或分解為一個單獨的 F 項;
F -> num{1, 2, 3, 4, 5}
而 F 表示數字集合,假設只有上述數字集合,即 F ∈ {1, 2, 3, 4, 5}。
則下面的字元序列都是有意義的,即給定的字元序列 S,是否能從語法規則中被推匯出來
1 + 1
1 + 2
2 + 3 + 4
1 + 2 + 3 + 4
1 + 3 + 2 + 4 + 5
假設給定的字元序列 S 為 2 + 3 + 4,按上述的規則 S -> E + F,分步推到如下(按最右推導,即從最右邊字元開始):
- S -> E + F // 原規則
- S -> E + 1 // 將 F 解析為數字 1,1 ≠ 4 繼續回溯
- S -> E + 2 // 2 ≠ 4 繼續回溯
- S -> E + 3 // 3 ≠ 4 繼續回溯
- S -> E + 4 // ok,數字 4 已最小不可分解
- S -> (E + F) + 4 // 將 E 按推導為 E + F 格式
- S -> (E + 1) + 4 // 將 F 解析為數字 1,1 ≠ 3 繼續回溯
- S -> (E + 2) + 4 // 將 F 解析為數字 2,2 ≠ 3 繼續回溯
- S -> (E + 3) + 4 // ok
- S -> (F + 3) + 4 // 將 E 按推導為 F 格式
- S -> (1 + 3) + 4 // 將 F 解析為數字 1,1 ≠ 2 繼續回溯
- S -> (2 + 3) + 4 // ok
即通過給定的規則,推匯出字串 S1,該字串和原始字元序列 S 相等,語法分析即認為該輸入序列是合法的。
而下面的字元序列是無效的,即語法錯誤;
7 + 1 // 不合法,不可能推出數字 7
1 + 2 + 6 // 不合法,不可能推出數字 6
可以看到,語法分析器將採用遞迴的思想,一層一層分析,直到子項不可再分;具體的推導過程推薦觀看 編譯原理 — 中科大_嗶哩嗶哩 P39。
Go 語言的詞法分析 & 語法分析
Go 語言中的詞法分析和語法分析是放在一起進行的;經過這一步,最終將原始碼生成抽象語法樹。
圖 3.0 Go語言的詞法分析與語法分析
作者將通過下面的列子來探索 Go 語言的詞法分析過程。假設目標資料夾中有兩個檔案 hello.go 和 main.go,其內容如下;
// main.go
package main
import "fmt"
func main() {
fmt.Println(Hello)
}
// hello.go
package main
var Hello string = "Hello world"
Go 語言的編譯器入口是在 src/cmd/compile/main.go;在進行一些初始化準備工作後,編譯器獲取待解析的檔案陣列,並將其交給 Go 語言解析器負責解析。
var filenames = []string{
"main.go",
"hello.go",
}
// src/cmd/compile/internal/gc/main.go:578
parsefiles(filenames)
解析器會利用多個 goroutine 來併發解析原始檔,其 goroutine 數量取決於處理器的核心數 + 10,主要作用是為了控制同時開啟的檔案數量。
// Limit the number of simultaneously open files.
sem := make(chan struct{}, runtime.GOMAXPROCS(0)+10)
for _, filename := range filenames {
go func(filename string) {
sem <- struct{}{}
defer func() { <-sem }()
file := syntax.Parse(os.Open(filename))
}(filename)
}
// wait
syntax.Parse 解析器會將每一個原始檔解析為一個 source 結構體,其簡化的格式如下:
type source struct {
buf []byte // 原始碼位元組陣列
ch rune // 指向當前解析的字元
b,r,e int // 指向buf陣列的開始/當前掃描字元/結束指標
}
解析器在初始化時,會將 buf 初始化為一個 4k 大小的空 slice,並將第一個位元組設定為空格 ' '
,如圖所示:
圖 3.1 buf初始化
然後解析器開始一個字元一個字元的讀取並分析,但由於 buf 目前是空的,第一次解析時會嘗試讀取目標檔案內容到 buf 中;針對上面的 main.go 檔案,讀取後 buf 內容為:
圖 3.2 讀取檔案內容
接下來,解析器會嘗試從 buf 中一個字元一個字元的讀取,再遇到 ' ', \n, \t, \r
等字元時分離。
for s.ch == ' ' || s.ch == '\t' || s.ch == '\n' && !nlsemi || s.ch == '\r' {
s.nextch()
}
針對圖 3.2 的 buf 內容,第一次操作結束後,掃描器中的 b,r,e 指標如下圖所示:
b: begin 開始位置;r: read 當前讀取到的位置;e: end 結束位置。
圖 3.3 解析第一個 token
解析器通過 b,r 指標計算(類似 buf[b:r]
)出本次解析獲得的字串 package
;再和 Go 預定義的關鍵字列表對比後,最終將設定當前 sacnner 掃描器的 tok 屬性設定為 _Package。
第一次解析結束後,掃描器的各個屬性如下圖所示:
圖 3.4 掃描器scanner基本屬性情況
Go 語言的詞法分析是漸漸試的,在獲得第一個 token 後,解析器嘗試初始化 SourceFile;每一個 SourceFile 對應一個 go 檔案,其中包含包的名稱,變數、常量、函式等的全部定義,其簡化結構如下:
type File struct {
PkgName *Name // package name
DeclList []Decl // 包含包名/變數/常量/函式等的定義
}
Go 原始碼中使用 fileOrNil 方法初始化 file,解析器將檢查第一個 token 是否為 pakcage,否則將報錯;畢竟所有的 go 檔案都是以 package 開頭的。
// 虛擬碼
func fileOrNil() {
f := new(File)
if tok != _Package {
p.syntaxError("package statement must be first")
}
f.PkgName = ?
}
接下來解析器將設定當前 file 的包名,但此時解析器並不知道具體的包名是什麼;不過根據語法規則解析器知道 package 關鍵字後面一定是跟的包名,只需要解析出下一個 token,該 token 就是對應的包名稱。
第二次解析結束後,掃描器中的 b,r,e 指標如下圖所示:
圖 3.5 第二次解析結束
在需要什麼的時候,顯示的解析什麼,這也是 Go 解析器漸漸試解析的體現。
同樣,根據 b,r 指標,計算出當前解析獲得的字串為 main
;該字串非內建 token,Go 語言將設定當前 sacnner 掃描器的 tok 及 limi 屬性為 _Name 及 main,其中 lit 是被掃描符號的文字表示。
_Name 一般表示變數名/常量名/方法名等,可以理解為非內建關鍵字的文字型別。
第二次解析結束後,scanner 的各個屬性如下圖所示:
圖 3.6 掃描器scanner基本屬性情況
檢查 _Package 及設定當前檔案 PkgName 的虛擬碼如下:
f := new(File)
if tok != _Package {
p.syntaxError("package statement must be first")
}
tok := parser.next() // 虛擬碼 get next token
if tok != _Name {
// 驗證:包名必須是一個 name 型別的 token,
// 可以理解為非內建關鍵字的文字型別
}
f.PkgName = &Name{Value: "main"} // 虛擬碼
到目前為止,解析器已經成功的初始化 syntax.File 結構併為其 PkgName 屬性賦值,如圖 3.7:
圖 3.7 syntax.File 屬性
接下來,解析器將開始分析 Go 語言的 import 關鍵字,這部分作者將在後面的文章中繼續輸出。
// { ImportDecl ";" }
for p.got(_Import) {
f.DeclList = p.appendGroup(f.DeclList, p.importDecl)
p.want(_Semi)
}
文章釋出於作者部落格二愣的閒談雜魚,歡迎大家來觀摩 Go 語言編譯原理 —— 詞法分析和語法分析(1)。
參考
- 解析器眼中的 Go 語言 | Go 語言設計與實現
- 語法分析 - 維基百科,自由的百科全書
- 詞法分析 - 維基百科,自由的百科全書
- 《Go語法樹入門——開啟自制程式語言和編譯器之旅》
- 編譯原理 — 中科大_嗶哩嗶哩 (゜-゜)つロ 乾杯~-bilibili
本作品採用《CC 協議》,轉載必須註明作者和本文連結