Go編譯原理系列2(詞法分析&語法分析基礎)

書旅發表於2021-12-23

前言

關注公眾號:IT猿圈,後臺回覆:Go編譯原理系列1,可獲得pdf版

前一篇編譯原理的文章中,並沒有介紹詞法分析是如何將原始檔中的字元轉換成一個個的詞法單元,中間用到了哪些技術或工具。也沒有詳細的介紹語法分析階段中的一些常見的文法及語法分析方式。所以,本文你可以瞭解到:

  • 詞法分析器是如何將我們的原始檔中的字元翻譯成詞法單元的(不確定有窮狀態機&確定有窮狀態機)
  • 有哪些常見的詞法分析器?他們是如何工作的?
  • 上下文無關文法
  • Go語言中的一些文法的規則
  • 抽象語法樹的生成
  • 語法分析的一些方法(自頂向下、自底向上)

? Tips:下邊的內容可能會比較抽象,特別是語法分析基礎部分涉及到的文法以及處理文法的兩種方法。但是我都會通過表格或者圖的方式,將每一步都描述清楚,相信您堅持看完,一定會有所收穫

詞法分析基礎

Token

前一篇編譯原理的文章中可以知道,詞法分析的任務是從左往右逐字元掃描源程式內容,識別出各個單詞,確定單詞的型別。將識別出的單詞轉換成統一的機內表示———詞法單元(token)形式

比如識別出這些單詞是關鍵字、識別符號、常量、界限符、還是運算子等。因為在任何一門程式語言中,這些型別是可以列舉的,所以一般會給定義好,比如Go語言中用_Name來表示識別符號,用_Operator來表示操作符

_Name_Operator就是我們常說的Token,最終被編譯的原始檔中的內容,都會被詞法分析器解析成這樣的Token形式

那詞法解析器是如何識別出原始檔中各個單詞,並判斷單詞是關鍵字?還是運算子?還是界限符的?這就用到了確定有窮自動機

不確定有窮自動機 & 確定有窮自動機

這裡不詳細介紹有窮自動機裡邊的字母表、句子、符號等抽象的概念,主要弄明白它是怎麼工作的即可,它的實現不是本文的重點,對有窮自動機感興趣的可以看《編譯原理》的第三章

有窮自動機分為兩類,即不確定的有窮自動機(NFA)和確定的有窮自動機(DFA)。下邊依次介紹這兩個有窮自動機是如何工作的

不確定有窮自動機(NFA)

其實我們用一門程式語言編寫的程式碼,可以看做是一個長字串,詞法分析過程要做的就是識別出這個長字串中,哪些是關鍵字、哪些是運算子、哪些是識別符號等

如果讓我們來做這樣一件事情,最容易想到的就是用正規表示式。其實詞法分析器進行解析的過程,也是利用正則,通過正則將長字串進行分割,分割出來的字串(可能是識別符號、可能是運算子等)去匹配到相應的token

假設說有一個正規表示式(a|b)*abb,然後給你一個字串,判斷這個字串是否滿足這個正規表示式?

如果你對回溯演算法比較熟悉的話,我們知道它可以通過簡單的回溯演算法來實現。但是這裡用到另一種方法來實現,就是不確定有窮自動機(NFA)

根據前邊給的正規表示式,可以畫出如下的有窮自動機:

紅色圓中的數字表示的是狀態

  • 當在0這個狀態的時候遇到字元a或者b,則狀態遷移到本身
  • 0這個狀態遇到a也可以遷移到狀態1
  • 1這個狀態遇到b,則遷移到狀態2
  • 2這個狀態遇到b,則遷移到狀態3(3是最終狀態)

該狀態機一共有四個狀態,其中0、1、2這三個狀態是一個單層的圓表示的,3這個狀態是用兩層的圓表示的。單層的圓表示的是狀態機的中間狀態,雙層的圓表示的是狀態機的最終狀態。箭頭和其上方的字元,表示的是每個狀態遇到不同的輸入,遷移到另一個狀態

你可以把以下幾個字串作為上邊這個狀態機的輸入,看是否能夠走到最終狀態,如果能走到,說明可以匹配,如果不能走到,說明不能匹配

可以匹配:abb、aabb、babb、aababb
不可匹配:a、ab、bb、acabb

從上邊可以看出來,NFA能夠解決匹配原始檔中字串目的,這樣看好像我們只要針對識別符號、關鍵字、常量等寫出相應的正規表示式,然後通過自動機就能分割出原始檔中各個字串,然後匹配相應的token

但是它有一個缺陷,比如拿abb去上邊的狀態機去匹配,0狀態遇到a可能還是0這個狀態,0狀態再遇到0,還是0這個狀態,再遇到b還是0這個狀態,這樣它又不能匹配了,實際上abb是能滿足(a|b)*abb這個正規表示式的。原因就是在0狀態的時候遇到a,它轉移的狀態是不確定的,所以它叫不確定有窮自動機

為了解決上邊的問題,就出現了確定有窮自動機

確定有窮自動機(DFA)

還是上邊那個正規表示式:(a|b)*abb,畫出它的有窮自動機就是下邊這樣

  • 0這個狀態遇到a,則遷移到狀態1
  • 0這個狀態遇到b,則還是遷移到本身狀態0
  • 1這個狀態遇到a,則還是遷移到本身狀態1
  • 1這個狀態遇到b,則遷移到狀態2
  • 2這個狀態遇到a,則遷移到狀態1
  • 2這個狀態遇到b,則遷移到狀態3(3是最終狀態)
  • 3這個狀態遇到a,則遷移到1狀態
  • 3這個狀態遇到b,則遷移到0狀態

0、1、2是中間狀態,3是最終狀態。與不確定有窮自動機的區別就是,它的每一個狀態遇到的輸入,都會有一個確定的狀態遷移。你可以用前邊給的字串來驗證一下這個有窮自動機

這樣通過DFA就可以解決我們的問題。但是,如果這樣的話,要對一個原始檔進行詞法分析,我們就需要寫很多的正規表示式,並且需要手動的為每一個正規表示式寫有窮狀態機的實現

為了解決這個問題,就出現了很多的詞法解析器的工具,能讓我們避免手動的去實現一個有窮自動機,下邊就簡單介紹兩個常見的詞法解析器的工具

詞法分詞器

re2c

我們可以編寫符合re2c規則的檔案,然後通過re2c生成.c的檔案,再去編譯執行這個.c檔案

如果你沒有安裝re2c,需要先安裝一下(點選下載)。下載完成之後,安裝過程

1. 解壓:tar -zxvf re2c-1.1.1.tar.gz
2. 進入解壓出來的目錄:cd re2c-1.1.1
3. ./configure
4. make && make install

下邊就是編寫一個re2c的原始檔。假設我要識別一個數字是二進位制的還是八進位制的還是十六進位制的,看一下用re2c是如何編寫的(re2c的原始檔是.l的檔案)

#include <stdio.h> //標頭檔案,後邊用到的標註輸入輸出就用到了這個標頭檔案裡的方法
enum num_t { ERR, BIN, OCT, DEC, HEX }; //定義了5個列舉值
static num_t lex(const char *YYCURSOR) //返回值型別是num_t。下邊函式看著只有一行程式碼,還有一堆註釋,其實這一堆註釋就是re2c的核心程式碼,!re2c是它的開頭
{
    const char *YYMARKER;
    /*!re2c
        re2c:define:YYCTYPE = char;
        re2c:yyfill:enable = 0;
        end = "\x00";
        bin = '0b'[01]+; //這些都是正規表示式
        oct = "0"[0-7]*;
        dec = [1-9][0-9]*;
        hex = '0x'[0-9a-fA-F]+;
        *       { return ERR; }
        bin end { return BIN; } //如歸以匹配的二進位制形式開頭,並且以匹配的end形式結尾,就返回二進位制的列舉值,其餘同理
        oct end { return OCT; }
        dec end { return DEC; }
        hex end { return HEX; }
    */
}
int main(int argc, char **argv) //獲取引數並遍歷,呼叫lex函式,根據它的返回值來進行switch,看它屬於那種型別的數字
{
    for (int i = 1; i < argc; ++i) {
        switch(lex(argv[i])) {
            case ERR: printf("error\n");break;
            case BIN: printf("binary\n");break;
            case OCT: printf("octal\n");break;
            case DEC: printf("decimal\n");break;
            case HEX: printf("hexadecimal\n");break;
        }
    }
    return 0;
}

說明:如果你將程式碼粘過去不能正常使用,嘗試把我寫的註釋去掉

然後去處理這個.l檔案

# re2c integer.l -o integer.c
# g++ integer.c -o integer
# ./integer 0b10(此時應該輸出binary)

你也可以試一下其它的進位制數,都可以正常得到它是哪種進位制的數

現在你可以開啟剛才我們生產的integer.c檔案,它的內容如下:

/* Generated by re2c 1.1.1 on Thu Dec  9 23:09:54 2021 */
#line 1 "integer.l"
#include <stdio.h>
enum num_t { ERR, BIN, OCT, DEC, HEX };
static num_t lex(const char *YYCURSOR)
{
    const char *YYMARKER;
    
#line 10 "integer.c"
{
    char yych;
    yych = *YYCURSOR;
    switch (yych) {
    case '0':    goto yy4;
    case '1':
    case '2':
    case '3':
    case '4':
    case '5':
    case '6':
    case '7':
    case '8':
    case '9':    goto yy5;
    default:    goto yy2;
    }
yy2:
    ++YYCURSOR;
yy3:
#line 14 "integer.l"
    { return ERR; }
#line 32 "integer.c"
yy4:
    yych = *(YYMARKER = ++YYCURSOR);
    switch (yych) {
    case 0x00:    goto yy6;
    case '0':
    case '1':
    case '2':
    case '3':
    case '4':
    case '5':
    case '6':
    case '7':    goto yy8;
    case 'B':
    case 'b':    goto yy11;
    case 'X':
    case 'x':    goto yy12;
    default:    goto yy3;
    }
yy5:
    yych = *(YYMARKER = ++YYCURSOR);
    switch (yych) {
    case 0x00:    goto yy13;
    case '0':
    case '1':
    case '2':
    case '3':
    case '4':
    case '5':
    case '6':
    case '7':
    case '8':
    case '9':    goto yy15;
    default:    goto yy3;
    }
yy6:
    ++YYCURSOR;
#line 16 "integer.l"
    { return OCT; }
#line 71 "integer.c"
yy8:
    yych = *++YYCURSOR;
    switch (yych) {
    case 0x00:    goto yy6;
    case '0':
    case '1':
    case '2':
    case '3':
    case '4':
    case '5':
    case '6':
    case '7':    goto yy8;
    default:    goto yy10;
    }
yy10:
    YYCURSOR = YYMARKER;
    goto yy3;
yy11:
    yych = *++YYCURSOR;
    if (yych <= 0x00) goto yy10;
    goto yy18;
yy12:
    yych = *++YYCURSOR;
    if (yych <= 0x00) goto yy10;
    goto yy20;
yy13:
    ++YYCURSOR;
#line 17 "integer.l"
    { return DEC; }
#line 101 "integer.c"
yy15:
    yych = *++YYCURSOR;
    switch (yych) {
    case 0x00:    goto yy13;
    case '0':
    case '1':
    case '2':
    case '3':
    case '4':
    case '5':
    case '6':
    case '7':
    case '8':
    case '9':    goto yy15;
    default:    goto yy10;
    }
yy17:
    yych = *++YYCURSOR;
yy18:
    switch (yych) {
    case 0x00:    goto yy21;
    case '0':
    case '1':    goto yy17;
    default:    goto yy10;
    }
yy19:
    yych = *++YYCURSOR;
yy20:
    switch (yych) {
    case 0x00:    goto yy23;
    case '0':
    case '1':
    case '2':
    case '3':
    case '4':
    case '5':
    case '6':
    case '7':
    case '8':
    case '9':
    case 'A':
    case 'B':
    case 'C':
    case 'D':
    case 'E':
    case 'F':
    case 'a':
    case 'b':
    case 'c':
    case 'd':
    case 'e':
    case 'f':    goto yy19;
    default:    goto yy10;
    }
yy21:
    ++YYCURSOR;
#line 15 "integer.l"
    { return BIN; }
#line 160 "integer.c"
yy23:
    ++YYCURSOR;
#line 18 "integer.l"
    { return HEX; }
#line 165 "integer.c"
}
#line 19 "integer.l"

}
int main(int argc, char **argv)
{
    for (int i = 1; i < argc; ++i) {
        switch(lex(argv[i])) {
            case ERR: printf("error\n");break;
            case BIN: printf("binary\n");break;
            case OCT: printf("octal\n");break;
            case DEC: printf("decimal\n");break;
            case HEX: printf("hexadecimal\n");break;
        }
    }
    return 0;
}

這段程式碼其實就是實現了上邊說的確定有窮自動機。程式碼很簡單,你可以拿示例中的0b10作為這段程式碼的輸入,看一下通過這個是否能得到它是binary

所以,我們其實只需提供一些正規表示式,re2c就可以幫我們實現一個確定有窮自動機(DFA)。這樣就可以輕鬆的做一些詞法解析的事情,我們就提供正規表示式即可

lex

lex這個詞法分析器生成工具在《編譯原理》這本書的第三章有介紹,我這裡僅簡單的介紹一下,更詳細的內容可以去對應章節看

lex跟re2c原理其實是一樣的,按照lex的規則編寫它的原始碼(也是以.l結尾的),然後將其生成.c檔案,生成的.c檔案其實就是將.l檔案中編寫的正則匹配轉換成有窮狀態機的C語言實現

我這裡參考王晶大佬的《面向信仰程式設計》中編譯原理相關部落格中的一段lex程式碼,它通過這段程式碼可以對簡單的go原始檔進行詞法解析

%{
#include <stdio.h>
%}

%%
package      printf("PACKAGE ");
import       printf("IMPORT ");
\.           printf("DOT ");
\{           printf("LBRACE ");
\}           printf("RBRACE ");
\(           printf("LPAREN ");
\)           printf("RPAREN ");
\"           printf("QUOTE ");
\n           printf("\n");
[0-9]+       printf("NUMBER ");
[a-zA-Z_]+   printf("IDENT ");
%%

這裡邊其實就是定義了一些關鍵字的正則,去匹配go原始檔中的關鍵字、數字、識別符號等。同樣通過lex命令將上邊這個.l檔案編譯成.c檔案,.c檔案其實就是實現了一個有窮的狀態機(根據.l檔案中提供的正則),能夠匹配.l中定義的一些符號

假設有這麼一段go的程式碼

package main

import (
    "fmt"
)

func main() {
    fmt.Println("Learn Compile")
}

經過詞法分析器處理以後就會變成這個樣子

PACKAGE  IDENT

IMPORT  LPAREN
    QUOTE IDENT QUOTE
RPAREN

IDENT  IDENT LPAREN RPAREN  LBRACE
    IDENT DOT IDENT LPAREN QUOTE IDENT IDENT QUOTE RPAREN
RBRACE

有了以上這些基礎的東西,後邊看Go的詞法分析原始碼的時候就輕鬆許多。因為Go語言的詞法分析和語法分析其實是連在一起的,所以這裡順便也整理一下語法分析,需要了解哪些基礎的東西能否幫助我們去看Go的語法分析部分的原始碼

語法分析基礎

文法

在設計語言時,每種程式設計語言都有一組精確的規則來描述程式的語法結構。比如在C語言中,一個程式由多個函式組成,一個函式由宣告和語句生成,一個語句由表示式組成等等。程式設計語言構造的語法可以使用上下文無關文法BNF(巴科斯正規化)表示法來描

  1. 文法給出了一個程式設計語言的精確易懂的語法規約
  2. 對於某些型別的文法,我們可以自動地構造出高效的語法分析器,它能夠確定一個源程式的語法結構
    3.一個正確設計的文法給出了一個語言的結構。該結構有助於把源程式翻譯為正確的目標代,也有助於檢測錯誤

這裡簡單的再描述一下語法分析器在編譯過程中扮演的角色和作用(在上一篇文章中有詳細描述),方便後邊的理解

語法分析器從詞法分析器獲得一個由詞法單元組成的串,並驗證這個串可以由源語言的文法生成。對於良構的程式,語法分析器構造出一棵語法分析樹,並把它傳遞給編譯器的其他部分進一步處理。實際上,並不需要顯式地構造岀一棵語法分析樹,因為,對源程式的檢查和翻譯動作可以和語法分析過程交替完成。因此語法分析器和前端的其他部分可以用一個模組來實現

處理文法的語法分析器大體上可以分為三種型別:通用的自頂向下的自底向上的。像Cocke-Younger-Kasami演算法和Earley演算法這樣的通用語法分析方法可以對任意文法進行語法分析。但是些通用方法效率很低,不能用於編譯器產品

下邊詳細的介紹一下上下文無關文法以及兩種處理文法的型別

上下文無關的文法

下邊的描述可能很抽象,沒關係,看下邊的示例就明白了(我們要理解,越通用的東西往往就越抽象)

一個上下文無關文法(簡稱文法)由終結符號非終結符號、一個開始符號一組產生式組成

  1. 終結符號是組成串的基本符號。通常我們所說的詞法單元,你就可以把它理解成是終結符號,比如if、for、)、(等,都是終結符號
  2. 非終結符號是表示串的集合的語法變數。非終結號表示的串集合用於定義由文法生成的語言。非終結符號給出了語言的層次結構,而這種層次結構是語法分析和翻譯的關鍵
  3. 在一個文法中,某個非終結符號被指定為開始符號。這個個符號表示的串集合就是這個文法生成的語言。按照慣例,先列出開始符號的產生式
  4. 一個文法的產生式描述了將終結符號和非終結符號組合成串的方法。每個產生式由下列元素組成:
    a. 一個被稱為產生式頭或左部的非終結符號。這個產生式定義了這個頭所代表的串集合的一部分
    b. 方向向右的箭頭
    c.一個由零個或多個終結符號與非終結符號組成的產生式體或右部。產生式體中的成分描述了產生式頭上的非終結符號所對應的串的某種構造方法

舉個比較簡單的示例,假設定義了下邊這樣的文法(下邊的|是或的意思)

S -> ABC
A -> c|B
B -> a|r
C -> n|y

在上邊定義的文法中,A、B、C是非終結符號,c、a、r、n、y是終結符號。上邊的每一行都是一個產生式,A也是開始符號。上邊這一組產生式再加上非終結符、終結符、開始符號,就組成了一個上下文無關文法。上邊個文法就可以匹配can、arn、aan、cry、ray等等

因為了解上邊這些是為了後邊看Go語法分析的實現,我這裡就從Go的語法解析器中拿到了它的文法,如果你明白了上邊提到的文法相關內容,就可以輕鬆看懂Go的語法解析用到的文法(Go的語法分析原始碼在:src/cmd/compile/internal/syntax/parser.go)

SourceFile = PackageClause ";" { ImportDecl ";" } { TopLevelDecl ";" } .
PackageClause  = "package" PackageName .
PackageName    = identifier .

ImportDecl       = "import" ( ImportSpec | "(" { ImportSpec ";" } ")" ) .
ImportSpec       = [ "." | PackageName ] ImportPath .
ImportPath       = string_lit .

TopLevelDecl  = Declaration | FunctionDecl | MethodDecl .
Declaration   = ConstDecl | TypeDecl | VarDecl .

ConstDecl = "const" ( ConstSpec | "(" { ConstSpec ";" } ")" ) .
ConstSpec = IdentifierList [ [ Type ] "=" ExpressionList ] .

TypeDecl  = "type" ( TypeSpec | "(" { TypeSpec ";" } ")" ) .
TypeSpec  = AliasDecl | TypeDef .
AliasDecl = identifier "=" Type .
TypeDef   = identifier Type .

VarDecl = "var" ( VarSpec | "(" { VarSpec ";" } ")" ) .
VarSpec = IdentifierList ( Type [ "=" ExpressionList ] | "=" ExpressionList ) .

因為每一個Go的原始檔最終都會被解析成一個抽象語法樹,語法樹的最頂層的結構或開始符號,都是SourceFile。SourceFile這個產生式含義也很簡單

  1. 首先是有一個(package)的定義
  2. 然後是可選的匯入宣告(import) 。大括號是可選的意思,關於Go更多的文法,你可以去這裡瞭解,非常詳細,對Go中的每一種型別,都有詳細的介紹
  3. 最後就是一個可選的頂層宣告(TopLevelDecl)

上邊並沒有列出Go全部的產生式,比如函式方法的產生式這裡沒有列,它會更復雜一些。這裡以PackageClause這個產生式為例來說一下它的含義

PackageClause  = "package" PackageName . 
PackageName    = identifier .

它的意思就是,滿足一個包宣告的,它正確的語法結構應該是以package開頭,然後package後邊跟一個識別符號(identifier)

知道了一門計算機語言的語法分析是通過定義好的文法來解析原始檔中的語法的,那語法解析器是如何實現根據文法來進行語法解析的呢?這就需要用到一些演算法,就是在上邊文法部分提到的那三種,通用的自頂向下的自底向上的

抽象語法樹

其實在語法分析階段,不僅需要判斷給定的字串是不是滿足規定的文法,還需要分析出這些字串

符合哪些結構,也就是說,分析出這個字串是怎麼通過起始符開始,通過產生式匹配出來的,並根

據這個產生的過程生成語法樹

假設有這樣一個文法

語句 -> 主 謂 賓
主語 -> 你
主語 -> 我
主語 -> 他
謂語 -> 敲
謂語 -> 寫
賓語 -> 程式碼
賓語 -> bug

比如"我寫bug"這句話,當我們知道了主謂賓分別是哪個詞,才能理解這句話的含義。下邊是它的語法樹

再比如下邊這個能匹配表示式的文法

Expr -> Expr op Expr | (Expr) | number
op   -> + - * /

假設有這樣一個表示式 6 + ( 60 - 6 )。按照上邊的文法,我們可以得到如下的推倒過程

Expr -> Expr + Expr
Expr -> number + Expr
Expr -> number + ( Expr - Expr )
Expr -> number + ( number - number )
Expr -> 6 + ( 60 - 6 )

用語法分析樹來表示就是:

去掉裡邊的一些多餘的節點,進一步的濃縮,就可以得到如下抽象語法樹。對於這種樹狀結構,可以採用遞迴的方式加以分析,從而生成目的碼

看一個不一樣的,假設有這麼一個表示式:6+20*3,還是通過上邊的文法對它進行解析,你會發現它可以由兩種不同的方式推匯出來

這個其實是語法中的歧義,就是指對於一個符合語法結構的字串,它可以由兩種不同的方式被推匯出來。相反,如果有一個文法,任何一個字串的推導方式都是唯一的,那這個文法就是沒有歧義的。那很顯然我們的程式語言使用的文法,必須是沒有歧義的

要想消除歧義,我們可以通過改寫文法來實現,將有歧義的產生式改寫成無歧義的。但是,這種改寫不僅非常困難,而且往往改寫後的產生式和原產生式相差很大,可讀性也比較差

而另一種方式就是通過優先順序來實現,不需要改寫產生式,當推導過程中出現歧義的時候,利用符號的優先順序來選擇需要的推導方式,程式語言中基本都是用的這種方式,我們後邊要了解的Go的語法分析部分,也是用的這種方式,這裡不做更加詳細的介紹,感興趣的可以看一下Go的語法解析這部分(位置:src/cmd/compile/internal/syntax/parser.go → func fileOrNil)

對於一個給定的文法 ,從中推匯出一個任意的字串是比較簡單和直接的,但需要推匯出指定的字串或者給定一個字串,要分析出它是怎麼從起始符號開始推導得到的,就沒那麼簡單了,這就是語法分析的任務。分析可以採用 自頂向下分析自底向上分析 兩種方法,因為這兩種分析方法涉及的內容挺多的,我這裡先簡單的提一些對我們後邊研究Go語法分析原始碼有幫助的部分。關於自頂向下分析 和 自底向上分析更詳細的內容,可以看《編譯原理》的第三章

語法分析方法

通用的語法分析方法

Cocke-Younger-Kasami演算法和Earley演算法這樣的通用語法分析方法可以對任意文法進行語法分析。但是些通用方法效率很低,不能用於編譯器產品

這裡不詳細介紹這兩種通用的語法分析方法,感性趣的可自行點選瞭解

自頂向下的語法分析方法

自頂向下分析就是從起始符號開始,不斷的挑選出合適的產生式,將一箇中間字串中的非終結符展開,最終展開到給定的字串

以下邊這個文法為例,來分析code這個字串

S –> AB
A –> oA | cA | ε
B –> dB | e

說明:ε表示空字串
  1. 開始時,起始符號只有一個產生式:S –> AB,所以只能選擇這一個產生式,用這個產生式的右邊部分代替S,就得到一箇中間字串AB
中間字串產生式
SS → AB
AB
  1. 對於這個中間字串,從它的起始符號A開始展開,A展開可以得到三個產生式:A → oA; A→ cA; A→ ε。我們可以拿我們要分析的字串code來和這個中間字串AB對比一下,發現只能選擇A → cA這個產生式,否則無法根據這個中間字串推匯出code。所以這裡選擇將中間句子中的A,替換成cA,就得到了中間句子cAB
中間字串產生式
SS → AB
ABA → cA
cAB
  1. 然後繼續嘗試將cAB中的A展開。發現只能選擇A → oA這個產生式
中間字串產生式
SS → AB
ABA → cA
cABA → oA
coAB
  1. 繼續對A進行展開,發現A只能選擇A → ε這個產生式(否則是推導不出來code的)
中間字串產生式
SS → AB
ABA → cA
cABA → oA
coABA → ε
coB
  1. 然後就是展開非終結符B,B展開可以得到兩個產生式:B → dB; B → e。按照上邊相同的方法,可以得到:
中間字串產生式
SS → AB
ABA → cA
cABA → oA
coABA → ε
coBB → d
codBB → e
code完成

以上就是自頂向下的語法分析過程,你現在再回過頭去看 上下文無關文法 部分提到的Go語言中的文法,是不是就明白了Go的語法分析過程。如果你去看Go的語法分析原始碼(位置:src/cmd/compile/internal/syntax/parser.go → func fileOrNil),你會發現Go用的就是這種自頂向下的語法分析方法

自底向上的語法分析方法

自底向上分析的分析方法,是從給定的字串開始,然後選擇合適的產生式,將中間字串中的子串摺疊為非終結符,最終摺疊到起始符號

假設有如下文法,我們要分析的字串是: aaab

S –> AB
A –> Aa | ε
B –> b | bB
  1. 首先從左邊第一個字元 a 開始,對比語法中的所有產生式,發現沒有產生式的右邊可以完全匹配。但經過觀察和思考發現:可以嘗試在 aaab 的最前面插入一個空句子 ε ,接下來可以應用 A -> ε ,之後再應用 A -> Aa, ... 。因此先插入空句子,得到中間句子 εaaab
中間字串產生式
aaab插入 ε
εaaab
  1. 此時,中間句子的最左邊 ε 可以和產生式 A -> ε 匹配。用這個產生式,將 ε 摺疊為 A ,得到 Aaaab
中間字串產生式
aaab插入 ε
εaaabA → ε
Aaaab
  1. 由中間字串Aaaab可以發現,它的最前面的 Aa 可以和 A -> Aa 匹配上,且只能和這個產生式匹配上,因此應用此產生式,將 Aa 摺疊為 A ,得到 Aaab
中間字串產生式
aaab插入 ε
εaaabA → ε
AaaabA → Aa
Aaab
  1. 按照上邊相同的步驟,將中間字元的子串摺疊為非終結符,最終摺疊到起始符號 S ,過程如下:
中間字串產生式
aaab插入 ε
εaaabA → ε
AaaabA → Aa
AaabA → Aa
AabA → Aa
AbB → b
ABS → AB
S完成

以上就是自底向上的語法分析大致過程,這裡主要是為了方便理解,沒有涉及到更多的東西。但是已經夠後邊看Go語法分析原始碼使用了

語法分析過程中的回溯和歧義

在前文中舉的例子,可能比較特別,因為在推導的過程中,每一步都只有唯一的一個產生式滿足條件。但是在實際的分析過程中,我們可能會遇到如下兩種情況:

  1. 所有產生式都不可應用
  2. 有多個產生式可以應用

如果出現第二種情況,往往我們需要採用回溯來進行處理。先嚐試選擇第一個滿足條件的產生式,如果能推導到目標字串,則表示該產生式是可用的,如果在推導過程中遇到了第一種情況,則回溯到剛才那個地方,選擇另一個滿足條件的產生式

如果嘗試完所有的產生式之後,都遇到了第一種情況,則表示該字串不滿足語法要求。如果有多條產生式都能推匯出目標字串,則說明語法有歧義

回溯分析一般都非常慢,因此一般通過精心構造語法來避免回溯

語法分析器生成工具

常見的語法分析器生成工具有Yaccbison,它的原理其實跟上邊介紹的詞法分析器差不多。就是按照對應語法分析器工具的語法編寫原始檔(檔案字尾是.y),(其實就是在原始檔程式碼裡邊提供一些文法,詞法分析器工具,也是我們提供了正則)然後通過使用對應工具的命令將.y檔案生成成.c檔案

生成的這個.c檔案裡邊,其實就根據我們提供的文法規則生成一個語法分析器的程式碼,我們只需要編譯執行這個.c檔案即可

因為本篇文章已經很長了,這裡就不舉例了,關於這兩個語法分析器生成工具,你可以點選我上邊的連結,進入官網下載安裝,自己嘗試一下

過程是完全和上邊介紹的詞法分析器工具一樣的

總結

本文主要是分享了詞法分析中的不確定有窮自動機和確定有窮自動機,並且展示了詞法分析中常用的詞法分析器是如何使用的。然後是語法分析中涉及到的文法、抽象語法樹的生成、以及語法解析的兩種解析方式

感謝閱讀,希望看完能有所收穫

相關文章