1. 程式人生 > >Python 之父的解析器系列之七:PEG 解析器的元語法

Python 之父的解析器系列之七:PEG 解析器的元語法

原題 | A Meta-Grammar for PEG Parsers

作者 | Guido van Rossum(Python之父)

譯者 | 豌豆花下貓(“Python貓”公眾號作者)

宣告 | 本翻譯是出於交流學習的目的,基於 CC BY-NC-SA 4.0 授權協議。為便於閱讀,內容略有改動。本系列的譯文已在 Github 開源,專案地址:https://github.com/chinesehuazhou/guido_blog_translation

本週我們使解析器生成器完成“自託管”(self-hosted),也就是讓它自己生成解析器。

首先我們有了一個解析器生成器,其中一部分是語法解析器。我們可以稱之為元解析器(meta-parser)。該元解析器與要生成的解析器類似:GrammarParser

繼承自Parser ,它使用相同的 mark()/reset()/expect() 機制。然而,它是手寫的。但是,只能是手寫麼?

在編譯器設計中有一個傳統,即編譯器使用它要編譯的語言編寫。我深切地記得在我初學程式設計時,當時用的 Pascal 編譯器是用 Pascal 本身編寫的,GCC 是用 C 編寫的,Rust 編譯器當然是用 Rust 編寫的。

這是怎麼做到的呢?有一個輔助過程(bootstrap,載入程式,通常譯作“自舉”):對於一種語言的子集或早期版本,它的編譯器是用其它的語言編寫的。(我記得最初的 Pascal 編譯器是用 FORTRAN 編寫的!)然後用編譯後的語言編寫一個新的編譯器,並用輔助的編譯器來編譯它。一旦新的編譯器執行得足夠好,輔助的編譯器就會被廢棄,並且該語言或新編譯器的每個新版本,都會受到先前版本的編譯器的編譯能力的約束。

讓我們的元解析器如法炮製。我們將為語法編寫一個語法(元語法),然後我們將從中生成一個新的元解析器。幸運的是我從一開始就計劃了,所以這是一個非常簡單的練習。我們在上一篇文章中新增的動作是必不可少的因素,因為我們不希望被迫去更改生成器——因此我們需要能夠生成一個可相容的資料結構。

這是一個不加動作的元語法的簡化版:

start: rules ENDMARKER
rules: rule rules | rule
rule: NAME ":" alts NEWLINE
alts: alt "|" alts | alt
alt: items
items: item items | item
item: NAME | STRING

我將自下而上地展示如何新增動作。參照第 3 篇,我們有了一些帶 name 和 alts 屬性的 Rule 物件。最初,alts 只是一個包含字串列表的列表(外層列表代表備選項,內層列表代表備選項的條目),但為了新增動作,我更改了一些內容,備選項由具有 items 和 action 屬性的 Alt 物件來表示。條目仍然由純字串表示。對於 item 規則,我們有:

item: NAME { name.string } | STRING { string.string }

這需要一些解釋:當解析器處理一個識別符號時,它返回一個 TokenInfo 物件,該物件具有 type、string 及其它屬性。我們不希望生成器來處理 TokenInfo 物件,因此這裡加了動作,它會從識別符號中提取出字串。請注意,對於像 NAME 這樣的全大寫識別符號,生成的解析器會使用小寫版本(此處為 name )作為變數名。

接下來是 items 規則,它必須返回一個字串列表:

items: item items { [item] + items } | item { [item] }

我在這裡使用右遞迴規則,所以我們不依賴於第 5 篇中新增的左遞迴處理。(為什麼不呢?保持事情儘可能簡單總是一個好主意,這個語法使用左遞迴的話,不是很清晰。)請注意,單個的 item 已被分層,但遞迴的 items 沒有,因為它已經是一個列表。

alt 規則用於構建 Alt 物件:

alt: items { Alt(items) }

我就不介紹 rules 和 start 規則了,因為它們遵循相同的模式。

但是,有兩個未解決的問題。首先,生成的程式碼如何知道去哪裡找到 Rule 和 Alt 類呢?為了實現這個目的,我們需要為生成的程式碼新增一些 import 語句。最簡單的方法是給生成器傳遞一個標誌,該標誌表示“這是元語法”,然後讓生成器在生成的程式頂部引入額外的 import 語句。但是既然我們已經有了動作,許多其它解析器也會想要自定義它們的匯入,所以為什麼我們不試試看,能否新增一個更通用的功能呢。

有很多方法可以剝了這隻貓的皮(譯註:skin this cat,解決這個難題)。一個簡單而通用的機制是在語法的頂部新增一部分“變數定義”,並讓生成器使用這些變數,來控制生成的程式碼的各個方面。我選擇使用 @ 字元來開始一個變數定義,在它之後是變數名(一個 NAME)和值(一個 STRING)。例如,我們可以將以下內容放在元語法的頂部:

@subheader "from grammar import Rule, Alt"

標準的匯入總是會列印(例如,去匯入 memoize),在那之後,解析器生成器會列印 subheader 變數的值。如果需要多個 import,可以在變數宣告中使用三引號字串,例如:

@subheader """
from token import OP
from grammar import Rule, Alt
"""

這很容易新增到元語法中,我們用這個替換 start 規則:

start: metas rules ENDMARKER | rules ENDMARKER
metas: meta metas | meta
meta: "@" NAME STRING NEWLINE

(我不記得為什麼我會稱它們為“metas”,但這是我在編寫程式碼時選擇的名稱,我會堅持這樣叫。:-)

我們還必須將它新增到輔助的元解析器中。既然語法不僅僅是一系列的規則,那麼讓我們新增一個 Grammar 物件,其中包含屬性 metasrules。我們可以放入如下的動作:

start: metas rules ENDMARKER { Grammar(rules, metas) }
     | rules ENDMARKER { Grammar(rules, []) }
metas: meta metas { [meta] + metas }
     | meta { [meta] }
meta: "@" NAME STRING { (name.string, eval(string.string)) }

(注意 meta 返回一個元組,並注意它使用 eval() 來處理字串引號。)

說到動作,我漏講了 alt 規則的動作!原因是這裡面有些混亂。但我不能再無視它了,上程式碼吧:

alt: items action { Alt(items, action) }
   | items { Alt(items, None) }
action: "{" stuffs "}" { stuffs }
stuffs: stuff stuffs { stuff + " " + stuffs }
      | stuff { stuff }
stuff: "{" stuffs "}" { "{" + stuffs + "}" }
     | NAME { name.string }
     | NUMBER { number.string }
     | STRING { string.string }
     | OP { None if op.string in ("{", "}") else op.string }

這個混亂是由於我希望在描繪動作的花括號之間允許任意 Python 程式碼,以及允許配對的大括號巢狀在其中。為此,我們使用了特殊識別符號 OP,標記生成器用它生成可被 Python 識別的所有標點符號(返回一個型別為 OP 識別符號,用於多字元運算子,如 <= 或 ** )。在 Python 表示式中可以合法地出現的唯一其它識別符號是名稱、數字和字串。因此,在動作的最外側花括號之間的“東西”似乎是一組迴圈的 NAME | NUMBER | STRING | OP 。

嗚呼,這沒用,因為 OP 也匹配花括號,但由於 PEG 解析器是貪婪的,它會吞掉結束括號,我們就永遠看不到動作的結束。因此,我們要對生成的解析器新增一些調整,允許動作通過返回 None 來使備選項失效。我不知道這是否是其它 PEG 解析器的標準配置——當我考慮如何解決右括號(甚至巢狀的符號)的識別問題時,立馬就想到了這個方法。它似乎運作良好,我認為這符合 PEG 解析的一般哲學。它可以被視為一種特殊形式的前瞻(我將在下面介紹)。

使用這個小調整,當出現花括號時,我們可以使 OP 上的匹配失效,它可以通過 stuff 和 action 進行匹配。

有了這些東西,元語法可以由輔助的元解析器解析,並且生成器可以將它轉換為新的元解析器,由此解析自己。更重要的是,新的元解析器仍然可以解析相同的元語法。如果我們使用新的元編譯器編譯元語法,則輸出是相同的:這證明生成的元解析器正常工作。

這是帶有動作的完整元語法。只要你把解析過程串起來,它就可以解析自己:

@subheader """
from grammar import Grammar, Rule, Alt
from token import OP
"""
start: metas rules ENDMARKER { Grammar(rules, metas) }
     | rules ENDMARKER { Grammar(rules, []) }
metas: meta metas { [meta] + metas }
     | meta { [meta] }
meta: "@" NAME STRING NEWLINE { (name.string, eval(string.string)) }
rules: rule rules { [rule] + rules }
     | rule { [rule] }
rule: NAME ":" alts NEWLINE { Rule(name.string, alts) }
alts: alt "|" alts { [alt] + alts }
    | alt { [alt] }
alt: items action { Alt(items, action) }
   | items { Alt(items, None) }
items: item items { [item] + items }
     | item { [item] }
item: NAME { name.string }
    | STRING { string.string }
action: "{" stuffs "}" { stuffs }
stuffs: stuff stuffs { stuff + " " + stuffs }
      | stuff { stuff }
stuff: "{" stuffs "}" { "{" + stuffs + "}" }
     | NAME { name.string }
     | NUMBER { number.string }
     | STRING { string.string }
     | OP { None if op.string in ("{", "}") else op.string }

現在我們已經有了一個能工作的元語法,可以準備做一些改進了。

但首先,還有一個小麻煩要處理:空行!事實證明,標準庫的 tokenize 會生成額外的識別符號來跟蹤非重要的換行符和註釋。對於前者,它生成一個 NL 識別符號,對於後者,則是一個 COMMENT 識別符號。以其將它們吸收進語法中(我已經嘗試過,但並不容易!),我們可以在 tokenizer 類中新增一段非常簡單的程式碼,來過濾掉這些識別符號。這是改進的 peek_token 方法:

def peek_token(self):
        if self.pos == len(self.tokens):
            while True:
                token = next(self.tokengen)
                if token.type in (NL, COMMENT):
                    continue
                break
            self.tokens.append(token)
            self.report()
        return self.tokens[self.pos]

這樣就完全過濾掉了 NL 和 COMMENT 識別符號,因此在語法中不再需要擔心它們。

最後讓我們對元語法進行改進!我想做的事情純粹是美容性的:我不喜歡被迫將所有備選項放在同一行上。我上面展示的元語法實際上並沒有解析自己,因為有這樣的情況:

start: metas rules ENDMARKER { Grammar(rules, metas) }
     | rules ENDMARKER { Grammar(rules, []) }

這是因為識別符號生成器(tokenizer)在第一行的末尾產生了一個 NEWLINE 識別符號,此時元解析器會認為這是該規則的結束。此外,NEWLINE 之後會出現一個 INDENT 識別符號,因為下一行是縮排的。在下一個規則開始之前,還會有一個 DEDENT 識別符號。

下面是解決辦法。為了理解 tokenize 模組的行為,我們可以將 tokenize 模組作為指令碼執行,併為其提供一些文字,以此來檢視對於縮排塊,會生成什麼樣的識別符號序列:

$ python -m tokenize
foo bar
    baz
    dah
dum
^D

我們發現它會產生以下的識別符號序列(我已經簡化了上面執行的輸出):

NAME     'foo'
NAME     'bar'
NEWLINE
INDENT
NAME     'baz'
NEWLINE
NAME     'dah'
NEWLINE
DEDENT
NAME     'dum'
NEWLINE

這意味著一組縮排的程式碼行會被 INDENT 和 DEDENT 標記符所描繪。現在,我們可以重新編寫元語法規則的 rule 如下:

rule: NAME ":" alts NEWLINE INDENT more_alts DEDENT {
        Rule(name.string, alts + more_alts) }
    | NAME ":" alts NEWLINE { Rule(name.string, alts) }
    | NAME ":" NEWLINE INDENT more_alts DEDENT {
        Rule(name.string, more_alts) }
more_alts: "|" alts NEWLINE more_alts { alts + more_alts }
         | "|" alts NEWLINE { alts }

(我跨行地拆分了動作,以便它們適應 Medium 網站的窄頁——這是可行的,因為識別符號生成器會忽略已配對的括號內的換行符。)

這樣做的好處是我們甚至不需要更改生成器:這種改進的元語法生成的資料結構跟以前相同。同樣注意 rule 的第三個備選項,對此讓我們寫:

start:
    | metas rules ENDMARKER { Grammar(rules, metas) }
    | rules ENDMARKER { Grammar(rules, []) }

有些人會覺得這比我之前展示的版本更乾淨。很容易允許兩種形式共存,所以我們不必爭論風格。

在下一篇文章中,我將展示如何實現各種 PEG 功能,如可選條目、重複和前瞻。(說句公道話,我本打算把那放在這篇裡,但是這篇已寫太長了,所以我要把它分成兩部分。)

本文內容與示例程式碼的授權協議:CC BY-NC-SA 4.0

公眾號【Python貓】, 本號連載優質的系列文章,有喵星哲學貓系列、Python進階系列、好書推薦系列、技術寫作、優質英文推薦與翻譯等等,歡迎關注哦