Go 語言的詞法分析和語法分析(1)

godruoyi 發表於 2021-03-23
Go

這篇文章是在看完 [[Go 語言設計與實現]] 前兩章———詞法/語法分析後的總結,作者儘量站在「巨集觀」的角度,說一下 Go 語言中的詞法分析和語法分析,儘量不涉及具體的原始碼探索。

詞法分析

詞法分析(lexical analysis)是 電腦科學 中將字元序列轉換為標記(token)序列的過程——詞法分析 - 維基百科,自由的百科全書

file
圖 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)
}

語法分析

file
圖 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 語言中的詞法分析和語法分析是放在一起進行的;經過這一步,最終將原始碼生成抽象語法樹。

file
圖 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,並將第一個位元組設定為空格 ' ',如圖所示:

file
圖 3.1 buf初始化

然後解析器開始一個字元一個字元的讀取並分析,但由於 buf 目前是空的,第一次解析時會嘗試讀取目標檔案內容到 buf 中;針對上面的 main.go 檔案,讀取後 buf 內容為:

file
圖 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 結束位置。

file
圖 3.3 解析第一個 token

解析器通過 b,r 指標計算(類似 buf[b:r])出本次解析獲得的字串 package;再和 Go 預定義的關鍵字列表對比後,最終將設定當前 sacnner 掃描器的 tok 屬性設定為 _Package。

第一次解析結束後,掃描器的各個屬性如下圖所示:

file
圖 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 指標如下圖所示:

file
圖 3.5 第二次解析結束

在需要什麼的時候,顯示的解析什麼,這也是 Go 解析器漸漸試解析的體現。

同樣,根據 b,r 指標,計算出當前解析獲得的字串為 main;該字串非內建 token,Go 語言將設定當前 sacnner 掃描器的 tok 及 limi 屬性為 _Name 及 main,其中 lit 是被掃描符號的文字表示。

_Name 一般表示變數名/常量名/方法名等,可以理解為非內建關鍵字的文字型別。

第二次解析結束後,scanner 的各個屬性如下圖所示:

file
圖 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:

file
圖 3.7 syntax.File 屬性

接下來,解析器將開始分析 Go 語言的 import 關鍵字,這部分作者將在後面的文章中繼續輸出。

// { ImportDecl ";" }
for p.got(_Import) {
    f.DeclList = p.appendGroup(f.DeclList, p.importDecl)
    p.want(_Semi)
}

文章釋出於作者部落格二愣的閒談雜魚,歡迎大家來觀摩 Go 語言編譯原理 —— 詞法分析和語法分析(1)

參考

本作品採用《CC 協議》,轉載必須註明作者和本文連結
二愣的閒談雜魚