Go編譯原理系列3(詞法分析)

書旅發表於2022-01-02

前言

在上一篇文章中,介紹了詞法分析中的核心技術,有窮自動機(DFA),以及兩個常見的詞法分析器的使用及工作原理。在這個基礎上去看Go的詞法分析原始碼會輕鬆許多

本文主要包含以下內容:

  1. Go編譯的入口檔案,以及在編譯入口檔案中做了哪些事情
  2. 詞法分析處在Go編譯的什麼位置,以及詳細過程是什麼樣的
  3. 寫一個測試的go原始檔,對這個原始檔進行詞法分析,並獲取到詞法分析的結果

原始碼分析

Go的編譯入口

為了能更清楚的瞭解Go的編譯過程是如何走到詞法分析這一步的,這裡先介紹Go的編譯入口檔案在什麼地方,以及大致做了哪些事情

Go的編譯入口檔案在:src/cmd/compile/main.go -> gc.Main(archInit)

進入到gc.Main(archInit)這個函式,這個函式內容比較長,它前邊部分做的事情主要是獲取命令列傳入的引數並更新編譯選項和配置。然後你會看到下邊這行程式碼

lines := parseFiles(flag.Args())

這就是詞法分析和語法分析的入口,它會對傳入的檔案進行詞法和語法分析,得到的是一棵語法樹,後邊就會將它構建成抽象語法樹,然後對它進行型別檢查等操作,會在後邊的文章中分享

開啟parseFiles(flag.Args())檔案,可以看到如下內容(我省略了後邊部分的程式碼,主要看詞法分析這塊的內容):

func parseFiles(filenames []string) uint {
    noders := make([]*noder, 0, len(filenames))
    // Limit the number of simultaneously open files.
    sem := make(chan struct{}, runtime.GOMAXPROCS(0)+10)

    for _, filename := range filenames {
        p := &noder{
            basemap: make(map[*syntax.PosBase]*src.PosBase),
            err:     make(chan syntax.Error),
        }
        noders = append(noders, p)

        go func(filename string) {
            sem <- struct{}{}
            defer func() { <-sem }()
            defer close(p.err)
            base := syntax.NewFileBase(filename)

            f, err := os.Open(filename)
            if err != nil {
                p.error(syntax.Error{Msg: err.Error()})
                return
            }
            defer f.Close()

            p.file, _ = syntax.Parse(base, f, p.error, p.pragma, syntax.CheckBranches) // errors are tracked via p.error
        }(filename)
    }
    ......
}

我們知道,在Go的編譯過程中,最終每一個原始檔,都會被解析成一棵語法樹,從上邊程式碼的前幾行就能看出來,它首先會建立多個協程去編譯原始檔,但是每次是有限制開啟的原始檔數的

sem := make(chan struct{}, runtime.GOMAXPROCS(0)+10)

然後遍歷原始檔,並起多個協程對檔案進行詞法和語法分析,主要體現在for迴圈和go func這塊。在go func中可以看到,它會先初始化原始檔的資訊,主要是記錄原始檔的名稱、行、列的資訊,目的是為了在進行詞法和語法分析的過程中,如果遇到錯誤,能夠報告出現錯誤的位置。主要包含以下幾個結構體

type PosBase struct {
    pos       Pos
    filename  string
    line, col uint32
}

type Pos struct {
    base      *PosBase
    line, col uint32
}

後邊就是開啟原始檔,開始初始化語法分析器,之所以初始化的是語法分析器的原因是,你會發現,Go的編譯過程中,詞法分析和語法分析是放在一起的,在進行語法分析器初始化的過程中,其實也初始化了詞法分析器,我們可以進入go fun中的syntax.Parse函式

func Parse(base *PosBase, src io.Reader, errh ErrorHandler, pragh PragmaHandler, mode Mode) (_ *File, first error) {
    defer func() {
        if p := recover(); p != nil {
            if err, ok := p.(Error); ok {
                first = err
                return
            }
            panic(p)
        }
    }()

    var p parser
    p.init(base, src, errh, pragh, mode) //初始化操作
    p.next() // 詞法解析器對原始檔進行解析,轉換成全部由token組成的原始檔
    return p.fileOrNil(), p.first //語法解析器對上邊的token檔案進行語法解析
}

可以看到呼叫了語法分析的初始化操作:

p.init(base, src, errh, pragh, mode)

進入到p.init中去,我們會看見這麼一行程式碼,它就是對詞法分析器的初始化

p.scanner.init(...這裡是初始化詞法分析器的引數)

可以看到語法分析器是用的p.scanner呼叫詞法分析器的init方法。看一下語法分析器的結構就明白了,在語法分析器的結構體中,嵌入了詞法分析器的結構體(本文內容主要是介紹詞法分析器,所以在這裡先不介紹語法分析器的各個結構體欄位的含義,在介紹語法分析的文章中會詳細介紹)

//語法分析結構體
type parser struct {
    file  *PosBase 
    errh  ErrorHandler
    mode  Mode
    pragh PragmaHandler
    scanner //嵌入了詞法分析器

    base   *PosBase 
    first  error 
    errcnt int  
    pragma Pragma  

    fnest  int
    xnest  int 
    indent []byte
}

搞清楚了語法分析和詞法分析的關係,下邊就開始看詞法分析的具體過程

詞法分析過程

詞法分析的程式碼位置在:

src/cmd/compile/internal/syntax/scanner.go

詞法分析器是通過一個結構體來實現的,它的結構如下:

type scanner struct {
    source //source也是一個結構體,它主要記錄的是要進行詞法分析的原始檔的資訊,比如內容的位元組陣列,當前掃描到的字元以及位置等(因為我們知道詞法分析過程是對原始檔進行從左往右的逐字元掃描的)
    mode   uint  //控制是否解析註釋
    nlsemi bool // if set '\n' and EOF translate to ';'

    // current token, valid after calling next()
    line, col uint   //當前掃描的字元的位置,初始值都是0
    blank     bool // line is blank up to col(詞法分析過程中用不到,語法分析過程會用到)
    tok       token // 當前匹配出來的字串對應的TOKEN(記錄了Go中支援的所有Token型別)
    lit       string   // Token的原始碼文字表示,比如從原始檔中識別出了if,那它的TOKEN就是_If,它的lit就是if
    bad       bool     // 如果出現了語法錯誤,獲得的lit可能就是不正確的
    kind      LitKind  // 如果匹配到的字串是一個值型別,這個變數就是標識它屬於哪種值型別,比如是INT還是FLOAT還是RUNE型別等
    op        Operator // 跟kind差不多,它是標識識別出的TOKEN如果是操作符的話,是哪種操作符)
    prec      int      // valid if tok is _Operator, _AssignOp, or _IncOp
}

type source struct {
    in   io.Reader
    errh func(line, col uint, msg string)

    buf       []byte // 原始檔內容的位元組陣列
    ioerr     error  // 檔案讀取的錯誤資訊
    b, r, e   int    // buffer indices (see comment above)
    line, col uint   // 當前掃描到的字元的位置資訊
    ch        rune   // 當前掃描到的字元
    chw       int    // width of ch
}

知道了詞法解析器的結構體各個欄位的含義之後,下邊瞭解一下Go中有哪些型別Token

Token

Token是程式語言中最小的具有獨立含義的詞法單元,Token主要包含關鍵字、自定義的識別符號、運算子、分隔符、註釋等,這些都可以在:src/cmd/compile/internal/syntax/tokens.go中看到,我下邊擷取了一部分(這些Token都是以常量的形式存在的)

const (
    _    token = iota
    _EOF       // EOF

    // names and literals
    _Name    // name
    _Literal // literal

    // operators and operations
    // _Operator is excluding '*' (_Star)
    _Operator // op
    _AssignOp // op=
    _IncOp    // opop
    _Assign   // =
    ......

    // delimiters
    _Lparen    // (
    _Lbrack    // [
    _Lbrace    // {
    _Rparen    // )
    _Rbrack    // ]
    _Rbrace    // }
    ......

    // keywords
    _Break       // break
    _Case        // case
    _Chan        // chan
    _Const       // const
    _Continue    // continue
    _Default     // default
    _Defer       // defer
    ......

    // empty line comment to exclude it from .String
    tokenCount //
)

每個詞法Token對應的詞法單元,最重要的三個屬性就是:詞法單元型別Token在原始碼中的文字形式Token出現的位置。註釋和分號是兩種比較特殊的Token,普通的註釋一般不影響程式的語義,因此很多時候可以忽略註釋(scanner結構體中的mode欄位,就是標識是否解析註釋)

所有的Token被分為四類:

  1. 特殊型別的Token。比如:_EOF
  2. 基礎面值對應的Token。比如:IntLitFloatLitImagLit
  3. 運算子。比如:Add* // +Sub* // -、*Mul* // *
  4. 關鍵字。比如:_Break* // break_Case* // case

詞法分析實現

在詞法分析部分,包含兩個核心的方法,一個是nextch(),一個是next()

我們知道,詞法分析過程是從原始檔中逐字元的讀取,nextch()函式就是不斷的從左往右逐字元讀取原始檔的內容

以下為nextch()函式的部分程式碼,它主要是獲取下一個未處理的字元,並更新掃描的位置資訊

func (s *source) nextch() {
redo:
    s.col += uint(s.chw)
    if s.ch == '\n' {
        s.line++
        s.col = 0
    }

    // fast common case: at least one ASCII character
    if s.ch = rune(s.buf[s.r]); s.ch < sentinel {
        s.r++
        s.chw = 1
        if s.ch == 0 {
            s.error("invalid NUL character")
            goto redo
        }
        return
    }

    // slower general case: add more bytes to buffer if we don't have a full rune
    for s.e-s.r < utf8.UTFMax && !utf8.FullRune(s.buf[s.r:s.e]) && s.ioerr == nil {
        s.fill()
    }

    // EOF
    if s.r == s.e {
        if s.ioerr != io.EOF {
            // ensure we never start with a '/' (e.g., rooted path) in the error message
            s.error("I/O error: " + s.ioerr.Error())
            s.ioerr = nil
        }
        s.ch = -1
        s.chw = 0
        return
    }

......
}

而next()函式,就是根據掃描的字元來通過上一篇中介紹的確定有窮自動機的思想,分割出字串,並匹配相應的token。next()函式的部分核心程式碼如下:

func (s *scanner) next() {
    nlsemi := s.nlsemi
    s.nlsemi = false

redo:
    // skip white space
    s.stop()
    startLine, startCol := s.pos()
    for s.ch == ' ' || s.ch == '\t' || s.ch == '\n' && !nlsemi || s.ch == '\r' {
        s.nextch()
    }

    // token start
    s.line, s.col = s.pos()
    s.blank = s.line > startLine || startCol == colbase
    s.start()
    if isLetter(s.ch) || s.ch >= utf8.RuneSelf && s.atIdentChar(true) {
        s.nextch()
        s.ident()
        return
    }

    switch s.ch {
    case -1:
        if nlsemi {
            s.lit = "EOF"
            s.tok = _Semi
            break
        }
        s.tok = _EOF

    case '\n':
        s.nextch()
        s.lit = "newline"
        s.tok = _Semi

    case '0', '1', '2', '3', '4', '5', '6', '7', '8', '9':
        s.number(false)

    case '"':
        s.stdString()
......
}

完整的描述這兩個方法所做的事情就是:

  1. 詞法分析器每次通過nextch()函式來獲取最新的未解析的字元
  2. 根據掃描到的字元,next()函式會去判斷當前掃描到的字元屬於那種型別,比如當前掃描到了一個a字元,那麼它就會去嘗試匹配一個識別符號型別,也就是next()函式中呼叫的s.ident()方法(並且會判斷這個識別符號是不是一個關鍵字)
  3. 如果掃描到的字元是一個數字字元,那就會去嘗試匹配一個基礎面值型別(比如IntLitFloatLitImagLit
  4. next()識別出一個token之後就會傳遞給語法分析器,然後語法分析器通過呼叫詞法分析器的next()函式,繼續獲取下一個token(所以你會發現,詞法分析器並不是一次性的將整個原始檔都翻譯成token之後,再提供給語法分析器,而是語法分析器自己需要一個就通過詞法分析器的next()函式獲取一個

我們可以在next()函式中看到這麼一行程式碼

for s.ch == ' ' || s.ch == '\t' || s.ch == '\n' && !nlsemi || s.ch == '\r' {
        s.nextch()
    }

它是過濾掉原始檔中的空格、製表符、換行符等

關於它是如何識別出的一個字串是基礎面值,還是字串的,可以看裡邊的ident()number()stdString()這些方法的內部實現,這裡就不粘程式碼了,其實思想就是前一篇文章中介紹的確定有窮自動機

下邊我從Go編譯器的入口開始,畫一下詞法分析這一塊的流程圖,方便把介紹的內容串起來

可能看完原始碼之後,對詞法解析器還是沒有一個太清晰的瞭解。下邊就通過Go為我們提供的測試檔案或者標準庫來實際的用一下詞法分析器,看它到底是如何工作的

測試詞法分析過程

測試詞法分析有兩種方式,你可以直接編譯執行Go提供的詞法分析器測試檔案,或者Go提供的標準庫

詞法分析器測試檔案地址:src/cmd/compile/internal/syntax/scanner_test.go
Go提供的詞法分析器標準庫地址:src/go/scanner/scanner.go

下邊我會自己寫一個原始檔,並將它傳遞給詞法分析器,看它是如何解析的,以及解析的結果是什麼樣的

通過測試檔案測試詞法分析器

我們可以直接編譯執行src/cmd/compile/internal/syntax/scanner_test.go中的TestScanner方法,該方法的原始碼如下(程式碼中有註釋):

func TestScanner(t *testing.T) {
    if testing.Short() {
        t.Skip("skipping test in short mode")
    }

    filename := *src_ // can be changed via -src flag
    //這裡你可以選擇一個你自己想解析的原始檔的絕對路徑
    src, err := os.Open("/Users/shulv/studySpace/GolangProject/src/data_structure_algorithm/SourceCode/Token/aa.go")
    if err != nil {
        t.Fatal(err)
    }
    defer src.Close()

    var s scanner
    s.init(src, errh, 0) //初始化詞法解析器
    for {
        s.next() //獲取token(next函式裡邊會呼叫nextch()方法,不斷獲取下一個字元,直到匹配到一個token)
        if s.tok == _EOF {
            break
        }
        if !testing.Verbose() {
            continue
        }
        switch s.tok { //獲取到的token值
        case _Name, _Literal: //識別符號或基礎面值
            //列印出檔名、行、列、token以及token對應的原始檔中的文字
            fmt.Printf("%s:%d:%d: %s => %s\n", filename, s.line, s.col, s.tok, s.lit)
        case _Operator:
            fmt.Printf("%s:%d:%d: %s => %s (prec = %d)\n", filename, s.line, s.col, s.tok, s.op, s.prec)
        default:
            fmt.Printf("%s:%d:%d: %s\n", filename, s.line, s.col, s.tok)
        }
    }
}

該測試函式首先會開啟你的原始檔,將原始檔的內容傳遞給詞法分析器的初始化函式。然後通過一個死迴圈,不斷的去呼叫next()函式獲取token,直到遇到結束符_EOF,則跳出迴圈

我要給詞法解析器解析的檔案內容如下:

package Token

import "fmt"

func testScanner()  {
    a := 666
    if a == 666 {
        fmt.Println("Learning Scanner")
    }
}

然後通過以下命令來將測試方法跑起來(你可以列印更多的資訊,sacnner結構體的欄位,你都可以列印出來看看):

# cd /usr/local/go/src/cmd/compile/internal/syntax
# go test -v -run="TestScanner"

列印結果:
=== RUN   TestScanner
parser.go:1:1: package
parser.go:1:9: name => Token
parser.go:1:14: ;
parser.go:3:1: import
parser.go:3:8: literal => "fmt"
parser.go:3:13: ;
parser.go:5:1: func
parser.go:5:6: name => testScanner
parser.go:5:17: (
parser.go:5:18: )
parser.go:5:21: {
parser.go:6:2: name => a
parser.go:6:4: :=
parser.go:6:7: literal => 666
parser.go:6:10: ;
parser.go:7:2: if
parser.go:7:5: name => a
parser.go:7:7: op => == (prec = 3)
parser.go:7:10: literal => 666
parser.go:7:14: {
parser.go:8:3: name => fmt
parser.go:8:6: .
parser.go:8:7: name => Println
parser.go:8:14: (
parser.go:8:15: literal => "Learning Scanner"
parser.go:8:33: )
parser.go:8:34: ;
parser.go:9:2: }
parser.go:9:3: ;
parser.go:10:1: }
parser.go:10:2: ;
--- PASS: TestScanner (0.00s)
PASS
ok      cmd/compile/internal/syntax    0.007s

通過標準庫測試詞法分析器

另一種測試方法就是通過Go提供的標準庫,我這裡演示一下如何用標準庫中的方法來測試詞法分析器

你需要寫一段程式碼來呼叫標準庫中的方法,來實現一個詞法分析過程,示例如下:

package Token

import (
    "fmt"
    "go/scanner"
    "go/token"
)

func TestScanner1()  {
    src := []byte("cos(x)+2i*sin(x) //Comment") //我要解析的內容(當然你也可以用一個檔案內容的位元組陣列)
    //初始化scanner
    var s scanner.Scanner
    fset := token.NewFileSet() //初始化一個檔案集(我在下邊會解釋這個)
    file := fset.AddFile("", fset.Base(), len(src)) //向字符集中加入一個檔案
    s.Init(file, src, nil, scanner.ScanComments) //第三個引數是mode,我傳的是ScanComments,表示需要解析註釋,一般情況下是可以不解析註釋的
    //掃描
    for  {
        pos, tok, lit := s.Scan() //就相當於next()函式
        if tok == token.EOF {
            break
        }
        fmt.Printf("%s\t%s\t%q\n", fset.Position(pos), tok, lit) //fset.Position(pos):獲取位置資訊
    }
}

執行以上程式碼,得到如下結果:

1:1     IDENT   "cos"
1:4     (       ""
1:5     IDENT   "x"
1:6     )       ""
1:7     +       ""
1:8     IMAG    "2i"
1:10    *       ""
1:11    IDENT   "sin"
1:14    (       ""
1:15    IDENT   "x"
1:16    )       ""
1:18    ;       "\n"
1:18    COMMENT "//Comment"

你會發現標準庫中使用的方法和測試檔案中的完全不一樣。這是因為標準庫中是單獨的實現了一套詞法分析器,而並沒有去複用go編譯中詞法分析器的程式碼,我理解這是因為go編譯器中的程式碼是不能否作為公用的方法給外部使用的,出於安全考慮,它裡邊的方法必須保持私有

如果你去看標準庫中詞法分析器的實現,發現和go編譯中的實現不太一樣,但是核心思想是一樣的(比如字元的掃描、token的識別)。不一樣的地方在於要解析的檔案的處理,我們知道,在Go語言中,由多個檔案組成包,然後多個包連結為一個可執行檔案,所以單個包對應的多個檔案可以看作是Go語言的基本編譯單元。因此Go提供的詞法解析器中還定義了FileSet和File物件,用於描述檔案集和檔案

type FileSet struct {
    mutex sync.RWMutex // protects the file set
    base  int          // base offset for the next file
    files []*File      // list of files in the order added to the set
    last  *File        // cache of last file looked up
}

type File struct {
    set  *FileSet
    name string // file name as provided to AddFile
    base int    // Pos value range for this file is [base...base+size]
    size int    // file size as provided to AddFile

    // lines and infos are protected by mutex
    mutex sync.Mutex
    lines []int // lines contains the offset of the first character for each line (the first entry is always 0)(行包含每行第一個字元的偏移量(第一個條目始終為0))
    infos []lineInfo
}

作用其實就是記錄解析的檔案的資訊的,就和詞法解析器scanner結構體中的source結構體作用差不多,區別就是,我們知道go編譯器是建立多個協程併發的對多個檔案進行編譯,而標準庫中通過檔案集來存放多個待解析的檔案,你會發現FileSet的結構體中有一個存放待解析檔案的一維陣列

下邊簡單的介紹一下FileSet和File的關係,以及它是如何計算出Token的位置資訊的

FileSet和File

FileSet和File的對應關係如圖所示:

圖片來源:go-ast-book

圖中Pos型別表示陣列的下標位置。FileSet中的每個File元素對應底層陣列的一個區間,不同的File之間沒有交集,相鄰的File之間可能存在填充空間

每個File主要由檔名、base和size三個資訊組成。其中base對應File在FileSet中的Pos索引位置,因此base和base+size定義了File在FileSet陣列中的開始和結束位置。在每個File內部可以通過offset定位下標索引,通過offset+File.base可以將File內部的offset轉換為Pos位置。因為Pos是FileSet的全域性偏移量,反之也可以通過Pos查詢對應的File,以及對應File內部的offset

而詞法分析的每個Token位置資訊就是由Pos定義,通過Pos和對應的FileSet可以輕鬆查詢到對應的File。然後在通過File對應的原始檔和offset計算出對應的行號和列號(實現中File只是儲存了每行的開始位置,並沒有包含原始的原始碼資料)。Pos底層是int型別,它和指標的語義類似,因此0也類似空指標被定義為NoPos,表示無效的Pos

來源:go-ast-book

通過FileSet和File的關係能看出來,Go標準庫中的詞法分析器通過檔案集的這種方式,來實現對多個原始檔的解析

總結

本文主要是從Go編譯的入口檔案開始,逐步的介紹了go編譯中詞法分析的原始碼部分實現,並且通過測試檔案和Go提供的詞法分析器標準庫,對詞法分析器進行了實際的測試和使用。相信看完之後能對Go的詞法分析過程,有個比較清晰的認識

詞法分析部分比較簡單,涉及的核心內容較少,真正難的在後邊的語法分析和抽象語法樹部分,感興趣的小夥伴請繼續關注

相關文章