Python 之父再發文:構建一個 PEG 解析器

来源:https://www.cnblogs.com/pythonista/archive/2019/08/03/11296229.html
-Advertisement-
Play Games

花下貓語: Python 之父在 Medium 上開了博客,現在寫了兩篇文章,本文是第二篇的譯文。前一篇的譯文 "在此" ,宣佈了將要用 PEG 解析器來替換當前的 pgen 解析器。 本文主要介紹了構建一個 PEG 解析器的大體思路,並介紹了一些基本的語法規則。根據 Python 之父的描述,這個 ...


花下貓語: Python 之父在 Medium 上開了博客,現在寫了兩篇文章,本文是第二篇的譯文。前一篇的譯文 在此 ,宣佈了將要用 PEG 解析器來替換當前的 pgen 解析器。

本文主要介紹了構建一個 PEG 解析器的大體思路,並介紹了一些基本的語法規則。根據 Python 之父的描述,這個 PEG 解析器還是一個很籠統的實驗品,而他也預告了,將會在以後的系列文章中豐富這個解析器。

閱讀這篇文章就像在讀一篇教程,雖然很難看懂,但是感覺很奇妙:我們竟然可以見證 Python 之父如何考慮問題、如何作設計、如何一點一點地豐富功能、並且傳授出來。這種機會非常難得啊!

我會持續跟進後續文章的翻譯,由於能力有限,可能翻譯中有不到位之處,懇請讀者們批評指正。

本文原創並首發於公眾號【Python貓】,未經授權,請勿轉載。

原文地址:https://mp.weixin.qq.com/s/yUQPeqc_uSRGe5lUi50kVQ


原題 | Building a PEG Parser

作者 | Guido van Rossum(Python之父)

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

原文 | https://medium.com/@gvanrossum_83706/building-a-peg-parser-d4869b5958fb

聲明 | 翻譯是出於交流學習的目的,歡迎轉載,但請保留本文出處,請勿用於商業或非法用途。

僅僅理解了 PEG 解析器的小部分,我就受到了啟發,決定自己構建一個。結果可能不是一個很棒的通用型的 PEG 解析器生成器——這類生成器已經有很多了(例如 TatSu,寫於 Python,生成 Python 代碼)——但這是一個學習 PEG 的好辦法,推進了我的目標,即用由 PEG 語法構建的解析器替換 CPython 的解析器。

在本文中,通過展示一個簡單的手寫解析器,我為如何理解解析器的工作原理奠定了基礎。

(順便說一句,作為一個實驗,我不會在文中到處放參考鏈接。如果你有什麼不明白的東西,請 Google 之 :-)

最常見的 PEG 解析方式是使用可以無限回溯的遞歸下降解析器。

以上周文章中的玩具語言為例:

statement: assignment | expr | if_statement
expr: expr '+' term | expr '-' term | term
term: term '*' atom | term '/' atom | atom
atom: NAME | NUMBER | '(' expr ')'
assignment: target '=' expr
target: NAME
if_statement: 'if' expr ':' statement

這種語言中超級抽象的遞歸下降解析器將為每個符號定義一個函數,該函數會嘗試調用與備選項相對應的函數。

例如,對於statement,我們有如下函數:

def statement():
    if assignment():
        return True
   if expr():
        return True
    if if_statement():
        return True
    return False

當然這是極其簡化的版本:沒有考慮解析器中必要的輸入及輸出。

我們就從輸入端開始講吧。

經典解析器使用單獨的標記生成器,來將輸入(文本文件或字元串)分解成一系列的標記,例如關鍵字、標識符(名稱)、數字與運算符。

(譯註:標記生成器,即 tokenizer,用於生成標記 token。以下簡稱為“標記器”)

PEG 解析器(像其它現代解析器,如 ANTLR)通常會把標記與解析過程統一。但是對於我的項目,我選擇保留單獨的標記器。

對 Python 做標記太複雜了,我不想拘泥於 PEG 的形式來重新實現。

例如,你必須得記錄縮進(這需要在標記器內使用堆棧),而且在 Python 中處理換行很有趣(它們很重要,除了在匹配的括弧內)。字元串的多種引號也會增加複雜性。

簡而言之,我不抱怨 Python 現有的標記器,所以我想保留它。(CPython 有兩個標記器,一個是解析器在內部使用的,寫於 C,另一個在標準庫中,用純 Python 重寫。它對我的項目很有幫助。)

經典的標記器通常具有一個簡單的介面,供你作函數調用,例如 get_token() ,它返回輸入內容中的下一個標記,每次消費掉幾個字元。

tokenize 模塊對它作了進一步簡化:它的基礎 API 是一個生成器,每次生成(yield)一個標記。

每個標記都是一個 TypeInfo 對象,它有幾個欄位,其中最重要之一表示的是標記的類型(例如 NAMENUMBERSTRING),還有一個很重要的是字元串值,表示該標記所包含的字元(例如 abc42 或者 "hello world")。還有的欄位會指明每個標記出現在輸入文件中的坐標,這對於報告錯誤很有用。

有一個特殊的標記類型是 ENDMARKER ,它表示的是抵達了輸入文件的末尾。如果你忽略它,並嘗試獲取下一個標記,則生成器會終結。

離題了,回歸正題。我們如何實現無限回溯呢?

回溯要求你能記住源碼中的位置,並且能夠從該處重新解析。標記器的 API 不允許我們重置它的輸入指針,但相對容易的是,將標記流裝入一個數組中,併在那裡做指針重置,所以我們就這樣做。(你同樣可以使用 itertools.tee() 來做,但是根據文檔中的警告,在我們這種情況下,效率可能較低。)

我猜你可能會先將整個輸入內容標記到一個 Python 列表裡,將其作為解析器的輸入,但這意味著如果在文件末尾處存在著無效的標記(例如一個字元串缺少結束的引號),而在文件前面還有語法錯誤,那你首先會收到的是關於標記錯誤的信息。

我覺得這是種糟糕的用戶體驗,因為這個語法錯誤有可能是導致字元串殘缺的根本原因。

所以我的設計是按需標記,所用的列表是惰性列表。

基礎 API 非常簡單。Tokenizer 對象封裝了一個數組,存放標記及其位置信息。

它有三個基本方法:

  • get_token() 返回下一個標記,並推進數組的索引(如果到了數組末尾,則從源碼中讀取另一個標記)
  • mark() 返回數組的當前索引
  • reset(pos) 設置數組的索引(參數必須從 mark() 方法中得到)

我們再補充一個便利方法 peek_token() ,它返回下一個標記且不推進索引。

然後,這就成了 Tokenizer 類的核心代碼:

class Tokenizer:
    def __init__(self, tokengen):
        """Call with tokenize.generate_tokens(...)."""
        self.tokengen = tokengen
        self.tokens = []
        self.pos = 0
    def mark(self):
        return self.pos
    def reset(self, pos):
        self.pos = pos
    def get_token(self):
        token = self.peek_token()
        self.pos += 1
        return token
    def peek_token(self):
        if self.pos == len(self.tokens):
            self.tokens.append(next(self.tokengen))
        return self.tokens[self.pos]

現在,仍然缺失著很多東西(而且方法和實例變數的名稱應該以下劃線開頭),但這作為 Tokenizer API 的初稿已經夠了。

解析器也需要變成一個類,以便可以擁有 statement()、expr() 和其它方法。

標記器則變成一個實例變數,不過我們不希望解析方法(parsing methods)直接調用 get_token()——相反,我們給 Parser 類一個 expect() 方法,它可以像解析類方法一樣,表示執行成功或失敗。

expect() 的參數是一個預期的標記——一個字元串(像“+”)或者一個標記類型(像NAME)。

討論完瞭解析器的輸出,我繼續講返回類型(return type)。

在我初稿的解析器中,解析函數只返回 True 或 False。那對於理論電腦科學來說是好的(解析器要解答的那類問題是“語言中的這個是否是有效的字元串?”),但是對於構建解析器卻不是——相反,我們希望用解析器來創建一個 AST。

所以我們就這麼辦,即讓每個解析方法在成功時返回 Node 對象,在失敗時返回 None

Node 類可以超級簡單:

class Node:
    def __init__(self, type, children):
        self.type = type
        self.children = children

在這裡,type 表示了該 AST 節點是什麼類型(例如是個“add”節點或者“if”節點),children 表示了一些節點和標記(TokenInfo 類的實例)。

儘管將來我可能會改變表示 AST 的方式,但這足以讓編譯器生成代碼或對其作分析了,例如 linting (譯註:不懂)或者是靜態類型檢查。

為了適應這個方案,expect() 方法在成功時會返回一個 TokenInfo 對象,在失敗時返回 None。為了支持回溯,我還封裝了標記器的 mark() 和 reset() 方法(不改變 API)。

這是 Parser 類的基礎結構:

class Parser:
    def __init__(self, tokenizer):
        self.tokenizer = tokenizer
    def mark(self):
        return self.tokenizer.mark()
    def reset(self, pos):
        self.tokenizer.reset(pos)
    def expect(self, arg):
        token = self.tokenizer.peek_token()
        if token.type == arg or token.string == arg:
            return self.tokenizer.get_token()
        return None

同樣地,我放棄了某些細節,但它可以工作。

在這裡,我有必要介紹解析方法的一個重要的需求:一個解析方法要麼返回一個 Node,並將標記器定位到它能識別的語法規則的最後一個標記之後;要麼返回 None,然後保持標記器的位置不變。

如果解析方法在讀取了多個標記之後失敗了,則它必須重置標記器的位置。這就是 mark() 與 reset() 的用途。請註意,expect() 也遵循此規則。

所以解析器的實際草稿如下。請註意,我使用了 Python 3.8 的海象運算符(:=):

class ToyParser(Parser):
    def statement(self):
        if a := self.assignment():
            return a
        if e := self.expr():
            return e
        if i := self.if_statement():
            return i
        return None
    def expr(self):
        if t := self.term():
            pos = self.mark()
            if op := self.expect("+"):
                if e := self.expr():
                    return Node("add", [t, e])
            self.reset(pos)
            if op := self.expect("-"):
                if e := self.expr():
                    return Node("sub", [t, e])
            self.reset(pos)
            return t
        return None
    def term(self):
        # Very similar...
    def atom(self):
        if token := self.expect(NAME):
            return token
        if token := self.expect(NUMBER):
            return token
        pos = self.mark()
        if self.expect("("):
            if e := self.expr():
                if self.expect(")"):
                    return e
        self.reset(pos)
        return None

我給讀者們留了一些解析方法作為練習(這實際上不僅僅是為了介紹解析器長什麼樣子),最終我們將像這樣從語法中自動地生成代碼。

NAME 和 NUMBER 等常量可從標準庫的 token 庫中導入。(這能令我們快速地進入 Python 的標記過程;但如果想要構建一個更加通用的 PEG 解析器,則應該探索一些其它方法。)

我還作了個小弊:expr 是左遞歸的,但我的解析器用了右遞歸,因為遞歸下降解析器不適用於左遞歸的語法規則。

有一個解決方案,但它還只是一些學術研究上的課題,我想以後單獨介紹它。你們只需知道,修複的版本與這個玩具語法並非 100% 相符。

我希望你們得到的關鍵信息是:

  • 語法規則相當於解析器方法,當一條語法規則引用另一條語法規則時,它的解析方法會調用另一條規則的解析方法
  • 當多個條目構成備選項時,解析方法會一個接一個地調用相應的方法
  • 當一條語法規則引用一個標記時,其解析方法會調用 expect()
  • 當一個解析方法在給定的輸入位置成功地識別了它的語法規則時,它返回相應的 AST 節點;當識別失敗時,它返回 None
  • 一個解析方法在消費(consum)一個或多個標記(直接或間接地,通過調用另一個成功的解析方法)後放棄解析時,必須顯式地重置標記器的位置。這適用於放棄一個備選項而嘗試下一個,也適用於完全地放棄解析

如果所有的解析方法都遵守這些規則,則不必在單個解析方法中使用 mark() 和 reset()。你可以用歸納法證明這一點。

順便提醒,雖然使用上下文管理器和 with 語句來替代顯式地調用 mark() 與 reset() 很有誘惑力,但這不管用:在成功時不應調用 reset()!

為了修複它,你可以在控制流中使用異常,這樣上下文管理器就知道是否該重置標記器(我認為 TatSu 做了類似的東西)。

舉例,你可以這樣做:

    def statement(self):
        with self.alt():
            return self.assignment()
        with self.alt():
            return self.expr()
        with self.alt():
            return self.if_statement()
        raise ParsingFailure

特別地,atom() 中用來識別帶括弧的表達式的 if-語句,可以變成:

        with self.alt():
            self.expect("(")
            e = self.expr()
            self.expect(")")
            return e

但我發現這太“神奇”了——在閱讀這些代碼時,你必須清醒地意識到每個解析方法(以及 expect())都可能會引發異常,而這個異常會被 with 語句的上下文管理器捕獲並忽略掉。

這相當不尋常,儘管肯定會支持(通過從 __exit__ 返回 true)。

還有,我的最終目標是生成 C,不是 Python,而在 C 里,沒有 with 語句來改變控制流。

不管怎樣,下麵是未來的一些主題:

  • 根據語法生成解析代碼
  • packrat 解析(記憶法)
  • EBNF 的特性,如(x | y)、[x y ...]、x* 、x+
  • tracing (用於調試解析器或語法)
  • PEG 特性,如前瞻和“切割”
  • 如何處理左遞歸規則
  • 生成 C 代碼

相關鏈接:

1、PEG解析器(考慮替換現有解析器)

2、pgen解析器(現有解析器的由來)

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


您的分享是我們最大的動力!

-Advertisement-
Play Games
更多相關文章
  • 迭代器 迭代器可以用來遍歷字元串、列表、元組、集合、字典。 可以使用next()獲取下一個元素: 錯誤、異常處理 except語句 ecxcept語句用來捕獲、處理錯誤、異常。 as e as是關鍵字,e是e是捕獲的異常實例(對象),可以自己隨便取名。 如果異常處理中用不到捕獲的異常對象,可以不要a ...
  • Java 學習 day01 java的三大技術架構 Javase:java標準版,該體系的知識點主要是學習java基礎的知識點, 主要用於桌面應用軟體的開發。比如計算器,QQ軟體等。==市場上幾乎沒有人使用java去開發桌面應用程式,因為java在創立的時候定位該門語言是面向互聯網的一門語言。Jav ...
  • BeanFactory是Spring中非常重要的一個類,搞懂了它,你就知道了bean的初始化和摧毀過程,對於深入理解IOC有很大的幫助。 BeanFactory體繫結構 首先看一下使用IDEA生成的繼承層次圖(圖中去掉了ApplicationContext的繼承圖): 可以看到 下的介面主要分為三個 ...
  • Ural 1298 Knight 題解 [TOC] 題意 給定一個$n\times n(1\le n\le8)$的國際象棋棋盤和一個騎士(基本上相當於中國象棋的馬),問可否用經過每個格子$1$次。如果可以,輸出路徑,否則輸出 。 題解 考慮回溯。暴力程式十分好寫,但是會超時。 可以用啟髮式優化。 設 ...
  • 原文地址:https://yq.aliyun.com/articles/8611(肥俠)著作權歸原作者是所有。 概述 關於微服務的介紹,可以參考微服務那點事。 微服務是最近非常火熱的新概念,大家都在追,也都覺得很對,但是似乎沒有很充足的理論基礎說明這是正確的,給人的感覺是 不明覺厲 。前段時間看了M ...
  • 1、多態中成員的特點: 1:成員變數: 編譯時期看父類,運行結果看父類 2:成員方法: 編譯時期看父類,運行結果看子類(子類把方法重寫了) 3:靜態方法: 編譯時期看父類,運行結果看父類 2.Object:根類,超類,對所有對象共性的提取,所有 任何類,如果沒有書寫extends顯示繼承某個類,都默 ...
  • 這裡用了float類型 公雞x、母雞y、小雞z共100只 錢:5x + 3y + 1/3z = 100 求x,y,z 代碼在codeblocks17.12運行的結果為 0 25 754 18 788 11 8112 4 84 #include <iostream>#include <cmath> u ...
  • 警告⚠️:本文耗時很長,先做好心理準備,建議PC端瀏覽器瀏覽效果更佳。 本篇我們講通過大量實例代碼及hotspot源碼分析偏向鎖(批量重偏向、批量撤銷)、輕量級鎖、重量級鎖及鎖的膨脹過程(也就是鎖的升級過程) 我們先來說一下我們為什麼需要鎖? 因為在併發情況為了保證線程的安全性,是在一個多線程環境下 ...
一周排行
    -Advertisement-
    Play Games
  • 移動開發(一):使用.NET MAUI開發第一個安卓APP 對於工作多年的C#程式員來說,近來想嘗試開發一款安卓APP,考慮了很久最終選擇使用.NET MAUI這個微軟官方的框架來嘗試體驗開發安卓APP,畢竟是使用Visual Studio開發工具,使用起來也比較的順手,結合微軟官方的教程進行了安卓 ...
  • 前言 QuestPDF 是一個開源 .NET 庫,用於生成 PDF 文檔。使用了C# Fluent API方式可簡化開發、減少錯誤並提高工作效率。利用它可以輕鬆生成 PDF 報告、發票、導出文件等。 項目介紹 QuestPDF 是一個革命性的開源 .NET 庫,它徹底改變了我們生成 PDF 文檔的方 ...
  • 項目地址 項目後端地址: https://github.com/ZyPLJ/ZYTteeHole 項目前端頁面地址: ZyPLJ/TreeHoleVue (github.com) https://github.com/ZyPLJ/TreeHoleVue 目前項目測試訪問地址: http://tree ...
  • 話不多說,直接開乾 一.下載 1.官方鏈接下載: https://www.microsoft.com/zh-cn/sql-server/sql-server-downloads 2.在下載目錄中找到下麵這個小的安裝包 SQL2022-SSEI-Dev.exe,運行開始下載SQL server; 二. ...
  • 前言 隨著物聯網(IoT)技術的迅猛發展,MQTT(消息隊列遙測傳輸)協議憑藉其輕量級和高效性,已成為眾多物聯網應用的首選通信標準。 MQTTnet 作為一個高性能的 .NET 開源庫,為 .NET 平臺上的 MQTT 客戶端與伺服器開發提供了強大的支持。 本文將全面介紹 MQTTnet 的核心功能 ...
  • Serilog支持多種接收器用於日誌存儲,增強器用於添加屬性,LogContext管理動態屬性,支持多種輸出格式包括純文本、JSON及ExpressionTemplate。還提供了自定義格式化選項,適用於不同需求。 ...
  • 目錄簡介獲取 HTML 文檔解析 HTML 文檔測試參考文章 簡介 動態內容網站使用 JavaScript 腳本動態檢索和渲染數據,爬取信息時需要模擬瀏覽器行為,否則獲取到的源碼基本是空的。 本文使用的爬取步驟如下: 使用 Selenium 獲取渲染後的 HTML 文檔 使用 HtmlAgility ...
  • 1.前言 什麼是熱更新 游戲或者軟體更新時,無需重新下載客戶端進行安裝,而是在應用程式啟動的情況下,在內部進行資源或者代碼更新 Unity目前常用熱更新解決方案 HybridCLR,Xlua,ILRuntime等 Unity目前常用資源管理解決方案 AssetBundles,Addressable, ...
  • 本文章主要是在C# ASP.NET Core Web API框架實現向手機發送驗證碼簡訊功能。這裡我選擇是一個互億無線簡訊驗證碼平臺,其實像阿裡雲,騰訊雲上面也可以。 首先我們先去 互億無線 https://www.ihuyi.com/api/sms.html 去註冊一個賬號 註冊完成賬號後,它會送 ...
  • 通過以下方式可以高效,並保證數據同步的可靠性 1.API設計 使用RESTful設計,確保API端點明確,並使用適當的HTTP方法(如POST用於創建,PUT用於更新)。 設計清晰的請求和響應模型,以確保客戶端能夠理解預期格式。 2.數據驗證 在伺服器端進行嚴格的數據驗證,確保接收到的數據符合預期格 ...