1. 程式人生 > >Python 之父的解析器系列之五:左遞迴 PEG 語法

Python 之父的解析器系列之五:左遞迴 PEG 語法

原題 | Left-recursive PEG grammars

作者 | Guido van Rossum(Python之父)

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

宣告 | 本翻譯是出於交流學習的目的,基於 CC BY-NC-SA 4.0 授權協議。為便於閱讀,內容略有改動。

我曾幾次提及左遞迴是一塊絆腳石,是時候去解決它了。基本的問題在於:使用遞迴下降解析器時,左遞迴會因堆疊溢位而導致程式終止。

【這是我的 PEG 系列的第 5 部分。其它文章參見這個目錄】

假設有如下的語法規則:

expr: expr '+' term | term

如果我們天真地將它翻譯成遞迴下降解析器的片段,會得到如下內容:

def expr():
    if expr() and expect('+') and term():
        return True
    if term():
        return True
    return False

也就是expr() 以呼叫expr() 開始,後者也以呼叫expr() 開始,以此類推……這隻能以堆疊溢位而結束,丟擲異常RecursionError

傳統的補救措施是重寫語法。在之前的文章中,我已經這樣做了。事實上,上面的語法也能識別出來,如果我們重寫成這樣:

expr: term '+' expr | term

但是,如果我們用它生成一個解析樹,那麼解析樹的形狀會有所不同,這會導致破壞性的後果,比如當我們在語法中新增一個'-'

運算子時(因為a - (b - c)(a - b) - c 不一樣)。

這通常可以使用更強大的 PEG 特性來解決,例如分組和迭代,我們可以將上述規則重寫為:

expr: term ('+' term)*

實際上,這正是 Python 當前語法在 pgen 解析器生成器上的寫法(pgen 與左遞迴規則具有同樣的問題)。

但是這仍然存在一些問題:因為像'+''-' 這樣的運算子,基本上是二進位制的(在 Python 中),當我們解析像a + b + c 這樣的東西時,我們必須遍歷解析的結果(基本上是列表['a','+','b','+','c'] ),以構造一個左遞迴的解析樹(類似於 [['a','+','b'] ,'+','c'] )。

原始的左遞迴語法已經表訴了所需的關聯性,因此,如果我們可以直接以該形式生成解析器,那將會很好。我們可以!一位粉絲向我指出了一個很好的技巧,還附帶了一個數學證明,很容易實現。我會試著在這裡解釋一下。

讓我們考慮輸入foo + bar + baz 作為示例。我們想要解析出的解析樹對應於(foo + bar)+ baz 。這需要對expr() 進行三次左遞迴呼叫:一次對應於頂級的“+” 運算子(即第二個); 一次對應於內部的“+”運算子(即第一個); 還有一次是選擇第二個備選項(即term )。

由於我不善於使用計算機繪製實際的圖表,因此我將在此使用 ASCII 技巧作演示:

expr------------+------+
  |              \      \
expr--+------+   '+'   term
  |    \      \          |
expr   '+'   term        |
  |            |         |
term           |         |
  |            |         |
'foo'        'bar'     'baz'

我們的想法是希望在 expr() 函式中有一個“oracle”(譯註:預言、神諭,後面就不譯了),它要麼告訴我們採用第一個備選項(即遞迴呼叫 expr()),要麼是第二個(即呼叫 term())。在第一次呼叫 expr() 時,“oracle”應該返回 true; 在第二次(遞迴)呼叫時,它也應該返回 true,但在第三次呼叫時,它應該返回 false,以便我們可以呼叫 term()。

在程式碼中,應該是這樣:

def expr():
    if oracle() and expr() and expect('+') and term():
        return True
    if term():
        return True
    return False

我們該怎麼寫這樣的“oracle”呢?試試看吧......我們可以嘗試記錄在呼叫堆疊上的 expr() 的(左遞迴)呼叫次數,並將其與下面表示式中“+” 運算子的數量進行比較。如果呼叫堆疊的深度大於運算子的數量,則應該返回 false。

我幾乎想用sys._getframe() 來實現它,但有更好的方法:讓我們反轉呼叫的堆疊!

這裡的想法是我們從 oracle 返回 false 處呼叫,並儲存結果。這就有了expr()->term()->'foo' 。(它應該返回初始的term 的解析樹,即'foo' 。上面的程式碼僅返回 True,但在本系列第二篇文章中,我已經演示瞭如何返回一個解析樹。)很容易編寫一個 oracle 來實現,它應該在首次呼叫時就返回 false——不需要檢查堆疊或向前回看。

然後我們再次呼叫expr() ,這時 oracle 會返回 true,但是我們不對 expr() 進行左遞迴呼叫,而是用前一次呼叫時儲存的結果來替換。瞧吶,預期的'+' 運算子及隨後的term 也出現了,所以我們將會得到foo + bar

我們重複這個過程,然後事情看起來又很清晰了:這次我們會得到完整表示式的解析樹,並且它是正確的左遞迴((foo + bar)+ baz )。

然後我們再次重複該過程,這一次,oracle 返回 true,並且前一次呼叫時儲存的結果可用,沒有下一步的'+' 運算子,並且第一個備選項失效。所以我們嘗試第二個備選項,它會成功,正好找到了初始的 term('foo')。與之前的呼叫相比,這是一個糟糕的結果,所以在這裡我們停止並留下最長的解析(即(foo + bar)+ baz )。

為了將其轉換為實際的工作程式碼,我首先要稍微重寫程式碼,以將 oracle() 的呼叫與左遞迴的 expr() 呼叫相結合。我們稱之為oracle_expr() 。程式碼:

def expr():
    if oracle_expr() and expect('+') and term():
        return True
    if term():
        return True
    return False

接著,我們將編寫一個實現上述邏輯的裝飾器。它使用了一個全域性變數(不用擔心,我稍後會改掉它)。oracle_expr() 函式將讀取該全域性變數,而裝飾器操縱著它:

saved_result = None
def oracle_expr():
    if saved_result is None:
        return False
    return saved_result
def expr_wrapper():
    global saved_result
    saved_result = None
    parsed_length = 0
    while True:
        new_result = expr()
        if not new_result:
            break
        new_parsed_length = <calculate size of new_result>
        if new_parsed_length <= parsed_length:
            break
        saved_result = new_result
        parsed_length = new_parsed_length
    return saved_result

這過程當然是可悲的,但它展示了程式碼的要點,所以讓我們嘗試一下,將它發展成我們可以引以為傲的東西。

決定性的洞察(這是我自己的,雖然我可能不是第一個想到的)是我們可以使用記憶快取而不是全域性變數,將一次呼叫的結果儲存到下一次,然後我們不需要額外的oracle_expr() 函式——我們可以生成對 expr() 的標準呼叫,無論它是否處於左遞迴的位置。

為了做到這點,我們需要一個單獨的 @memoize_left_rec 裝飾器,它只用於左遞迴規則。它通過將儲存的值從記憶快取中取出,充當了 oracle_expr() 函式的角色,並且它包含著一個迴圈呼叫,只要每個新結果所覆蓋的部分比前一個長,就反覆地呼叫 expr()。

當然,因為記憶快取分別按輸入位置和每個解析方法來處理快取,所以它不受回溯或多個遞迴規則的影響(例如,在玩具語法中,我一直使用 expr 和 term 都是左遞迴的)。

我在第 3 篇文章中建立的基礎結構的另一個不錯的屬性是它更容易檢查新結果是否長於舊結果:mark() 方法將索引返回到輸入的標記符陣列中,因此我們可以使用它,而非上面的parsed_length 。

我沒有證明為什麼這個演算法總是有效的,不管這個語法有多瘋狂。那是因為我實際上沒有讀過那個證明。我看到它適用於玩具語法中的 expr 等簡單情況,也適用於更復雜的情況(例如,涉及一個備選項裡可選條目背後藏著的左遞迴,或涉及多個規則之間的相互遞迴),但在 Python 的語法中,我能想到的最複雜的情況仍然相當溫和,所以我可以信任於定理和證明它的人。

所以讓我們堅持幹,並展示一些真實的程式碼。

首先,解析器生成器必須檢測哪些規則是左遞迴的。這是圖論中一個已解決的問題。我不會在這裡展示演算法,事實上我將進一步簡化工作,並假設語法中唯一的左遞迴規則就是直接左遞迴的,就像我們的玩具語法中的 expr 一樣。然後檢查左遞迴只需要查詢以當前規則名稱開頭的備選項。我們可以這樣寫:

def is_left_recursive(rule):
    for alt in rule.alts:
        if alt[0] == rule.name:
            return True
    return False

現在我們修改解析器生成器,以便對於左遞迴規則,它能生成一個不同的裝飾器。回想一下,在第 3 篇文章中,我們使用 @memoize 修飾了所有的解析方法。我們現在對生成器進行一個小小的修改,對於左遞迴規則,我們替換成 @memoize_left_rec ,然後我們在memoize_left_rec 裝飾器中變魔術。生成器的其餘部分和支援程式碼無需更改!(然而我不得不在視覺化程式碼中搗鼓一下。)

作為參考,這裡是原始的 @memoize 裝飾器,從第 3 篇中複製而來。請注意,self 是一個Parser 例項,它具有 memo 屬性(用空字典初始化)、mark() 和 reset() 方法,用於獲取和設定 tokenizer 的當前位置:

def memoize(func):
    def memoize_wrapper(self, *args):
        pos = self.mark()
        memo = self.memos.get(pos)
        if memo is None:
            memo = self.memos[pos] = {}
        
        key = (func, args)
        if key in memo:
            res, endpos = memo[key]
            self.reset(endpos)
        else:
            res = func(self, *args)
            endpos = self.mark()
            memo[key] = res, endpos
        return res
    return memoize_wrapper

@memoize 裝飾器在每個輸入位置記住了前一呼叫——在輸入標記符的(惰性)陣列的每個位置,有一個單獨的memo 字典。memoize_wrapper 函式的前四行與獲取正確的memo 字典有關。

這是 @memoize_left_rec 。只有 else 分支與上面的 @memoize 不同:

    def memoize_left_rec(func):
    def memoize_left_rec_wrapper(self, *args):
        pos = self.mark()
        memo = self.memos.get(pos)
        if memo is None:
            memo = self.memos[pos] = {}
        key = (func, args)
        if key in memo:
            res, endpos = memo[key]
            self.reset(endpos)
        else:
            # Prime the cache with a failure.
            memo[key] = lastres, lastpos = None, pos
            # Loop until no longer parse is obtained.
            while True:
                self.reset(pos)
                res = func(self, *args)
                endpos = self.mark()
                if endpos <= lastpos:
                    break
                memo[key] = lastres, lastpos = res, endpos
            res = lastres
            self.reset(lastpos)
        return res
    return memoize_left_rec_wrapper

它很可能有助於顯示生成的 expr() 方法,因此我們可以跟蹤裝飾器和裝飾方法之間的流程:

    @memoize_left_rec 
    def expr(self):
        pos = self.mark()
        if ((expr := self.expr()) and
            self.expect('+') and
            (term := self.term())):
            return Node('expr', [expr, term])
        self.reset(pos)
        if term := self.term():
            return Node('term', [term])
        self.reset(pos)
        return None

讓我們試著解析 foo + bar + baz

每當你呼叫被裝飾的 expr() 函式時,裝飾器就會“攔截”呼叫,它會在當前位置查詢前一個呼叫。在第一個呼叫處,它會進入 else 分支,在那裡它重複地呼叫未裝飾的函式。當未裝飾的函式呼叫 expr() 時,這當然指向了被裝飾的版本,因此這個遞迴呼叫會再次被截獲。遞迴在這裡停止,因為現在 memo 快取有了命中。

接下來呢?初始的快取值來自這行:

            # Prime the cache with a failure.
            memo[key] = lastres, lastpos = None, pos

這使得被裝飾的 expr() 返回 None,在那 expr() 裡的第一個 if 會失敗(在expr := self.expr() )。所以我們繼續到第二個 if,它成功識別了一個 term(在我們的例子中是 ‘foo’),expr 返回一個 Node 例項。它返回到了哪裡?到了裝飾器裡的 while 迴圈。這新的結果會更新 memo 快取(那個 node 例項),然後開始下一個迭代。

再次呼叫未裝飾的 expr(),這次截獲的遞迴呼叫返回新快取的 Node 例項(一個 term)。這是成功的,呼叫繼續到 expect('+')。這再次成功,然後我們現在處於第一個“+” 操作符。在此之後,我們要查詢一個 term,也成功了(找到 'bar')。

所以對於空的 expr(),目前已識別出 foo + bar ,回到 while 迴圈,還會經歷相同的過程:用新的(更長的)結果來更新 memo 快取,並開啟下一輪迭代。

遊戲再次上演。被截獲的遞迴 expr() 呼叫再次從快取中檢索新的結果(這次是 foo + bar),我們期望並找到另一個 ‘+’(第二個)和另一個 term(‘baz’)。我們構造一個 Node 表示 (foo + bar) + baz ,並返回給 while 迴圈,後者將它填充進 memo 快取,並再次迭代。

但下一次事情會有所不同。有了新的結果,我們查詢另一個 '+' ,但沒有找到!所以這個expr() 呼叫會回到它的第二個備選項,並返回一個可憐的 term。當走到 while 迴圈時,它失望地發現這個結果比最後一個短,就中斷了,將更長的結果((foo + bar)+ baz )返回給原始呼叫,就是初始化了外部 expr() 呼叫的地方(例如,一個 statement() 呼叫——此處未展示)。

到此,今天的故事結束了:我們已經成功地在 PEG(-ish)解析器中馴服了左遞迴。至於下週,我打算論述在語法中新增“動作”(actions),這樣我們就可以為一個給定的備選項的解析方法,自定義它返回的結果(而不是總要返回一個 Node 例項)。

如果你想使用程式碼,請參閱GitHub倉庫。(我還為左遞迴添加了視覺化程式碼,但我並不特別滿意,所以不打算在這裡給出連結。)

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

作者簡介: Guido van Rossum,Python 的創造者,一直是“終身仁慈獨裁者”,直到 2018 年 7 月 12 日退位。目前,他是新的最高決策層的五位成員之一,依然活躍在社群中。本文出自他在 Medium 開部落格所寫的解析器系列,該系列仍在連載中,每週日更新。

譯者簡介: 豌豆花下貓,生於廣東畢業於武大,現為蘇漂程式設計師,有一些極客思維,也有一些人文情懷,有一些溫度,還有一些態度。公眾號:「Python貓」(python_cat)。

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