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

原題 | A Meta-Grammar for PEG Parsers

作者 | Guido van Rossum(Python之父)

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

聲明 | 本翻譯是出于交流學(xué)習(xí)的目的药有,基于 CC BY-NC-SA 4.0 授權(quán)協(xié)議。為便于閱讀沪铭,內(nèi)容略有改動复斥。本系列的譯文已在 Github 開源,項(xiàng)目地址:https://github.com/chinesehuazhou/guido_blog_translation

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

首先我們有了一個解析器生成器,其中一部分是語法解析器唬党。我們可以稱之為元解析器(meta-parser)。該元解析器與要生成的解析器類似:GrammarParser 繼承自Parser 鬼佣,它使用相同的 mark()/reset()/expect() 機(jī)制驶拱。然而,它是手寫的晶衷。但是蓝纲,只能是手寫么阴孟?

在編譯器設(shè)計(jì)中有一個傳統(tǒng),即編譯器使用它要編譯的語言編寫税迷。我深切地記得在我初學(xué)編程時永丝,當(dāng)時用的 Pascal 編譯器是用 Pascal 本身編寫的,GCC 是用 C 編寫的箭养,Rust 編譯器當(dāng)然是用 Rust 編寫的慕嚷。

這是怎么做到的呢?有一個輔助過程(bootstrap毕泌,引導(dǎo)程序喝检,通常譯作“自舉”):對于一種語言的子集或早期版本,它的編譯器是用其它的語言編寫的撼泛。(我記得最初的 Pascal 編譯器是用 FORTRAN 編寫的D铀怠)然后用編譯后的語言編寫一個新的編譯器,并用輔助的編譯器來編譯它坎弯。一旦新的編譯器運(yùn)行得足夠好纺涤,輔助的編譯器就會被廢棄,并且該語言或新編譯器的每個新版本抠忘,都會受到先前版本的編譯器的編譯能力的約束撩炊。

讓我們的元解析器如法炮制。我們將為語法編寫一個語法(元語法)崎脉,然后我們將從中生成一個新的元解析器拧咳。幸運(yùn)的是我從一開始就計(jì)劃了,所以這是一個非常簡單的練習(xí)囚灼。我們在上一篇文章中添加的動作是必不可少的因素骆膝,因?yàn)槲覀儾幌M黄热ジ纳善鳌虼宋覀冃枰軌蛏梢粋€可兼容的數(shù)據(jù)結(jié)構(gòu)。

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

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 只是一個包含字符串列表的列表(外層列表代表備選項(xiàng)政钟,內(nèi)層列表代表備選項(xiàng)的條目),但為了添加動作樟结,我更改了一些內(nèi)容养交,備選項(xiàng)由具有 items 和 action 屬性的 Alt 對象來表示。條目仍然由純字符串表示瓢宦。對于 item 規(guī)則碎连,我們有:

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

這需要一些解釋:當(dāng)解析器處理一個標(biāo)識符時,它返回一個 TokenInfo 對象驮履,該對象具有 type鱼辙、string 及其它屬性廉嚼。我們不希望生成器來處理 TokenInfo 對象,因此這里加了動作座每,它會從標(biāo)識符中提取出字符串前鹅。請注意,對于像 NAME 這樣的全大寫標(biāo)識符峭梳,生成的解析器會使用小寫版本(此處為 name )作為變量名。

接下來是 items 規(guī)則蹂喻,它必須返回一個字符串列表:

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

我在這里使用右遞歸規(guī)則葱椭,所以我們不依賴于第 5 篇中添加的左遞歸處理。(為什么不呢口四?保持事情盡可能簡單總是一個好主意孵运,這個語法使用左遞歸的話,不是很清晰蔓彩。)請注意治笨,單個的 item 已被分層,但遞歸的 items 沒有赤嚼,因?yàn)樗呀?jīng)是一個列表旷赖。

alt 規(guī)則用于構(gòu)建 Alt 對象:

alt: items { Alt(items) }

我就不介紹 rules 和 start 規(guī)則了,因?yàn)樗鼈冏裱嗤哪J健?/p>

但是更卒,有兩個未解決的問題等孵。首先,生成的代碼如何知道去哪里找到 Rule 和 Alt 類呢蹂空?為了實(shí)現(xiàn)這個目的俯萌,我們需要為生成的代碼添加一些 import 語句。最簡單的方法是給生成器傳遞一個標(biāo)志上枕,該標(biāo)志表示“這是元語法”咐熙,然后讓生成器在生成的程序頂部引入額外的 import 語句。但是既然我們已經(jīng)有了動作辨萍,許多其它解析器也會想要自定義它們的導(dǎo)入棋恼,所以為什么我們不試試看,能否添加一個更通用的功能呢分瘦。

有很多方法可以剝了這只貓的皮(譯注:skin this cat蘸泻,解決這個難題)。一個簡單而通用的機(jī)制是在語法的頂部添加一部分“變量定義”嘲玫,并讓生成器使用這些變量悦施,來控制生成的代碼的各個方面。我選擇使用 @ 字符來開始一個變量定義去团,在它之后是變量名(一個 NAME)和值(一個 STRING)抡诞。例如穷蛹,我們可以將以下內(nèi)容放在元語法的頂部:

@subheader "from grammar import Rule, Alt"

標(biāo)準(zhǔn)的導(dǎo)入總是會打印(例如昼汗,去導(dǎo)入 memoize)肴熏,在那之后,解析器生成器會打印 subheader 變量的值顷窒。如果需要多個 import蛙吏,可以在變量聲明中使用三引號字符串,例如:

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

這很容易添加到元語法中鞋吉,我們用這個替換 start 規(guī)則:

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

(我不記得為什么我會稱它們?yōu)椤癿etas”鸦做,但這是我在編寫代碼時選擇的名稱,我會堅(jiān)持這樣叫谓着。:-)

我們還必須將它添加到輔助的元解析器中泼诱。既然語法不僅僅是一系列的規(guī)則,那么讓我們添加一個 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 規(guī)則的動作!原因是這里面有些混亂阿纤。但我不能再無視它了句灌,上代碼吧:

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 代碼,以及允許配對的大括號嵌套在其中欠拾。為此胰锌,我們使用了特殊標(biāo)識符 OP,標(biāo)記生成器用它生成可被 Python 識別的所有標(biāo)點(diǎn)符號(返回一個類型為 OP 標(biāo)識符藐窄,用于多字符運(yùn)算符资昧,如 <= 或 ** )。在 Python 表達(dá)式中可以合法地出現(xiàn)的唯一其它標(biāo)識符是名稱荆忍、數(shù)字和字符串格带。因此,在動作的最外側(cè)花括號之間的“東西”似乎是一組循環(huán)的 NAME | NUMBER | STRING | OP 刹枉。

嗚呼叽唱,這沒用,因?yàn)?OP 也匹配花括號微宝,但由于 PEG 解析器是貪婪的棺亭,它會吞掉結(jié)束括號,我們就永遠(yuǎn)看不到動作的結(jié)束蟋软。因此镶摘,我們要對生成的解析器添加一些調(diào)整嗽桩,允許動作通過返回 None 來使備選項(xiàng)失效。我不知道這是否是其它 PEG 解析器的標(biāo)準(zhǔn)配置——當(dāng)我考慮如何解決右括號(甚至嵌套的符號)的識別問題時凄敢,立馬就想到了這個方法碌冶。它似乎運(yùn)作良好,我認(rèn)為這符合 PEG 解析的一般哲學(xué)涝缝。它可以被視為一種特殊形式的前瞻(我將在下面介紹)扑庞。

使用這個小調(diào)整,當(dāng)出現(xiàn)花括號時拒逮,我們可以使 OP 上的匹配失效嫩挤,它可以通過 stuff 和 action 進(jìn)行匹配。

有了這些東西消恍,元語法可以由輔助的元解析器解析,并且生成器可以將它轉(zhuǎn)換為新的元解析器以现,由此解析自己狠怨。更重要的是,新的元解析器仍然可以解析相同的元語法邑遏。如果我們使用新的元編譯器編譯元語法佣赖,則輸出是相同的:這證明生成的元解析器正常工作。

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

@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 }

現(xiàn)在我們已經(jīng)有了一個能工作的元語法,可以準(zhǔn)備做一些改進(jìn)了纪吮。

但首先俩檬,還有一個小麻煩要處理:空行!事實(shí)證明碾盟,標(biāo)準(zhǔn)庫的 tokenize 會生成額外的標(biāo)識符來跟蹤非重要的換行符和注釋棚辽。對于前者,它生成一個 NL 標(biāo)識符冰肴,對于后者屈藐,則是一個 COMMENT 標(biāo)識符。以其將它們吸收進(jìn)語法中(我已經(jīng)嘗試過熙尉,但并不容易A摺),我們可以在 tokenizer 類中添加一段非常簡單的代碼检痰,來過濾掉這些標(biāo)識符包归。這是改進(jìn)的 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 標(biāo)識符,因此在語法中不再需要擔(dān)心它們攀细。

最后讓我們對元語法進(jìn)行改進(jìn)箫踩!我想做的事情純粹是美容性的:我不喜歡被迫將所有備選項(xiàng)放在同一行上爱态。我上面展示的元語法實(shí)際上并沒有解析自己,因?yàn)橛羞@樣的情況:

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

這是因?yàn)闃?biāo)識符生成器(tokenizer)在第一行的末尾產(chǎn)生了一個 NEWLINE 標(biāo)識符境钟,此時元解析器會認(rèn)為這是該規(guī)則的結(jié)束锦担。此外,NEWLINE 之后會出現(xiàn)一個 INDENT 標(biāo)識符慨削,因?yàn)橄乱恍惺强s進(jìn)的洞渔。在下一個規(guī)則開始之前,還會有一個 DEDENT 標(biāo)識符缚态。

下面是解決辦法磁椒。為了理解 tokenize 模塊的行為,我們可以將 tokenize 模塊作為腳本運(yùn)行玫芦,并為其提供一些文本浆熔,以此來查看對于縮進(jìn)塊,會生成什么樣的標(biāo)識符序列:

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

我們發(fā)現(xiàn)它會產(chǎn)生以下的標(biāo)識符序列(我已經(jīng)簡化了上面運(yùn)行的輸出):

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

這意味著一組縮進(jìn)的代碼行會被 INDENT 和 DEDENT 標(biāo)記符所描繪∏欧現(xiàn)在医增,我們可以重新編寫元語法規(guī)則的 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 }

(我跨行地拆分了動作,以便它們適應(yīng) Medium 網(wǎng)站的窄頁——這是可行的老虫,因?yàn)闃?biāo)識符生成器會忽略已配對的括號內(nèi)的換行符叶骨。)

這樣做的好處是我們甚至不需要更改生成器:這種改進(jìn)的元語法生成的數(shù)據(jù)結(jié)構(gòu)跟以前相同。同樣注意 rule 的第三個備選項(xiàng)祈匙,對此讓我們寫:

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

有些人會覺得這比我之前展示的版本更干凈忽刽。很容易允許兩種形式共存,所以我們不必爭論風(fēng)格夺欲。

在下一篇文章中跪帝,我將展示如何實(shí)現(xiàn)各種 PEG 功能,如可選條目洁闰、重復(fù)和前瞻歉甚。(說句公道話,我本打算把那放在這篇里扑眉,但是這篇已寫太長了纸泄,所以我要把它分成兩部分。)

本文內(nèi)容與示例代碼的授權(quán)協(xié)議:CC BY-NC-SA 4.0

公眾號【Python貓】腰素, 本號連載優(yōu)質(zhì)的系列文章聘裁,有喵星哲學(xué)貓系列、Python進(jìn)階系列弓千、好書推薦系列衡便、技術(shù)寫作、優(yōu)質(zhì)英文推薦與翻譯等等,歡迎關(guān)注哦镣陕。

?著作權(quán)歸作者所有,轉(zhuǎn)載或內(nèi)容合作請聯(lián)系作者
  • 序言:七十年代末谴餐,一起剝皮案震驚了整個濱河市,隨后出現(xiàn)的幾起案子呆抑,更是在濱河造成了極大的恐慌岂嗓,老刑警劉巖,帶你破解...
    沈念sama閱讀 217,406評論 6 503
  • 序言:濱河連續(xù)發(fā)生了三起死亡事件鹊碍,死亡現(xiàn)場離奇詭異厌殉,居然都是意外死亡,警方通過查閱死者的電腦和手機(jī)侈咕,發(fā)現(xiàn)死者居然都...
    沈念sama閱讀 92,732評論 3 393
  • 文/潘曉璐 我一進(jìn)店門公罕,熙熙樓的掌柜王于貴愁眉苦臉地迎上來,“玉大人耀销,你說我怎么就攤上這事楼眷。” “怎么了熊尉?”我有些...
    開封第一講書人閱讀 163,711評論 0 353
  • 文/不壞的土叔 我叫張陵摩桶,是天一觀的道長。 經(jīng)常有香客問我帽揪,道長,這世上最難降的妖魔是什么辅斟? 我笑而不...
    開封第一講書人閱讀 58,380評論 1 293
  • 正文 為了忘掉前任转晰,我火速辦了婚禮,結(jié)果婚禮上士飒,老公的妹妹穿的比我還像新娘查邢。我一直安慰自己,他們只是感情好酵幕,可當(dāng)我...
    茶點(diǎn)故事閱讀 67,432評論 6 392
  • 文/花漫 我一把揭開白布扰藕。 她就那樣靜靜地躺著,像睡著了一般芳撒。 火紅的嫁衣襯著肌膚如雪邓深。 梳的紋絲不亂的頭發(fā)上,一...
    開封第一講書人閱讀 51,301評論 1 301
  • 那天笔刹,我揣著相機(jī)與錄音芥备,去河邊找鬼。 笑死舌菜,一個胖子當(dāng)著我的面吹牛萌壳,可吹牛的內(nèi)容都是我干的。 我是一名探鬼主播,決...
    沈念sama閱讀 40,145評論 3 418
  • 文/蒼蘭香墨 我猛地睜開眼袱瓮,長吁一口氣:“原來是場噩夢啊……” “哼缤骨!你這毒婦竟也來了?” 一聲冷哼從身側(cè)響起尺借,我...
    開封第一講書人閱讀 39,008評論 0 276
  • 序言:老撾萬榮一對情侶失蹤绊起,失蹤者是張志新(化名)和其女友劉穎,沒想到半個月后褐望,有當(dāng)?shù)厝嗽跇淞掷锇l(fā)現(xiàn)了一具尸體勒庄,經(jīng)...
    沈念sama閱讀 45,443評論 1 314
  • 正文 獨(dú)居荒郊野嶺守林人離奇死亡,尸身上長有42處帶血的膿包…… 初始之章·張勛 以下內(nèi)容為張勛視角 年9月15日...
    茶點(diǎn)故事閱讀 37,649評論 3 334
  • 正文 我和宋清朗相戀三年瘫里,在試婚紗的時候發(fā)現(xiàn)自己被綠了实蔽。 大學(xué)時的朋友給我發(fā)了我未婚夫和他白月光在一起吃飯的照片。...
    茶點(diǎn)故事閱讀 39,795評論 1 347
  • 序言:一個原本活蹦亂跳的男人離奇死亡谨读,死狀恐怖局装,靈堂內(nèi)的尸體忽然破棺而出,到底是詐尸還是另有隱情劳殖,我是刑警寧澤铐尚,帶...
    沈念sama閱讀 35,501評論 5 345
  • 正文 年R本政府宣布,位于F島的核電站哆姻,受9級特大地震影響宣增,放射性物質(zhì)發(fā)生泄漏。R本人自食惡果不足惜矛缨,卻給世界環(huán)境...
    茶點(diǎn)故事閱讀 41,119評論 3 328
  • 文/蒙蒙 一爹脾、第九天 我趴在偏房一處隱蔽的房頂上張望。 院中可真熱鬧箕昭,春花似錦灵妨、人聲如沸。這莊子的主人今日做“春日...
    開封第一講書人閱讀 31,731評論 0 22
  • 文/蒼蘭香墨 我抬頭看了看天上的太陽。三九已至述召,卻和暖如春朱转,著一層夾襖步出監(jiān)牢的瞬間,已是汗流浹背积暖。 一陣腳步聲響...
    開封第一講書人閱讀 32,865評論 1 269
  • 我被黑心中介騙來泰國打工肋拔, 沒想到剛下飛機(jī)就差點(diǎn)兒被人妖公主榨干…… 1. 我叫王不留,地道東北人呀酸。 一個月前我還...
    沈念sama閱讀 47,899評論 2 370
  • 正文 我出身青樓凉蜂,卻偏偏與公主長得像,于是被迫代替她去往敵國和親。 傳聞我的和親對象是個殘疾皇子窿吩,可洞房花燭夜當(dāng)晚...
    茶點(diǎn)故事閱讀 44,724評論 2 354

推薦閱讀更多精彩內(nèi)容