語言文法淺淺談
Posted sp42a
tags:
篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了語言文法淺淺談相关的知识,希望对你有一定的参考价值。
想要自造一門語言,我們必須先定義該門語言的文法,這看來很難,特別是被一堆專有名詞卡住,而相關的文件解釋往往又抽象,有如天上浮雲。
實際上,開發者若曾自訂數學公式、撰寫過規則表示式,可能早就定義過一門語言了。
文法用來產生字串
在先前專欄〈打造玩具語言〉中談到,Brainfuck 的實現是認識自造語言不錯的起點,不過,能簡單地實現 Brainfuck,原因就在於:Brainfuck 的文法已經告訴開發者了,開發者只要根據文法規則實現剖析、求值。如果開發者想自行定義語言的文法規則呢?雖然不理會文法定義,以土炮、有洞就補洞的方式,也有可能實現一門玩具語言。然而,若想打造一門具實用性的語言,文法會是最難的部份。
文法到底是什麼呢?
語言說穿了就是字串,文法就是產生字串的規則。讓我們來思考一個特例:使用某隨機演算公式來產生的隨機數字有沒有意義呢?雖然乍看毫無意義可言,實際上,隨機演算公式就是隨機數字的文法,只是隨機演算公式試圖讓人們無法解讀其文法規則罷了。
若從這個角度來看,文法是個數學公式,隨機演算公式產生的隨機數字,就是一門語言。更進一步地來思考,數學公式也是一門語言。例如,一條(4+5)*3
這類四則運算,可以從個位數四則運算的文法來產生。對於學習過四則運算的人而言,即使從未正式地寫出四則運算的文法規則,由於心中都曉得四則運算的規則,當他們看到一條四則運算公式時,也就能依規則來剖析與計算。
開發者可以試著寫出個位數四則運算的文法,然而,這對初次嘗試的人來說,並不容易。一方面,可能是因為太熟悉四則運算(人們常因過於熟悉而不知道是怎麼辦到的),另一方面,是因為四則運算的文法,是屬於上下文無關文法(Context-free Grammar),而關於這類文法構成的語言,可以形成巢狀,相對來說,文法規則其實比較複雜。
規則表示式與文法
開發者多半接觸過規則表示式,也知道它實際上是門語言,一個規則表示式可以由字面字元、詮讀字元、量詞等文法產生。例如,^09\\d8$
是套用^、\\d
等文法規則而產生的規則表示式,如果是熟悉規則表示式文法的開發者,他們的腦海中可以剖析^09\\d8$
,然後知道這個規則表示式的意義。
那麼,若使用^09\\d8$
,我們可以比對出哪些字串呢?像是:0970399398、0918332423……,也就是,比對 09 開頭後接八個整數後結尾的所有字串。然而,從另一個角度來看,我們也可以說:^09\\d8$
有能力產生 0970399398 等字串。既然這樣的文法能用來產生字串,能以^09\\d8$
來產生 0970399398 這類的字串,那麼,^09\\d8$
是這類字串的文法嗎?
是的!如果將「09 開頭後接八個整數後結尾的所有字串」看成是語言,^09\\d8$
就是這門語言的文法,因此,「一條規則表示式就是一門語言的文法」。
類似地,我們如果把電子郵件位址看成是一門語言,此時,想定義出一條規則表示式來比對電子郵件位址,其實,這就是在定義電子郵件位址這門語言的文法。
同時,一門語言的文法不用是唯一的,例如,對於使用^09\\d8$
而能夠涵蓋的語言,我們也可以使用^09[0-9]8$
等其他寫法來產生字串。然而,在定義文法時,若要以字串對應回文法之際,不能有兩種以上的對應方式,若發生這種情況,定義的文法就成了曖昧文法(Ambiguous Grammar)。例如,對於 ab 字串來說,規則表示式(ab)*(a|b)*
可以對應至(ab)*
,也可以對應至(a|b)*
,這時,就會有個疑問:到底是哪個比對出來的呢?就語言來說,就是一句話會有多種解讀方式。
一般而言,可以透過規則表示式來描述的語言,是正規語言(Regular language)。簡單來說,正規語言是可以被有限狀態機,或者是不需使用到記憶體的自動機辨識的語言,因此,語言不能有階層、巢狀之類的關係,同時,任意數量的對稱括號,無法使用規則表示式描述。像是四則運算式中會出現任意數量的括號,因此,它就不是正規語言,我們無法使用規則表示式來描述,至於圖靈等價語言、html 等,也都不是規則語言。所以,規則表示式只用來處理循序的文字,無法用於剖析一份程式碼,或是 HTML 網頁,原因就在於,像是程式碼或 HTML 網頁這類語言,其實,是屬於上下文無關文法(Context-free Grammar)描述的範疇。
所謂的「上下文無關」,是指文法規則中,左邊都只有一個符號,因此,我們可以隨意套用文法規則中的任一條,來產生一個符合文法的字串,不會有上下文關係。例如,面對個位數四則運算的文法,我們如果以 BNF(Backus-Naur Form)的形式來書寫,其內容 敘述會是:
<expr> ::= <expr> <op> <expr>
<expr> ::= "(" <expr> ")"
<expr> ::= "0" | "1" | "2" | "3" | "4" | "5" | "6" | "7" | "8" | "9"
<op> ::= "+" | "-" | "×" | "÷"
上述的<expr>
可以衍生出<expr> <op> <expr>
,接著衍生出2 <op> <expr>
,然後2 + ,最後是2 + 3,這種衍生過程都是先消除最左邊符號的方式,稱作左衍生(leftmost derivation)。相對地,衍生過程若是先消除最右邊的符號,則稱為右衍生,如果試著將衍生過程畫出來,會是個樹狀結構。從這點來看,文法也是個資料結構,描述了語言的結構關係。不過,以上的四則運算文法是曖昧的,因為沒有定義優先權和結合律的規則。對於1+2×3+4來
說,衍生過程形成的樹狀結構不會是唯一,結果就是在缺乏優先權和結合性的規則下,這個算式的運算結果會有不同的解讀方式。
如果你想要進一步知道四則運算式的優先權和結合律規則,此時,可以參考〈Language〉。
名詞是溝通經驗的橋樑
正規語言、曖昧文法、上下文無關文法、左衍生、右衍生……,一定要有這麼多名詞嗎?是的,而且,這些都是屬於形式語言(Formal language)的範疇,若想打造一門具實用性的語言,要知道的名詞還會更多。
務實地面對這些名詞會是必要的,因為這些名詞背後代表的,是前人的經驗,某些程度上,這些名詞也有點像是設計模式的概念,可用來作為溝通經驗的橋樑。
然而,釐清名詞不會是個簡單的任務,特別是這些名詞使用了正式的數學定義來描述,更常令人感到抽象難解。若是開發者曾經被這些名詞困擾過,不妨可以試著從規則表示式、四則運算著手,透過這類可能早以熟悉的運算來重新思考一下,或許會有不同的啟發。
LL/LR不神祕
在設計語言時,如果我們嘗試自行建立剖析器(Parser),終究會遇上 LL、LR 剖析器等名詞,不過,面對相關文件上的數學定義,往往令人覺得神祕難解。實際上,關於剖析樹(Parser tree)生成的兩個方向,開發者也可能早就做過類似的剖析。
文法與剖析樹
開發者多半都做過文字剖析,只是文字來源複雜度不同,可能是單純以某符號分格的字串,或是需透過規則表示式定義的格式。
若進一步來看,文字來源具有某種結構,就必須套用某種資料結構與演算法,然而,只要能達到剖析需求,撰寫的程式就都是剖析器,只不過採用的資料結構或演算法可能專用於特定需求,以及差別在於有沒有效率罷了。
事實上,描述文字結構的方式就是文法,在實現剖析器時,開發者可能早就設計過文法,只是文法隱含在實作之中。例如,剖析四則運算時,會根據優先權尋找運算子符號,以決定運算元的計算順序,這實際上就是在套用四則運算中的優先權文法規則。另一方面,我在先前專欄〈語言文法淺淺談〉也談過,如果我們定義了一個規則表示式,實際上,這也是在定義某門語言的文法。
套用文法規則的過程,其實,就是試著以文法來衍生(derive)某串文字。
我在先前發表的〈語言文法淺淺談〉中也談過,若我們將衍生過程逐一畫下,會是個樹狀結構,而如果衍生出的文字就是想剖析的來源文字,這棵樹就是來源文字的剖析樹。如果文法不曖昧,此時,文字只會建立唯一的剖析樹,而從葉節點往根節點化簡(reduce)運算的過程,就是解讀文字意義的方式,當中唯一的剖析樹,也代表著文字只會有一種解釋。
剖析器的任務就是根據文法,正確(且有效率)地建立出剖析樹。面對簡單的文字剖析作業,開發者雖然未必在程式的實作上,直接去構造剖析樹的資料結構,但實際上,剖析樹也會是隱含在流程之中。例如,剖析四則運算式的處理方式之一,是找出優先權最低的運算符號位置,將運算式切為左右兩份,分別進行遞迴處理,若我們將遞迴流程畫出來,也會看到是個樹狀結構。
LL/LR 剖析器?
為了能夠探討剖析文字時的通用性與效率,如果我們將相關的資料結構與演算流程,隱含在程式實現之中,其實並不是個好主意。於是,也就有了 LL、LR 之類的名詞,以此代表著剖析器建立剖析樹時的方式。
方才我們談到以遞迴處理四則運算的方式,就是屬於 LL 剖析器,而這裡所稱的第一個L,其實是代表從左而右讀取輸入的詞法單元(Token),至於第二個 L,代表的是左衍生,也就是,剖析樹的生成過程,是以先消除最左邊符號的方式來進行。
事實上,從簡單的文法與文字剖析的角度,我們會比較能理解 LL 與 LR 的差別。例如,若有個文法S->Ac、A->ab
,以及輸入文字 abc,開發者可以如何剖析呢?此時,剖析樹可以從S節點開始進行,基於 ab,而衍出A節點,A節點也衍生出對應a與b的節點,接著,基於 c 衍生出 S 的子節點來對應。基本上,上述這種衍生方式,是基於文法來進行左衍生,屬於 LL 剖析器。
另一種剖析方式則是,看到 a,就建立對應的節點,看到 b,也建立對應節點,接著,依文法規則來化簡出 A 作為父節點,進一步地,看到 c,也建立對應節點,基於文法規則,來化簡出 S 節點。基本上,類似這種自底而上建立剖析樹的方式,正是屬於 LR 剖析器。
相較之下,LL 剖析器是自頂而下建立剖析樹,而另一種 LR 剖析器的 R,代表著右衍生。其實,這是因為,節點生成順序,會是依文法規則進行右衍生後的相反順序。
LL剖析器由於是「自頂而下」,實現起來比較直覺。例如,1+2*3
是個運算式,剖析時,就是想辦法切分為 1、+、23,然後1與23 也是個運算式,因此,可以再分別對它們進行剖析。若開發者曾經實作過簡單的語言剖析器,很有可能就是採取了 LL 剖析器的概念。就像在 Gof 設計模式中談到的 Interpreter 模式,基本上,就是用來實作 LL 剖析器的一種模式。
後序運算式
單看 LR 剖析器以化簡、從底而上建立剖析樹的方式,就滿違反直覺的。而且,若想一窺實現的原理,相關的文件往往就讓人看得一個頭兩個大。然而,開發者若曾經試著實現將中序運算式轉為後序式,然後進行後序式的運算,其實,在那當下,也就已經實現簡單的LR剖析器。而這種運算式的後序運算式,又稱逆向波蘭運算式(Reverse polish notation)。例如,(a+b)*(c+d)
,轉為後序運算式後是 ab+cd+*
,接著,運算時將 a、b 置入堆疊,看到 + 的話,取出 a、b計算後、置回堆疊,接著,c、d置入堆疊,看到+取出 cd 運算後、放入堆疊,最後,看到*將堆疊中兩個值取出相乘,就是最終的結果。
對照至 LR 剖析器對剖析樹的建立,就會是取得a、b後建立對應節點、各置入堆疊,看到 + 取出 a、b 對應節點,建立運算節點包含 a、b 節點後、置入堆疊,這就是化簡動作,而如此持續下去,就可以建立起完整的剖析樹。也就是說,將輸入的中序運算式轉換為後序運算式,之後,我們再按照後序運算的方式,來建立節點關係,而這樣就會是一種自底而上建立剖析樹的過程。
在〈LL and LR Parsing Demystified〉中,作者也談到下列狀況:如果我們將四則運算式的剖析樹繪製出來,可以看到LL剖析器與LR剖析器的建立節點方式,會分別對應於「前序遍歷(Pre-Order Traversal)」,以及「後序遍歷(Post-Order Traversal)」,而且,前者是先存取根,再來存取子樹,後者是先存取子樹,然後再存取根。例如,該篇文章當中的1+2*3
剖析樹,若是後序遍歷,會是123*+
,這就是後序運算式;若是前序遍歷,會是+1*23
,這就是前序表示式了。
試著從經驗中理解
簡單來說,如果將剖析器當成黑箱,並且令其將剖析樹各節點依建立順序輸出的話,根據結果相當於樹的前序或後序遍歷,將會決定了它是 LL 或 LR 剖析器。
如果開發者過去實現過某種剖析器,就算沒有真的實現樹狀資料結構,此時,也可以從處理符號的順序上來看看,判斷其大概會是屬於LL剖析器,或是LR剖析器。
雖說真想設計個語言,我們多半會使用剖析產生器(parser generator),像是 Yacc、ANTLR 之類,不過,下次如果想自行實現簡單的剖析器,或者必須理解 LL、LR 這類名詞,才能善用某個剖析產生器時,對於 LL、LR 的基本認知,就會是個不錯的思考方向。
以上是关于語言文法淺淺談的主要内容,如果未能解决你的问题,请参考以下文章