通過 LPeg 介紹解析表達式語法(Parsing Expression Grammars)


通過 LPeg 介紹解析表達式語法(Parsing Expression Grammars)

說明: 本文是對 An introduction to Parsing Expression Grammars with LPeg 的翻譯, 此文非常好, 從最簡單的模式(Pattern)講起, 逐步擴展, 最終完成一個計算器表達式的解析器, 非常適合初學者循序漸進地理解學習 LPeg

目錄

什么是 PEG

PEG 或者說 解析表達式語法, 是用字符串匹配來描述語言(或模式)的方式. 跟正則表達式不同, PEG 可以解析一門完整的語言, 包括遞歸結構. 從分類而言, PEG 跟上下文無關文法--通過像YaccBison這樣的工具來實現--很相似.

注意: 語法是語言的規格. 在實際中, 我們用語法把某些語言的輸入字符串轉換成內存中我們可以操作的對象.

跟上下文無關語法(CFG)相比, PEG 的實現方式非常不同. 不同於CFG中被歸約為狀態機, PEG 采用順序解析. 這意味着你編寫的解析規則的順序很重要. 隨后將會提供一個例子. 它們可能會比 CFG 更慢一些, 但是實際中它們相當快. 從概念上來說 PEG 跟一種通常被稱為遞歸下降的手寫模式很相似.

PEG 更容易寫, 通常你不需要寫一個單獨的掃描器(譯者注:此處指詞法掃描): 你的語法直接作用於輸入的文本, 而不需要標識化的步驟(譯者注:此處指把輸入文本通過詞法掃描器分解成 token 序列)。

注意: 如果你對上面這些都沒什么感覺也別擔心, 通過這個指南你會明白它們是如何工作的.

什么是 LPeg

關於 PEG 我首先介紹的是 LPeg, PEG 算法的一個 Lua 實現. LPeg 語法直接在Lua代碼中指定. 這和大多數其他采用編譯器-編譯器模式(compiler compiler pattern)的工具都不同.

在編譯器-編譯器模式中, 你用一種定制的領域特定語言來書寫語法, 然后把它編譯為你的目標語言. 有了 LPeg 你只需編寫 Lua 代碼即可. 每個解析單元(Parsing Unit)或模式對象都是語言中的一類對象(first class). 它可以被 Lua 的內置操作符和控制語句組合起來. 這使得它成為一種表達語法的非常強大的方式.

MoonScript’s grammar 是一個規模更大用來解析一種完整的編程語言moonscriptLPeg 語法的例子.

安裝 LPeg

你可以通過 luarocks.org 來安裝 LPeg:

luarocks install lpeg

一旦安裝完成, 你可以通過 require 來加載模塊:

local lpeg = require("lpeg")

一些簡單的語法

LPeg 提供一系列的單和雙字母命名的函數, 把 Lua 字面量轉換成模式對象. 模式對象能被組合起來制造出更復雜的模式, 或對一個字符串調用檢查匹配, 模式對象的操作符被重載用來提供不同的組合方式.

為了簡潔起見, 我們假定 lpeg 被導入到所有的例子中:

local lpeg = require("lpeg")

我會嘗試解釋用在這個指南中的例子里每一樣東西, 但是為了對所有內置函數都能有一個全面的了解,我建議閱讀 LPeg 官方使用手冊

字符串等價

我們能做出的最簡單的例子就是檢查一個字符串跟另一個字符串相等:

lpeg.P("hello"):match("world") --> 不匹配, 返回 nil
lpeg.P("hello"):match("hello") --> 一個匹配, 返回 6
lpeg.P("hello"):match("helloworld") --> 一個匹配, 返回 6

默認情況下,成功匹配時,LPeg 將返回字符消耗數(譯者注: 也就是成功匹配子串之后的下一個字符的位置). 如果你只是想看看是否匹配這就足夠好了,但如果你試圖解析出字符串的結構來,你必須用一些 LPeg 的捕獲(capturing)函數.

注意: 值得注意的是, 即使沒有得到字符串的末尾, 匹配仍然會成功. 你可以用 -1 來避免這種情況. 我會在下面描述.

模式組合

乘法和加法運算符是用於組合模式的最常用的兩個被重載的運算符.

  • 乘法可以被想成跟 and 一樣的,左邊的操作數必須匹配,同時右邊的操作數必須匹配.

  • 加法可以被想成跟 or 一樣的,要么是左操作數匹配,要么右操作數必須匹配。

這兩個運算符都被要求保持順序. 左邊操作數一直要在右邊操作數之前被檢查. 這里有一些例子:

local hello = lpeg.P("hello")
local world = lpeg.P("world")

-- 譯者注:如果是在 Lua 的命令行交互模式下執行, 記得去掉 local, 否則會報錯
local patt1 = hello * world
local patt2 = hello + world


-- hello followed by world
patt1:match("helloworld") --> matches
patt1:match("worldhello") --> doesn't match

-- either hello or world
patt2:match("hello") --> matches
patt2:match("world") --> matches

注意: 正常的Lua 運算符的處理規則應用到這些操作符上, 因此當需要的時候你將不得不使用括號.

解析數字

有了這個基礎, 我們現在可以寫一個語法來做些什么. 讓我們寫一個從任意字符串中提取所有整數的的語法。

該算法將工作如下:

  • 對於每個字符...
    • 如果它是十進制數字字符, 開始捕獲...
      • 消耗掉每個字符, 如果它是一個十進制數字字符(譯者注:這里的消耗意指比較指針后移一位)
    • 否則忽略, 跳到下一個字符

LPeg 中寫一個解析器我喜歡的途徑是首先寫出最具體的模式。然后使用這些模式作為積木來組裝出最終結果。幸運的是,每一個模式都是一個 Lua 中使用 LPeg 的一類對象,所以很容易單獨測試每個部件.

首先, 我們寫一個解析一個整數的模式:

local integer = lpeg.R("09")^1

這個模式將會匹配 09 之間的任一個數字字符 1 次或者多次. LPeg 中的所有模式都是貪婪的.

我們希望作為返回值的數字值不是匹配結束的字符偏移值. 我們能夠立即用一個 / 運算符來應用一個捕獲變換函數:

local integer = lpeg.R("09")^1 / tonumber

print(integer:match("2923")) --> The number 2923

譯者注: 這里為清楚顯示 / 的作用, 補充下面的對比, 如果不加 / tonumber, 那么返回的就是匹配子串位置后移一位的位置:

> integer = lpeg.R("09")^1 / tonumber
> print(integer:match("2923"))
2923
> integer = lpeg.R("09")^1
> print(integer:match("2923"))
5
> 

它的工作機制是, 通過把模式匹配的結果“2923”做為一個字符串來捕獲,並將其傳遞給Lua函數 tonumbermatch 的返回值是一個從字符串解析得到的標准數字值。

注意: 如果在調用 match 時一個捕獲被使用, 那么缺省的返回值會被替換成捕獲到的值.

現在我們寫一個解析器, 用來匹配一個整數或者一些其他字符:

local integer_or_char = integer + lpeg.P(1)

注意: 當使用 LPeg 的操作符重載時, 它會通過把所有的 Lua 字面量傳遞給 P 而自動地把它們轉換為模式. 在上述的例子中我們可以只寫 1 來取代 lpeg.P(1)

(譯者注: 也就是形如: local integer_or_char = integer + 1)

在這里順序是很重要的:

完成我們的語法只需要重復我們已有的部件, 並且用 Ct 把捕獲到的結果存儲到一個表中:

local extract_ints = lpeg.Ct(integer_or_char^0)

這里是完整的語法個一些運行例子:

local integer = lpeg.R("09")^1 / tonumber
local integer_or_char = integer + lpeg.P(1)
local extract_ints = lpeg.Ct(integer_or_char^0)

-- Testing it out:

extract_ints:match("hello!") --> {}
extract_ints:match("hello 123") --> {123}
extract_ints:match("5 5 5 yeah 7 7 7 ") --> {5,5,5,7,7,7}

一個計算器語法解析器

接下來我們准備構建一個計算器表達式解析器. 我重點強調解析器是因為我們不會去求值表達式而是去構建一個被解析表達式的語法樹.

如果你曾打算建立一種編程語言,你幾乎總是要解析出一個語法樹. 針對這個計算器的語法樹例子是一個很好的練習.

在寫下任意代碼之前我們應該定義能被解析的語言. 它應該能夠解析整數, 加法, 減法, 乘法和除法. 它應該清楚運算符優先級。它應該允許操作符和數字之間的任意空格.

這里是一些輸入例子(由換行符分割):

1*2
1 + 2
5 + 5/2
1*2 + 3
1 * 2 - 3 + 3 + 2

接着我們設計語法樹的格式: 我們如何把這些解析表達式映射為 Lua 友好的表示?

對於普通的整數, 我們可以直接把它們映射為 Lua 中的整數. 對於任意二元表達式(加法,除法等), 我們將會使用 Lisp 風格的 S-表達式(S-Expression) 數組, 數組中的第一個項目是被當做字符串的運算符, 數組中的第 2, 第 3 個項目是運算符左邊的操作數和右邊的操作數.

光用嘴說很麻煩, 用例子很容易領悟:

parse("5") --> 5
parse("1*2") --> {"*", 1, 2}
parse("9+8") --> {"+", 9, 8}
parse("3*2+8") --> {"+", {"*", 3, 2}, 8}

上面的 parse 函數將會成為我們創建的語法.

以規范的方式進行,我們就可以開始編寫解析器。像以前一樣,我們開始盡可能具體:

local lpeg = require("lpeg")

-- 譯者注:處理空格,包括制表符, 回車符, 換行符
local white = lpeg.S(" \t\r\n") ^ 0

local integer = white * lpeg.R("09") ^ 1 / tonumber
local muldiv = white * lpeg.C(lpeg.S("/*"))
local addsub = white * lpeg.C(lpeg.S("+-"))

為了允許任意空格, 我制造了一個空格模式對象 white, 並把它加到所有其他模式對象的前面. 通過這種方式, 我們可以自由地使用模式對象, 而不必去考慮空格是否已經被處理.

我們重復利用了上面的整數模式 integer, 匹配運算符的模式是直線前進. 基於它們的優先級我已經把運算符分成兩組不同的模式.

在嘗試編寫整個語法之前,讓我們專注於讓單個組件工作起來。我選擇編寫整數或乘法/除法的解析程序.

注意: 編程語言的創造者一貫把乘法優先級稱為因子(factor), 把加法優先級稱為項(term)。我們將在這里使用這一術語.

local factor = integer * muldiv * integer + integer

factor:parse("5") --> 5
factor:parse("2*1") --> 2 "*" 1

我們上面工作的乘法運算,但有一個問題。 該模式的捕獲(在本例中的返回值)是錯誤的。它按: 運算符 的順序返回多個值。我們需要的是一個第一個項目為運算符的表。

為了修復這個問題, 我們將會創建一個變換函數, 節點構造器(node constructor)如下:

local function node(p)
  return p / function(left, op, right)
    return { op, left, right }
  end
end

local factor = node(integer * muldiv * integer) + integer

factor:match("5") --> 5
factor:match("2*1") --> {"*", 2, 1}

看起來很好, 現在我們可以利用節點構造器來構建剩下的語法了.

因為我們正在構建一個遞歸語法, 我們將會使用 lpeg.P 的語法形式. 讓我們以這種語法形式重寫上述代碼:

local calculator = lpeg.P({
  "exp",
  exp = lpeg.V("factor") + integer,
  factor = node(integer * muldiv * integer)
})

這里有一些新東西. 第一個是語法表里的 "exp", 它是我們語法的根模式. 這意味着被命名為 exp 的模式將會第一個被執行.

lpeg.V 在我們的語法中被用來引用非終結符(non-terminal). 這就是我們如何做的遞歸,通過對未被聲明過的模式的引用. 這種特殊的語法不是遞歸的,但它仍然演示了 v 如何被使用。

PEG 中我們不能使用任何一種會導致解析器進入無限循環的遞歸. 為了達到我們想要的優先級,我們需要聰明地構造我們的模式。

由於factor、乘法和除法的優先級最高,所以它應該是模式層次結構中最深的。

讓我們重新設計我們的 factor 解析器來處理有重復乘法/除法的情況:

local calculator = lpeg.P({
  "exp",
  exp = lpeg.V("factor") + integer,
  factor = node(integer * muldiv * (lpeg.V("factor") + integer))
})

譯者注: 這里可以根據這段代碼寫出對應的 BNF :

<calculatar> ::= <exp>
<exp> ::= <factor> | <integer>
<factor> ::= <integer>  <muldiv>  { <factor> | <integer>}
<integer> ::= Number
<muldiv> ::= '*' | '/'

使用右遞歸允許任意數量的乘法鏈。我們可以把同一優先級的運算符鏈起來而沒有任何問題。它可以被解析為:

calculator:match("5*3*2") --> {"*", {"*"}}

我們工作的方式是降低優先級直到我們到達語法的頂層. 接着是解析 term

local calculator = lpeg.P({
  "exp",
  exp = lpeg.V("term") + lpeg.V("factor") + integer,
  term = node((lpeg.V("factor") + integer) * addsub * lpeg.V("exp")),
  factor = node(integer * muldiv * (lpeg.V("factor") + integer))
})

term 模式只是對可能發生的情況進行考慮。左側可以是一個高優先級的 factor,或一個整數 integer. 右側可以是相同優先級的 term, 或高優先級的 factor, 或整數 integer(請注意,這些都根據優先級順序列出)

我們能夠復用 exp 模式作為 term 模式的左邊, 因為它剛好符合我們想要的所有東西。

最后是使用了節點構造器的語法:

local lpeg = require("lpeg")
local white = lpeg.S(" \t\r\n") ^ 0

local integer = white * lpeg.R("09") ^ 1 / tonumber
local muldiv = white * lpeg.C(lpeg.S("/*"))
local addsub = white * lpeg.C(lpeg.S("+-"))

local function node(p)
  return p / function(left, op, right)
    return { op, left, right }
  end
end

local calculator = lpeg.P({
  "input",
  input = lpeg.V("exp") * -1,
  exp = lpeg.V("term") + lpeg.V("factor") + integer,
  term = node((lpeg.V("factor") + integer) * addsub * lpeg.V("exp")),
  factor = node(integer * muldiv * (lpeg.V("factor") + integer))
})

注意: 我增加了一個(line)模式, 檢查確保解析器到達了輸入的末尾.

結束

這就是這篇指南. 希望它對於你在自己的項目中開始使用 LPeg 已經足夠了. 用 LPeg 寫的語法 是對 Lua模式或正則表達式的一種很好的替代, 因為它們更容易閱讀,調試和測試. 此外,他們足夠強大到到能夠實現這樣的解析器, 它可以用於完整的編程語言!

在未來我希望寫更多的包括了我在實施 moonscript 中用到的更先進的技術的指導文檔。


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM