引言:
編譯語言設計的精髓在於自動化過程,即如果要設計一門編程語言,那么一定要設計一個自動化系統,能夠自行讀入分析程序員寫入的程序,將其翻譯為機器能夠識別的指令等信息。當然高級語言的編譯不是一蹴而就的,而是通過若干步的分解、規約、轉換、優化,最后得到目標程序。
具體的編譯步驟如下:
源程序就是我們寫入的高級語言,編譯的第一步叫做“詞法分析”。詞法分析的本質,就是要拆解出語句的每一個單詞,然后對這個單詞的類型進行辨識。
首先拿中文來舉例。比如有一句話是“我喜歡你”,那么首先我們要把這句話拆成“我”、“喜歡”、“你”,然后再逐個分析他們的類型,得到“我”->主語;“喜歡”->謂語;“你”->賓語。這樣我們就把這句話每個單詞都分析出來了,也就完成了中文的“詞法分析”。
那么回到編程語言,它的詞法分析就是將字符序列轉換為單詞(Token)序列的過程。翻譯成俗話,就是把我們寫的大片語言文本分解為一個一個單詞,再輸出每個單詞的類型。舉一個例子:
int p = 3 + a;
這個語句非常簡單,即定義一個變量p,它的初值為變量a與3的加和。那么接下來我們要對這個語句進行詞法分析,首先我們要把這段文本拆解成單詞,拆出來就是'int'、'p'、'='、'3'、'+'、'a'、';'。對這些單詞再進行類型的辨識,那么就得到以下結果:
語素 | 語言類型 |
int | 關鍵字 |
p | 標識符 |
= | 運算符 |
3 | 數字 |
+ | 運算符 |
a | 標識符 |
這樣我們就把這段文本中的每個單詞的類型都分析出來了。乍一看非常簡單對不對,對於人類而言你只需要用肉眼就可以輕松觀察出來每個單詞的類型,但對於計算機而言,它可沒有人類那樣的智能。如果想要計算機能夠識別並分析語素的類型,那就需要我們人類來為它構造一個自動化輸入和分析的系統。
構造自動系統的步驟主要分為如下幾步:
①編寫正則表達式(RE)
②將正則表達式轉換為非確定有限自動機(NFA)
③將非確定有限自動機轉換為確定有限自動機(DFA)
④將確定有限自動機最小化、規范化
⑤利用確定有限自動機編程
那么接下來就介紹一下上述提到的這幾個系統。
正則表達式:
正則表達式的英文名稱是Regular Expression,簡稱RE。我們先來看一下定義:正則表達式是對字符串操作的一種邏輯公式,就是用事先定義好的一些特定字符、及這些特定字符的組合,組成一個“規則字符串”,這個“規則字符串”用來表達對字符串的一種過濾邏輯。
用俗話來解釋,就是正則表達式可以指定一種字符串的規則,只有滿足相應規則的字符串才能與表達式相匹配。那么接下來介紹幾種最簡單的RE:
① a|b -> 只有一個字符且非a即b
② ab -> 字符串必須是ab連接
上述兩個非常基礎,也很好理解。舉個例子,單個數字的正則表達式就是0|1|2|3|4|5|6|7|8|9,即要想匹配“單個數字”這個規則的內容,必須是一個數字且是0~9中的一個;兩位數字的正則表達式就是10|11|12|...|99,不多贅述。接下來會有稍微復雜的表達式:
③(a|b)* -> 有任意個(a|b)連接,例如abaaabbabbba...
④(a|b)+ -> 有非零個(a|b)連接
⑤(a|b)? -> 有零到一個(a|b),相當於只有單個a 或 單個b 或ε(空串)可以匹配
⑥[^ab] -> 匹配非a非b的字符
⑦^ab -> 匹配以ab開頭的字符串
...
其實還有很多種正則表達類型,但是文法分析用不到那么復雜的,因此就沒再列了。對上述規則熟悉后,我們便可以用正則表達式來表達一些我們想要匹配的字符串類型。例如我們想匹配規范的偶數,那么我們就可以這樣設計正則表達式:
(1|2|3|4|5|6|7|8|9)?(0|1|2|3|4|5|6|7|8|9)*(0|2|4|6|8)
即首位不能是零,中間位可以是任意個數的任意數字,末位必須是偶數的數字。
再舉一個:以a開頭和結尾的小寫字母串,那么正則表達式就是:
a((a-z)*a)?
即確定a為開頭,后面內容可有可無,如果后面有內容,那么必須強行a結尾。這里要提示的是,像上述的正則表達式我們都是根據題意下意識直接構造的,它並不規范,具有很強的不確定性。規范確定的正則表達式也叫正規表達式,之后會介紹這部分內容,這里只是做個提示。
非確定有限自動機:
上文我們使用正則表達式把要匹配的文本模式表示了出來,但是RE也並非計算機能夠直接識別的內容,因為計算機對於*、+這些符號的反應機制很難構造。這里我們要引入一個新東西:自動機(Automata)。自動機這個東西其實很好理解,如下圖:
自動機共由5部分組成,分別是狀態集合S、輸入字符Σ、狀態轉移函數f、初態S0、終止態Z,即狀態自動機M=(S,Σ,f,S0,Z)。對於上圖而言:
S={休息,Coding,加班Coding,卒}
Σ={上班,下班,需求完成,產品經理腦洞大開,過勞}
S0=休息
Z={卒} ps:終態可以不唯一
f是一系列映射的集合,映射就是某狀態獲得某輸入后轉移到某新狀態的意思。
在這個自動機中,最開始是休息狀態,獲得上班的輸入以后就會轉移到Coding的狀態,以此類推,當狀態變為卒時,便可以終止該自動機的運行。
如果一個自動機的狀態是有限的,那么我們稱其為有限狀態機(Finite Automata,簡稱FA)。但是存在這么一種狀態機,它存在下述兩種情況:
①同一個狀態獲得同一個輸入,卻轉移到多個不同的輸出狀態;
②狀態的輸入存在ε-邊,即無條件狀態轉移。
下面我們可以看一下這兩個例子:
特點還是比較明顯的。圖1的狀態0獲得輸入a后,分別指向了狀態0和狀態1;圖2中的狀態A可以無條件轉移到狀態B,狀態B又無條件轉移到狀態C。當一個有限自動機存在這些特點時,這個自動機是不穩定的、不確定的,ε-邊的存在導致了狀態不穩定性,多重輸出的存在導致了狀態轉移的不確定性。含有這些特點的狀態機我們叫做非確定有限自動機(Nondeterministic Finite Automata,簡稱NFA)。
那么,為什么要先介紹NFA這種存在瑕疵的自動機呢?這是因為當我們拿到正則表達式RE后,能直接構造出來的狀態機就是非確定的。接下來我們來了解一下如何將RE轉化為NFA。
首先我們來看一些NFA的轉化規則:
簡而言之就是:遇到連接字符串,則分離字符;遇到或符號,則分多條路;遇到*號,則創建ε-邊進入到一個“自循環”狀態。運用這個規則,我們就可以對(a|b)*(aa|bb)(a|b)*這種正則表達式進行NFA轉換了,如圖3下半張圖就是(a|b)*(aa|bb)(a|b)*這個正則表達式對應的NFA結果。仔細觀察可以看到,ε-邊和多重輸入的狀態是很難避免的,因此我們說從RE轉成的FA絕大部分情況會是NFA。
確定有限自動機:
與NFA對立,確定有限自動機(Deterministic Finite Automata,簡稱DFA)就要具備兩個條件:不能存在ε-邊,不能存在相同輸入的多狀態轉移,例如:
圖中的DFA對於每個狀態而言,一種輸入只能有一個固定的去向,消去了NFA多重狀態轉移的問題。那么,如何證明這個DFA和原來的NFA是等價的呢?我們可以測試所有輸入,然后檢查兩個自動機是否有相同的匹配結果。例如在NFA中輸入bbabb可以進入到終態,在DFA中輸入bbabb同樣可以到終態。對於所有的輸入都有相同的匹配結果,那么這個DFA和NFA就是等價的。
判斷不難判斷,但NFA轉換為等價DFA這個工作可不是隨便畫兩筆就能完成的。這里我們要引入一個新的概念:ε-閉包(ε-closure)。什么是ε-閉包呢,就是某個狀態通過若干步ε-邊轉移以后,所能到達的所有狀態集合。ε-closure(A)的意思就是從A狀態出發,經過無限次ε-邊轉移以后所能經過的所有狀態。舉個實例:
這個圖里面,如果要求ε-closure({5}),那么我們就從狀態5出發,不斷走ε-邊,易得經過的狀態有5、6、2(必須包括5自己)。這樣{5,6,2}就是ε-closure({5})所求的閉包集合。
大家一定猜到閉包的實質是在干嘛了:因為DFA要求沒有ε-邊,因此我們就把有ε-邊連接的幾個狀態給划分為一團(即閉包),這樣ε-邊只會出現在這個閉包內。如果我們把閉包定義為新的狀態,那么這個閉包內部的ε-邊自然就沒了。拿剛才的ε-closure({5})舉例,5、6、2之間有很多ε-邊,現在我們把5、6、2塞到一團里成為一個閉包,然后再把這個閉包定義為一個新狀態,那么ε-邊就成功消除了。
好,現在ε-閉包可以幫助我們消去ε-邊,但現在還有一個問題沒解決,那就是單輸入出現多狀態轉移的問題。針對這個問題,我們的解決方式依然是閉包,只不過這回不是ε-閉包,而是a-閉包、b-閉包、c-閉包...(其中abc都是輸入)
a-閉包的定義可以仿照ε-閉包,即對於某狀態集,經過一步a轉換后所能經過的狀態的集合(注意是一步,不再像ε-閉包那樣是任意步),然后對這些狀態分別再求ε-閉包。這個可能有點繞,拿剛才的圖舉例子,如果要求a-closure({1,2}),那么首先我們對狀態1和2分別輸入a,得到的是{3,4,5},然后再對{3,4,5}求ε-閉包,得到的就是{3,4,5,6,2,8,7},這樣{3,4,5,6,2,8,7}就成為了一個新的閉包和狀態。
a-閉包解決多狀態轉移的思路與ε-閉包解決ε-邊的思路非常相似。由於有的狀態輸入a以后有多個狀態轉移,那我直接把這多個去向划分為一團(即閉包),這樣多重a-邊轉移就只會出現在閉包內,再把閉包轉換為一個新狀態,那么多重轉移就消除了。
上圖是一個NFA轉DFA的例子。首先我們第一個閉包選擇初態p的ε-閉包,發現結果就是p,那么我們把這個ε-閉包結果作為新的狀態0放到I列中。接下來我們要對這個新狀態0分別求0-閉包和1-閉包:p輸入0以后能到達的狀態是q和s,再對q和s求ε-閉包發現還是q和s,那么{q,s}就是狀態0的0-閉包。這時發現{q,s}是一種新的狀態(未在I列出現),我們要把這些新的狀態添加到I列中,然后不斷重復上述工作,直到狀態不再增加為止。
此時新的狀態已經出來了,那么每個狀態經過輸入以后轉移到什么狀態也就出來了,例如上表狀態0輸入0以后轉移到狀態1,輸入1以后轉移到狀態2,以此類推,然后我們就可以輕松構建出一個DFA自動機了。
DFA最小化:
DFA的成功建立意味着可以進行編程工作了,只要編碼完成計算機便擁有了分析輸入串的能力。但是有時候我們得到的DFA非常龐大,其中不乏一些無用狀態。因此我們需要精簡DFA,去掉一些無用狀態,將一些等價狀態進行合並。
在最開始,我們將所有狀態划分為兩個閉包,一個是終結態閉包,包含了所有終結狀態;一個是非終結態閉包,包含了所有非終結狀態。對於閉包內部,我們可以進一步進行划分:當同一閉包內的兩個狀態不是等價狀態時,它們就可以划分為不同的閉包。
什么叫等價狀態呢?這詞是我編的,定義如下:如果兩個狀態對於所有輸入,最后轉移到的閉包相同,那么兩個狀態就是等價的,可以進行合並。舉個例子:
按照上述規則,首先我們把這幾個狀態分為終結態閉包{0,1}和非終結態閉包{2},對於{0,1}這個閉包進行測試:當輸入a時,0和1指向的都是自身閉包;當輸入b時,0和1指向的都是2那個閉包,即滿足“對於所有輸入,最后轉移到的閉包相同”,因此我們說0和1是等價狀態,可以合並:
可以看到原來的0和1就合並為了新的0,整個自動機少了一個冗余的狀態,這樣我們就得到了一個精簡化的DFA。接下來我們可以對DFA進行編程,這應該相對比較容易(但是碼量很龐大),因此就不再多贅述了。
小結:
詞法分析的關鍵在於正則表達式的准確構造、NFA的建立、NFA與DFA的轉化以及DFA的最小化,這樣便將一個符號表達式轉化為一個計算機可自動讀入、分析輸入串的自動機程序。詞法分析的結果是分離的tokens和屬性,那么如何判斷這些屬性的搭配是否合理呢?那就涉及到編譯原理的下一層——語法分析了。語法分析的難度將會更上一層,只有認真體會設計思想、多思考多練習,才能將編譯原理學習得更加深入。