用 Python 實現 Python 直譯器

發表於2016-09-10
 Allison 是 Dropbox 的工程師,在那裡她維護著這個世界上最大的 Python 客戶端網路之一。在去 Dropbox 之前,她是 Recurse Center 的協調人, 是這個位於紐約的程式設計師深造機構的作者。她在北美的 PyCon 做過關於 Python 內部機制的演講,並且她喜歡研究奇怪的 bug。她的部落格地址是 akaptur.com

介紹

Byterun 是一個用 Python 實現的 Python 直譯器。隨著我對 Byterun 的開發,我驚喜地的發現,這個 Python 直譯器的基礎結構用 500 行程式碼就能實現。在這一章我們會搞清楚這個直譯器的結構,給你足夠探索下去的背景知識。我們的目標不是向你展示直譯器的每個細節—像程式設計和電腦科學其他有趣的領域一樣,你可能會投入幾年的時間去深入瞭解這個主題。

Byterun 是 Ned Batchelder 和我完成的,建立在 Paul Swartz 的工作之上。它的結構和主要的 Python 實現(CPython)差不多,所以理解 Byterun 會幫助你理解大多數直譯器,特別是 CPython 直譯器。(如果你不知道你用的是什麼 Python,那麼很可能它就是 CPython)。儘管 Byterun 很小,但它能執行大多數簡單的 Python 程式(這一章是基於 Python 3.5 及其之前版本生成的位元組碼的,在 Python 3.6 中生成的位元組碼有一些改變)。

Python 直譯器

在開始之前,讓我們限定一下“Pyhton 直譯器”的意思。在討論 Python 的時候,“直譯器”這個詞可以用在很多不同的地方。有的時候直譯器指的是 Python REPL,即當你在命令列下敲下 python 時所得到的互動式環境。有時候人們會或多或少的互換使用 “Python 直譯器”和“Python”來說明從頭到尾執行 Python 程式碼的這一過程。在本章中,“直譯器”有一個更精確的意思:Python 程式的執行過程中的最後一步。

在直譯器接手之前,Python 會執行其他 3 個步驟:詞法分析,語法解析和編譯。這三步合起來把原始碼轉換成程式碼物件(code object),它包含著直譯器可以理解的指令。而直譯器的工作就是解釋程式碼物件中的指令。

你可能很奇怪執行 Python 程式碼會有編譯這一步。Python 通常被稱為解釋型語言,就像 Ruby,Perl 一樣,它們和像 C,Rust 這樣的編譯型語言相對。然而,這個術語並不是它看起來的那樣精確。大多數解釋型語言包括 Python 在內,確實會有編譯這一步。而 Python 被稱為解釋型的原因是相對於編譯型語言,它在編譯這一步的工作相對較少(直譯器做相對多的工作)。在這章後面你會看到,Python 的編譯器比 C 語言編譯器需要更少的關於程式行為的資訊。

Python 的 Python 直譯器

Byterun 是一個用 Python 寫的 Python 直譯器,這點可能讓你感到奇怪,但沒有比用 C 語言寫 C 語言編譯器更奇怪的了。(事實上,廣泛使用的 gcc 編譯器就是用 C 語言本身寫的)你可以用幾乎任何語言寫一個 Python 直譯器。

用 Python 寫 Python 既有優點又有缺點。最大的缺點就是速度:用 Byterun 執行程式碼要比用 CPython 執行慢的多,CPython 直譯器是用 C 語言實現的,並做了認真優化。然而 Byterun 是為了學習而設計的,所以速度對我們不重要。使用 Python 最大優勢是我們可以僅僅實現直譯器,而不用擔心 Python 執行時部分,特別是物件系統。比如當 Byterun 需要建立一個類時,它就會回退到“真正”的 Python。另外一個優勢是 Byterun 很容易理解,部分原因是它是用人們很容易理解的高階語言寫的(Python !)(另外我們不會對直譯器做優化 —— 再一次,清晰和簡單比速度更重要)

構建一個直譯器

在我們考察 Byterun 程式碼之前,我們需要從高層次對直譯器結構有一些瞭解。Python 直譯器是如何工作的?

Python 直譯器是一個虛擬機器(virtual machine),是一個模擬真實計算機的軟體。我們這個虛擬機器是棧機器(stack machine),它用幾個棧來完成操作(與之相對的是暫存器機器(register machine),它從特定的記憶體地址讀寫資料)。

Python 直譯器是一個位元組碼直譯器(bytecode interpreter):它的輸入是一些稱作位元組碼(bytecode)的指令集。當你寫 Python 程式碼時,詞法分析器、語法解析器和編譯器會生成程式碼物件(code object)讓直譯器去操作。每個程式碼物件都包含一個要被執行的指令集 —— 它就是位元組碼 —— 以及還有一些直譯器需要的資訊。位元組碼是 Python 程式碼的一箇中間層表示( intermediate representation):它以一種直譯器可以理解的方式來表示原始碼。這和組合語言作為 C 語言和機器語言的中間表示很類似。

微型直譯器

為了讓說明更具體,讓我們從一個非常小的直譯器開始。它只能計算兩個數的和,只能理解三個指令。它執行的所有程式碼只是這三個指令的不同組合。下面就是這三個指令:

  • LOAD_VALUE
  • ADD_TWO_VALUES
  • PRINT_ANSWER

我們不關心詞法、語法和編譯,所以我們也不在乎這些指令集是如何產生的。你可以想象,當你寫下 7 + 5,然後一個編譯器為你生成那三個指令的組合。如果你有一個合適的編譯器,你甚至可以用 Lisp 的語法來寫,只要它能生成相同的指令。

假設

生成這樣的指令集:

Python 直譯器是一個棧機器(stack machine),所以它必須通過操作棧來完成這個加法(見下圖)。直譯器先執行第一條指令,LOAD_VALUE,把第一個數壓到棧中。接著它把第二個數也壓到棧中。然後,第三條指令,ADD_TWO_VALUES,先把兩個數從棧中彈出,加起來,再把結果壓入棧中。最後一步,把結果彈出並輸出。

棧機器

LOAD_VALUE這條指令告訴直譯器把一個數壓入棧中,但指令本身並沒有指明這個數是多少。指令需要一個額外的資訊告訴直譯器去哪裡找到這個數。所以我們的指令集有兩個部分:指令本身和一個常量列表。(在 Python 中,位元組碼就是我們所稱的“指令”,而直譯器“執行”的是程式碼物件。)

為什麼不把數字直接嵌入指令之中?想象一下,如果我們加的不是數字,而是字串。我們可不想把字串這樣的東西加到指令中,因為它可以有任意的長度。另外,我們這種設計也意味著我們只需要物件的一份拷貝,比如這個加法 7 + 7, 現在常量表 "numbers"只需包含一個[7]

你可能會想為什麼會需要除了ADD_TWO_VALUES之外的指令。的確,對於我們兩個數加法,這個例子是有點人為製作的意思。然而,這個指令卻是建造更復雜程式的輪子。比如,就我們目前定義的三個指令,只要給出正確的指令組合,我們可以做三個數的加法,或者任意個數的加法。同時,棧提供了一個清晰的方法去跟蹤直譯器的狀態,這為我們增長的複雜性提供了支援。

現在讓我們來完成我們的直譯器。直譯器物件需要一個棧,它可以用一個列表來表示。它還需要一個方法來描述怎樣執行每條指令。比如,LOAD_VALUE會把一個值壓入棧中。

這三個方法完成了直譯器所理解的三條指令。但直譯器還需要一樣東西:一個能把所有東西結合在一起並執行的方法。這個方法就叫做 run_code,它把我們前面定義的字典結構 what-to-execute 作為引數,迴圈執行裡面的每條指令,如果指令有引數就處理引數,然後呼叫直譯器物件中相應的方法。

為了測試,我們建立一個直譯器物件,然後用前面定義的 7 + 5 的指令集來呼叫 run_code

顯然,它會輸出 12。

儘管我們的直譯器功能十分受限,但這個過程幾乎和真正的 Python 直譯器處理加法是一樣的。這裡,我們還有幾點要注意。

首先,一些指令需要引數。在真正的 Python 位元組碼當中,大概有一半的指令有引數。像我們的例子一樣,引數和指令打包在一起。注意指令的引數和傳遞給對應方法的引數是不同的。

第二,指令ADD_TWO_VALUES不需要任何引數,它從直譯器棧中彈出所需的值。這正是以基於棧的直譯器的特點。

記得我們說過只要給出合適的指令集,不需要對直譯器做任何改變,我們就能做多個數的加法。考慮下面的指令集,你覺得會發生什麼?如果你有一個合適的編譯器,什麼程式碼才能編譯出下面的指令集?

從這點出發,我們開始看到這種結構的可擴充套件性:我們可以通過向直譯器物件增加方法來描述更多的操作(只要有一個編譯器能為我們生成組織良好的指令集就行)。

變數

接下來給我們的直譯器增加變數的支援。我們需要一個儲存變數值的指令 STORE_NAME;一個取變數值的指令LOAD_NAME;和一個變數到值的對映關係。目前,我們會忽略名稱空間和作用域,所以我們可以把變數和值的對映直接儲存在直譯器物件中。最後,我們要保證what_to_execute除了一個常量列表,還要有個變數名字的列表。

我們的新的實現在下面。為了跟蹤哪個名字繫結到哪個值,我們在__init__方法中增加一個environment字典。我們也增加了STORE_NAMELOAD_NAME方法,它們獲得變數名,然後從environment字典中設定或取出這個變數值。

現在指令的引數就有兩個不同的意思,它可能是numbers列表的索引,也可能是names列表的索引。直譯器通過檢查所執行的指令就能知道是那種引數。而我們打破這種邏輯 ,把指令和它所用何種引數的對映關係放在另一個單獨的方法中。

僅僅五個指令,run_code這個方法已經開始變得冗長了。如果保持這種結構,那麼每條指令都需要一個if分支。這裡,我們要利用 Python 的動態方法查詢。我們總會給一個稱為FOO的指令定義一個名為FOO的方法,這樣我們就可用 Python 的getattr函式在執行時動態查詢方法,而不用這個大大的分支結構。run_code方法現在是這樣:

真實的 Python 位元組碼

現在,放棄我們的小指令集,去看看真正的 Python 位元組碼。位元組碼的結構和我們的小直譯器的指令集差不多,除了位元組碼用一個位元組而不是一個名字來代表這條指令。為了理解它的結構,我們將考察一個函式的位元組碼。考慮下面這個例子:

Python 在執行時會暴露一大批內部資訊,並且我們可以通過 REPL 直接訪問這些資訊。對於函式物件condcond.__code__是與其關聯的程式碼物件,而cond.__code__.co_code就是它的位元組碼。當你寫 Python 程式碼時,你永遠也不會想直接使用這些屬性,但是這可以讓我們做出各種惡作劇,同時也可以看看內部機制。

當我們直接輸出這個位元組碼,它看起來完全無法理解 —— 唯一我們瞭解的是它是一串位元組。很幸運,我們有一個很強大的工具可以用:Python 標準庫中的dis模組。

dis是一個位元組碼反彙編器。反彙編器以為機器而寫的底層程式碼作為輸入,比如彙編程式碼和位元組碼,然後以人類可讀的方式輸出。當我們執行dis.dis,它輸出每個位元組碼的解釋。

這些都是什麼意思?讓我們以第一條指令LOAD_CONST為例子。第一列的數字(2)表示對應原始碼的行數。第二列的數字是位元組碼的索引,告訴我們指令LOAD_CONST在位置 0 。第三列是指令本身對應的人類可讀的名字。如果第四列存在,它表示指令的引數。如果第五列存在,它是一個關於引數是什麼的提示。

考慮這個位元組碼的前幾個位元組:[100, 1, 0, 125, 0, 0]。這 6 個位元組表示兩條帶引數的指令。我們可以使用dis.opname,一個位元組到可讀字串的對映,來找到指令 100 和指令 125 代表的是什麼:

第二和第三個位元組 —— 1 、0 ——是LOAD_CONST的引數,第五和第六個位元組 —— 0、0 —— 是STORE_FAST的引數。就像我們前面的小例子,LOAD_CONST需要知道的到哪去找常量,STORE_FAST需要知道要儲存的名字。(Python 的LOAD_CONST和我們小例子中的LOAD_VALUE一樣,LOAD_FASTLOAD_NAME一樣)。所以這六個位元組代表第一行原始碼x = 3 (為什麼用兩個位元組表示指令的引數?如果 Python 使用一個位元組,每個程式碼物件你只能有 256 個常量/名字,而用兩個位元組,就增加到了 256 的平方,65536個)。

條件語句與迴圈語句

到目前為止,我們的直譯器只能一條接著一條的執行指令。這有個問題,我們經常會想多次執行某個指令,或者在特定的條件下跳過它們。為了可以寫迴圈和分支結構,直譯器必須能夠在指令中跳轉。在某種程度上,Python 在位元組碼中使用GOTO語句來處理迴圈和分支!讓我們再看一個cond函式的反彙編結果:

第三行的條件表示式if x 被編譯成四條指令:LOAD_FASTLOAD_CONSTCOMPARE_OPPOP_JUMP_IF_FALSEx 對應載入x、載入 5、比較這兩個值。指令POP_JUMP_IF_FALSE完成這個if語句。這條指令把棧頂的值彈出,如果值為真,什麼都不發生。如果值為假,直譯器會跳轉到另一條指令。

這條將被載入的指令稱為跳轉目標,它作為指令POP_JUMP的引數。這裡,跳轉目標是 22,索引為 22 的指令是LOAD_CONST,對應原始碼的第 6 行。(dis>>標記跳轉目標。)如果X 為假,直譯器會忽略第四行(return yes),直接跳轉到第6行(return "no")。因此直譯器通過跳轉指令選擇性的執行指令。

Python 的迴圈也依賴於跳轉。在下面的位元組碼中,while x 這一行產生了和if x 幾乎一樣的位元組碼。在這兩種情況下,直譯器都是先執行比較,然後執行POP_JUMP_IF_FALSE來控制下一條執行哪個指令。第四行的最後一條位元組碼JUMP_ABSOLUT(迴圈體結束的地方),讓直譯器返回到迴圈開始的第 9 條指令處。當 x 變為假,POP_JUMP_IF_FALSE會讓直譯器跳到迴圈的終止處,第 34 條指令。

探索位元組碼

我希望你用dis.dis來試試你自己寫的函式。一些有趣的問題值得探索:

  • 對直譯器而言 for 迴圈和 while 迴圈有什麼不同?
  • 能不能寫出兩個不同函式,卻能產生相同的位元組碼?
  • elif是怎麼工作的?列表推導呢?

到目前為止,我們已經知道了 Python 虛擬機器是一個棧機器。它能順序執行指令,在指令間跳轉,壓入或彈出棧值。但是這和我們期望的直譯器還有一定距離。在前面的那個例子中,最後一條指令是RETURN_VALUE,它和return語句相對應。但是它返回到哪裡去呢?

為了回答這個問題,我們必須再增加一層複雜性:幀(frame)。一個幀是一些資訊的集合和程式碼的執行上下文。幀在 Python 程式碼執行時動態地建立和銷燬。每個幀對應函式的一次呼叫 —— 所以每個幀只有一個程式碼物件與之關聯,而一個程式碼物件可以有多個幀。比如你有一個函式遞迴的呼叫自己 10 次,這會產生 11 個幀,每次呼叫對應一個,再加上啟動模組對應的一個幀。總的來說,Python 程式的每個作用域都有一個幀,比如,模組、函式、類定義。

幀存在於呼叫棧(call stack)中,一個和我們之前討論的完全不同的棧。(你最熟悉的棧就是呼叫棧,就是你經常看到的異常回溯,每個以”File ‘program.py'”開始的回溯對應一個幀。)直譯器在執行位元組碼時操作的棧,我們叫它資料棧(data stack)。其實還有第三個棧,叫做塊棧(block stack),用於特定的控制流塊,比如迴圈和異常處理。呼叫棧中的每個幀都有它自己的資料棧和塊棧。

讓我們用一個具體的例子來說明一下。假設 Python 直譯器執行到下面標記為 3 的地方。直譯器正處於foo函式的呼叫中,它接著呼叫bar。下面是幀呼叫棧、塊棧和資料棧的示意圖。我們感興趣的是直譯器先從最底下的foo()開始,接著執行foo的函式體,然後到達bar

呼叫棧

現在,直譯器處於bar函式的呼叫中。呼叫棧中有 3 個幀:一個對應於模組層,一個對應函式foo,另一個對應函式bar。(見上圖)一旦bar返回,與它對應的幀就會從呼叫棧中彈出並丟棄。

位元組碼指令RETURN_VALUE告訴直譯器在幀之間傳遞一個值。首先,它把位於呼叫棧棧頂的幀中的資料棧的棧頂值彈出。然後把整個幀彈出丟棄。最後把這個值壓到下一個幀的資料棧中。

當 Ned Batchelder 和我在寫 Byterun 時,很長一段時間我們的實現中一直有個重大的錯誤。我們整個虛擬機器中只有一個資料棧,而不是每個幀都有一個。我們寫了很多測試程式碼,同時在 Byterun 和真正的 Python 上執行,希望得到一致結果。我們幾乎通過了所有測試,只有一樣東西不能通過,那就是生成器(generators)。最後,通過仔細的閱讀 CPython 的原始碼,我們發現了錯誤所在(感謝 Michael Arntzenius 對這個 bug 的洞悉)。把資料棧移到每個幀就解決了這個問題。

回頭在看看這個 bug,我驚訝的發現 Python 真的很少依賴於每個幀有一個資料棧這個特性。在 Python 中幾乎所有的操作都會清空資料棧,所以所有的幀公用一個資料棧是沒問題的。在上面的例子中,當bar執行完後,它的資料棧為空。即使foo公用這一個棧,它的值也不會受影響。然而,對應生成器,它的一個關鍵的特點是它能暫停一個幀的執行,返回到其他的幀,一段時間後它能返回到原來的幀,並以它離開時的相同狀態繼續執行。

Byterun

現在我們有足夠的 Python 直譯器的知識背景去考察 Byterun。

Byterun 中有四種物件。

  • VirtualMachine類,它管理高層結構,尤其是幀呼叫棧,幷包含了指令到操作的對映。這是一個比前面Inteprter物件更復雜的版本。
  • Frame類,每個Frame類都有一個程式碼物件,並且管理著其他一些必要的狀態位,尤其是全域性和區域性名稱空間、指向呼叫它的整的指標和最後執行的位元組碼指令。
  • Function類,它被用來代替真正的 Python 函式。回想一下,呼叫函式時會建立一個新的幀。我們自己實現了Function,以便我們控制新的Frame的建立。
  • Block類,它只是包裝了塊的 3 個屬性。(塊的細節不是直譯器的核心,我們不會花時間在它身上,把它列在這裡,是因為 Byterun 需要它。)

VirtualMachine

每次程式執行時只會建立一個VirtualMachine例項,因為我們只有一個 Python 直譯器。VirtualMachine 儲存呼叫棧、異常狀態、在幀之間傳遞的返回值。它的入口點是run_code方法,它以編譯後的程式碼物件為引數,以建立一個幀為開始,然後執行這個幀。這個幀可能再建立出新的幀;呼叫棧隨著程式的執行而增長和縮短。當第一個幀返回時,執行結束。

Frame

接下來,我們來寫Frame物件。幀是一個屬性的集合,它沒有任何方法。前面提到過,這些屬性包括由編譯器生成的程式碼物件;區域性、全域性和內建名稱空間;前一個幀的引用;一個資料棧;一個塊棧;最後執行的指令指標。(對於內建名稱空間我們需要多做一點工作,Python 在不同模組中對這個名稱空間有不同的處理;但這個細節對我們的虛擬機器不重要。)

接著,我們在虛擬機器中增加對幀的操作。這有 3 個幫助函式:一個建立新的幀的方法(它負責為新的幀找到名字空間),和壓棧和出棧的方法。第四個函式,run_frame,完成執行幀的主要工作,待會我們再討論這個方法。

Function

Function的實現有點曲折,但是大部分的細節對理解直譯器不重要。重要的是當呼叫函式時 —— 即呼叫 __call__方法 —— 它建立一個新的Frame並執行它。

接著,回到VirtualMachine物件,我們對資料棧的操作也增加一些幫助方法。位元組碼操作的棧總是在當前幀的資料棧。這些幫助函式讓我們的POP_TOPLOAD_FAST以及其他操作棧的指令的實現可讀性更高。

在我們執行幀之前,我們還需兩個方法。

第一個方法,parse_byte_and_args 以一個位元組碼為輸入,先檢查它是否有引數,如果有,就解析它的引數。這個方法同時也更新幀的last_instruction屬性,它指向最後執行的指令。一條沒有引數的指令只有一個位元組長度,而有引數的位元組有3個位元組長。引數的意義依賴於指令是什麼。比如,前面說過,指令POP_JUMP_IF_FALSE,它的引數指的是跳轉目標。BUILD_LIST,它的引數是列表的個數。LOAD_CONST,它的引數是常量的索引。

一些指令用簡單的數字作為引數。對於另一些,虛擬機器需要一點努力去發現它含意。標準庫中的dis模組中有一個備忘單,它解釋什麼引數有什麼意思,這讓我們的程式碼更加簡潔。比如,列表dis.hasname告訴我們LOAD_NAMEIMPORT_NAMELOAD_GLOBAL,以及另外的 9 個指令的引數都有同樣的意義:對於這些指令,它們的引數代表了程式碼物件中的名字列表的索引。

下一個方法是dispatch,它查詢給定的指令並執行相應的操作。在 CPython 中,這個分派函式用一個巨大的 switch 語句實現,有超過 1500 行的程式碼。幸運的是,我們用的是 Python,我們的程式碼會簡潔的多。我們會為每一個位元組碼名字定義一個方法,然後用getattr來查詢。就像我們前面的小直譯器一樣,如果一條指令叫做FOO_BAR,那麼它對應的方法就是byte_FOO_BAR。現在,我們先把這些方法當做一個黑盒子。每個指令方法都會返回None或者一個字串why,有些情況下虛擬機器需要這個額外why資訊。這些指令方法的返回值,僅作為直譯器狀態的內部指示,千萬不要和執行幀的返回值相混淆。

Block

在我們完成每個位元組碼方法前,我們簡單的討論一下塊。一個塊被用於某種控制流,特別是異常處理和迴圈。它負責保證當操作完成後資料棧處於正確的狀態。比如,在一個迴圈中,一個特殊的迭代器會存在棧中,當迴圈完成時它從棧中彈出。直譯器需要檢查迴圈仍在繼續還是已經停止。

為了跟蹤這些額外的資訊,直譯器設定了一個標誌來指示它的狀態。我們用一個變數why實現這個標誌,它可以是None或者是下面幾個字串之一:"continue""break""excption"return。它們指示對塊棧和資料棧進行什麼操作。回到我們迭代器的例子,如果塊棧的棧頂是一個loop塊,why的程式碼是continue,迭代器就應該儲存在資料棧上,而如果whybreak,迭代器就會被彈出。

塊操作的細節比這個還要繁瑣,我們不會花時間在這上面,但是有興趣的讀者值得仔細的看看。

指令

剩下了的就是完成那些指令方法了:byte_LOAD_FASTbyte_BINARY_MODULO等等。而這些指令的實現並不是很有趣,這裡我們只展示了一小部分,完整的實現在 GitHub 上。(這裡包括的指令足夠執行我們前面所述的所有程式碼了。)

動態型別:編譯器不知道它是什麼

你可能聽過 Python 是一種動態語言 —— 它是動態型別的。在我們建造直譯器的過程中,已經透露出這樣的資訊。

動態的一個意思是很多工作是在執行時完成的。前面我們看到 Python 的編譯器沒有很多關於程式碼真正做什麼的資訊。舉個例子,考慮下面這個簡單的函式mod。它取兩個引數,返回它們的模運算值。從它的位元組碼中,我們看到變數ab首先被載入,然後位元組碼BINAY_MODULO完成這個模運算。

計算 19 % 5 得4,—— 一點也不奇怪。如果我們用不同類的引數呢?

剛才發生了什麼?你可能在其它地方見過這樣的語法,格式化字串。

用符號%去格式化字串會呼叫位元組碼BUNARY_MODULO。它取棧頂的兩個值求模,不管這兩個值是字串、數字或是你自己定義的類的例項。位元組碼在函式編譯時生成(或者說,函式定義時)相同的位元組碼會用於不同類的引數。

Python 的編譯器關於位元組碼的功能知道的很少,而取決於直譯器來決定BINAYR_MODULO應用於什麼型別的物件並完成正確的操作。這就是為什麼 Python 被描述為動態型別(dynamically typed):直到執行前你不必知道這個函式引數的型別。相反,在一個靜態型別語言中,程式設計師需要告訴編譯器引數的型別是什麼(或者編譯器自己推斷出引數的型別。)

編譯器的無知是優化 Python 的一個挑戰 —— 只看位元組碼,而不真正執行它,你就不知道每條位元組碼在幹什麼!你可以定義一個類,實現__mod__方法,當你對這個類的例項使用%時,Python 就會自動呼叫這個方法。所以,BINARY_MODULO其實可以執行任何程式碼。

看看下面的程式碼,第一個a % b看起來沒有用。

不幸的是,對這段程式碼進行靜態分析 —— 不執行它 —— 不能確定第一個a % b沒有做任何事。用 %呼叫__mod__可能會寫一個檔案,或是和程式的其他部分互動,或者其他任何可以在 Python 中完成的事。很難優化一個你不知道它會做什麼的函式。在 Russell Power 和 Alex Rubinsteyn 的優秀論文中寫道,“我們可以用多快的速度解釋 Python?”,他們說,“在普遍缺乏型別資訊下,每條指令必須被看作一個INVOKE_ARBITRARY_METHOD。”

總結

Byterun 是一個比 CPython 容易理解的簡潔的 Python 直譯器。Byterun 複製了 CPython 的主要結構:一個基於棧的直譯器對稱之為位元組碼的指令集進行操作,它們順序執行或在指令間跳轉,向棧中壓入和從中彈出資料。直譯器隨著函式和生成器的呼叫和返回,動態的建立、銷燬幀,並在幀之間跳轉。Byterun 也有著和真正直譯器一樣的限制:因為 Python 使用動態型別,直譯器必須在執行時決定指令的正確行為。

我鼓勵你去反彙編你的程式,然後用 Byterun 來執行。你很快會發現這個縮短版的 Byterun 所沒有實現的指令。完整的實現在 https://github.com/nedbat/byterun,或者你可以仔細閱讀真正的 CPython 直譯器ceval.c,你也可以實現自己的直譯器!

致謝

感謝 Ned Batchelder 發起這個專案並引導我的貢獻,感謝 Michael Arntzenius 幫助除錯程式碼和這篇文章的修訂,感謝 Leta Montopoli 的修訂,以及感謝整個 Recurse Center 社群的支援和鼓勵。所有的不足全是我自己沒搞好。

相關文章