1. 程式人生 > >【編譯原理龍書筆記】(三)詞法分析(附聯絡答案)(仍未完成)

【編譯原理龍書筆記】(三)詞法分析(附聯絡答案)(仍未完成)

這篇部落格是根據自己學習龍書的過程編寫,因為博主習慣了英語環境,在強行從英語轉化為中文的時候難免會有些不自然,請大家諒解。

感謝沉魚姐姐,很多答案都是參考了她的github,雖然無緣認識,但也算是一位領路人。

3.1 詞法分析器的作用

詞法分析是編譯的第一階段。

這裡寫圖片描述

詞法分析器讀取了源程式,將其打碎成一個個的token之後傳入語法分析器。

詞法分析器的任務:

  • 讀取源程式,過濾掉源程式的註釋和空白。
  • 將編譯器生成的錯誤資訊與源程式的位置聯絡起來。
  • 巨集的擴充套件
  • 生成詞法單元

3.1.1 詞法分析及語法分析

我們將詞法分析和語法分析分離開並不是毫無根據的。至少有以下幾個好處:

  1. 簡化編譯器的設計。如果在語法分析階段,仍舊要考慮什麼過濾註釋,過濾空白之類的鬼東西,設計起來簡直可以殺了程式設計師。於是我們選擇將兩部分分開。這種思想在軟體工程的設計中十分常見,包括在計算機網路的層結構中也可以看到。
  2. 提高編譯器的效率。
  3. 增強編譯器的可移植性。有的時候輸入的字元會跟裝置有關,這樣的情況下,我們只需要改一改其中一小部分的詞法分析,就可以得到適應機器的結果,而非要修改整個詞法分析+語法分析。

3.1.2 詞法單元,模式,詞素

這三者讀起來很相似然而概念上卻是完全不同的東西。

  • 詞法單元是由一個詞法單元名和一個(可選的)屬性值組成。詞法單元名是一個表示某種詞法單位的抽象符號。
  • 模式描述了一個詞法單元的詞素可能具有的形式。當詞法單元是一個關鍵字時,它的模式就是組成這個關鍵字的字元序列。對於識別符號和其他詞法單元,模式就是一個更加複雜的結構,可以和很多字串匹配。
  • 詞素是源程式的一個字元序列,和某個詞法單元的模式匹配,會被詞法分析器識別為某個詞法單元的一個例項。

在絕大多數的程式設計語言中,詞法單元由五大部分組成:

  1. 關鍵字。關鍵字的模式就是關鍵字本身
  2. 運算子。這些運算子既可以是單個的運算子,也可以是代指一類運算子(比如比較運算子)
  3. 表示所有識別符號的詞法單元。初學者可以把其理解成儲存各種變數名的地方。
  4. 一個或多個表示常量的詞法單元,儲存了數字和literal字串
  5. 標點符號。左右括號,逗號分號等等。

3.1.3 詞法單元的屬性

從詞法單元的定義來看,就可以看出一個詞法單元是可以對應到多個詞素的。那麼區分這些詞素的至關重要的一部分就是給編譯器提供詞素的額外資訊來描述各種不同的詞素。

最典型的例子就是在identifier這個詞法單元中,其對應的詞素包括所有的變數名,那麼如何區分這些變數名便成了一個主要的任務。我們通常採用其型別,第一次出現的位置等等去描述它,並把它儲存在字元表中。

詞法分析的一個大問題就是我們無法在只看一個字串的時候決定它是對是錯,著名的fortran例子告訴我們,有的時候,我們需要看整個statement才能發現這個statement的意思是什麼。

3.1.4 詞法錯誤

3.2 輸入緩衝

之前提到過,在大部分程式中,我們都需要有 look ahead 情景的出現,這就給讀入過程增加了複雜性。“我們一次到底該讀多少程式碼呢”,這一節我們就會介紹這些問題。

3.2.1 緩衝區對

在編譯一個程式的時候,我們往往需要進行大量的字串讀入。前人做了比較多的優化,其中一項就是採用來個交替讀入的緩衝區。每個緩衝區大概能有4096的位元組,如果不是瘋狂搞破壞的話讀一句話肯定夠了(不夠的情況後文也有解釋)。

讀入程式中維護了兩個指標:分別是

  1. lexemeBegin 指標,顧名思義,就是當前詞素的開始處。
  2. forward 指標,就是試圖判斷詞素的結尾是什麼。這個很複雜我們會在接下來的章節中詳細介紹。

可以想象,一旦確定了當前詞素的位置,那我們就把forward的位置+1之後賦值給lexemeBegin,然後繼續上述的過程。

但是簡單地做上面的工作會有一個小小的問題,就是如果恰好一個詞素被分開了怎麼辦,這就涉及到了哨兵標記。

這裡寫圖片描述

3.2.2 哨兵標記

主要就是說,當我們移動forward指標的時候,實際上我們同時做了兩件事情,第一件事情是判斷是否已經能夠完成詞素的匹配。並且要同時檢查我們是否到了緩衝區的結尾(如果到了結尾自然要選擇是不是要重新裝載緩衝區,是不是要大幅度移動forward指標),這個問題被 eof 很好的解決了。我們在這裡描述一種處理這個問題的演算法,這個演算法十分清晰,讓人一目瞭然。

switch (*forward++) {
    case eof:
        if (forward = buff1.end()) {
            load buff2;
            forward = buff2.begin();
        }
        else if (forward = buff2.end()) {
            load buff1;
            forward = buff1.begin();
        }
        else terminate lexical analysis; // This is end of whole code
    case otherWords:
}

在現代程式語言中,詞素的長度往往並沒有那麼長,然而如果你硬要問我有沒有無敵長的字串,其實還是有的。在這種情況下,我們會採用一些演算法,將其視為多個不是很長的字串的加和,之後的處理中再把他們加和起來。

一種更加嚴重的情況就是當我們需要往前看很多很多字元才能決定詞素的情況。在曾經的PL/I語言中,關鍵字並不是保留字。曾經出現過 DECLARE (ARG1, ARG2, … , ARGN) 此般凶殘的殺人法。我們無法判斷 DECLARE到底是一個關鍵字,還是一個數組的名字,在當時,只能做兩個分支,然後由語法分析器來解決這個問題,不過現在的絕大多數程式語言都將關鍵字保留,以避免這類愚蠢的問題。

3.3 詞法單元的規約

書中提到,正則表示式是一種用來描述詞素模式的重要方法,雖然我並不能懂這句話的意思,但是讓我們先走入正則表示式的世界,去一窺究竟。

3.3.1 串和語言

這一節中給出了我們所需要語言的一些定義。首先,字母表(alphabet)是一個有限的符號集合,我們後面所定義的語言,表示式等等東西都要依靠於這個字母表。

某個字母表上的一個字串(string)是該字母表中符號的又窮序列。注意這個串可以是空的,我們用 ϵ 來表示。

串的字首就是從其尾部刪除一些符號得到的串,對應來說字尾就是從其頭部刪除一些符號得到的串。之後串的子串是刪除某個字首加上刪除某個字尾後得到的串。

因為字首和字尾其實可能是串本身,所以我們規定串的真字首和真字尾,即是非本身的前後綴串。

3.3.2 語言上的運算

這一節又給出了一些繁瑣的集合論的定義。大意就是給出字母表集合的時候,其上的並(union),連線(concatenation),閉包的定義。因為跟代數中的形式過於相近,這裡就先不給出其表格形式了。

3.3.3 正則表示式

正則表示式實際上是用於描述一套字串集合所定義的一套語法。我們用特定的字串加上一些符號去描述我們想描述的一些具有某些性質的字串。

那麼我們為什麼需要正則表示式呢,大概是因為在描述一些數量龐大的字串集合的時候,應用正則表示式的概念,會讓我們的描述更加清晰。

同時,我們不會想要每次都強行的寫出所有正則表示式的字母表示形式,因此我們開發了遞迴這種東西。下面給出正則表示式遞迴式的明確定義。

歸納基礎:

1) ϵ 是一個正則表示式,其所對應的語言 L(ϵ)={ϵ},是一個只有空字元的語言。
2) 如果a上的一個符號,那麼a也是一個正則表示式,L(a)={a}。就是說只有一個字元的語言。

歸納部分:

1) (r) | (s) 是一個正則表示式,其所對應的語言為 L(r)L(s)
2) (r) (s) 是一個正則表示式,其所對應的語言為 L(r)L(s)
3) (r)* 是一個正則表示式,其所對應的語言為 (L(r))
4) (r) 是一個正則表示式,只是說明在表示式左右加個括號是沒有影響的。】

3.3.4 正則定義

其實關於正則定義的應用,我們在第二章的時候應該已經看過一點了。正則定義出現的意義主要是因為為了簡化定義式的表達。舉個例子好了,當我們要用正則表示式定義C語言中所有可能出現的識別符號的名字的時候:

letter_A|B|Z|a|b||z|_digit0|1||9idletter_(letter_|digit)

上述的id定義中我們用到了之前所定義的letter_digit,這就是正則定義的簡化之處。

如果形式化地定義以上的結果,我們對正則定義有如下的寫法:

d1r1d2r2dnrn

其中的ri 就不只用了字元表中的定義,還有之前 di1的定義。我們管這樣的定義序列叫做正則定義。

3.3.5 正則表示式的擴充套件

OK到了這裡正則表示式的東西基本都介紹完了,但是程式設計師們是一些不會滿足的人。因此大家又定義了一些其他的符號,我們一起來看一下:

  1. +,表示一個或多個例項。
  2. ? ,表示零個或一個例項。
  3. 字元類,當我們想表示 a|b||z的時候,我們可以簡單的用[a-z]去表達。這樣簡化了很多我們所需要的工作。

3.4 詞法單元的識別

上一節中,我們介紹了關於正則表示式的一些東西,然而如果不配合上一些應用的話,會給人一種“這並沒有什麼鳥用的感覺“。那在接下來的章節中,我們就即將說明,這正則表示式還是有些鳥用的。

好了,那假如我們現在心血來潮想要搞出一門程式語言,也許就叫 C減減。然後其control flow是如下圖所示的。if then都是保留字,relop是比較符號。

這裡寫圖片描述

那麼我們就可以設定對應的正則表示式語言為下圖

這裡寫圖片描述

還記得嗎,詞法分析器還有一個職責就是過濾所有的空白符號,包括空格,tab符號和回車鍵。那麼我們還要設計關於空白符號的正則表示式。

這裡寫圖片描述

我們所想要的目標如下圖所示,當我們檢測到id或者number的話,我們就會去符號表中去找所對應的entry。而當我們遇到關係運算符的時候,會直接設定所對應詞素的屬性。

這裡寫圖片描述

那我們要如何進行識別呢,這就是我們下面要介紹的狀態轉換圖了。

3.4.1 狀態轉換圖

首先介紹一下基本概念,狀態轉換圖是我們構建詞法分析器的一箇中間步驟。我們想要的是根據語法的正則表示式來構建一個模式轉換圖。圖中每一個節點代表我們詞法分析的一箇中間狀態,隨著讀入輸入的字串而不停變化,一般來說,從讀入第一個字串開始,在遇到whitespace之後停止。

有一些關於狀態轉換的約定,都是很直觀的東西,在這裡列出來權當備註。

1) 接受狀態或最終狀態:這些狀態表明我們又找到了一個詞素,在狀態轉換圖中經常用雙層的圈來表示。
2) 如果需要將forward指標往前退一個位置,那我們就在途中的節點旁邊加上一個星號。
3) 必須要有一個狀態被指定為開始狀態。一般這個狀態都是在剛讀完一個詞素,要開始讀下一個詞素的時候。

下面是一個例子,是我們讀入關係運算符的時候,所做的狀態轉換。

這裡寫圖片描述

這張圖很好懂,無非就是根據讀了什麼走不同的狀態,這裡也不多贅述。

3.4.2 保留字和識別符號的識別

這是一個關鍵的問題,試想一下,當你有一個變數名字叫做thennext的時候,這時候我們的程式一個一個字元的往後讀,讀到then的時候詞法分析器就會覺得日了狗了,不知道是關鍵字還是隻是一個變數名。

我們解決這個問題的最好方法在之後有介紹,就是把所有狀態轉換圖併到一個,這樣編譯器就能用很多switch語句分別處理而不用糾結,但是這樣當然會帶來的問題就是實現的複雜會成倍地提高,不過仍然是值得的。我們之後會採取那種方法,所以現在書中介紹的治標不治本的方法這裡也不介紹了。

3.4.3 完成我們的例子

我們沒有做的地方還有讀入數位的例子,那個圖雖然長,然而並沒有什麼意義,其實就是一些非常暴力的實現。

3.4.4 基於狀態轉換圖的詞法分析器的體系結構

無論我們要採用什麼奇技淫巧,如果我們的詞法分析器是基於狀態轉換圖而建立的,我們永遠都可以把圖中的一個結點當做是一個狀態,然後用switch語句去轉換狀態(對應轉換圖中的每一條邊)。多說無用,直接看我們的程式碼吧,看了就都明白了。

TOKEN getRelop()
{
    TOKEN retToken = new(RELOP);
    while (1) {
        switch(state) {
            case 0: c = nextChar();
                    if (c == '<') state = 1;
                    else if (c == '=') state = 5;
                    else if (c == '>') state = 6;
                    else fail();
                    break;
            case 1: ...
            ...
            case 8: retract();
                    retToken.attribute = GT;
                    return(retToken);
        }
    }
}

上圖中的retract的含義是因為我們讀取了多一個字元,然後需要把指標向前移動一位,所以才要考 retract 函式來完成。

照例自我介紹: