Python 位元組碼介紹

發表於2018-07-09

瞭解 Python 位元組碼是什麼,Python 如何使用它來執行你的程式碼,以及知道它是如何幫到你的。

如果你曾經編寫過 Python,或者只是使用過 Python,你或許經常會看到 Python 原始碼檔案——它們的名字以 .py 結尾。你可能還看到過其它型別的檔案,比如以 .pyc 結尾的檔案,或許你可能聽說過它們就是 Python 的 “位元組碼bytecode” 檔案。(在 Python 3 上這些可能不容易看到 —— 因為它們與你的 .py 檔案不在同一個目錄下,它們在一個叫 __pycache__ 的子目錄中)或者你也聽說過,這是節省時間的一種方法,它可以避免每次執行 Python 時去重新解析原始碼。

但是,除了 “噢,原來這就是 Python 位元組碼” 之外,你還知道這些檔案能做什麼嗎?以及 Python 是如何使用它們的?

如果你不知道,那你走運了!今天我將帶你瞭解 Python 的位元組碼是什麼,Python 如何使用它去執行你的程式碼,以及知道它是如何幫助你的。

Python 如何工作

Python 經常被介紹為它是一個解釋型語言 —— 其中一個原因是在程式執行時,你的原始碼被轉換成 CPU 的原生指令 —— 但這樣的看法只是部分正確。Python 與大多數解釋型語言一樣,確實是將原始碼編譯為一組虛擬機器指令,並且 Python 直譯器是針對相應的虛擬機器實現的。這種中間格式被稱為 “位元組碼”。

因此,這些 .pyc 檔案是 Python 悄悄留下的,是為了讓它們執行的 “更快”,或者是針對你的原始碼的 “優化” 版本;它們是你的程式在 Python 虛擬機器上執行的位元組碼指令。

我們來看一個示例。這裡是用 Python 寫的經典程式 “Hello, World!”:

下面是轉換後的位元組碼(轉換為人類可讀的格式):

如果你輸入那個 hello() 函式,然後使用 CPython 直譯器去執行它,那麼上述列出的內容就是 Python 所執行的。它看起來可能有點奇怪,因此,我們來深入瞭解一下它都做了些什麼。

Python 虛擬機器內幕

CPython 使用一個基於棧的虛擬機器。也就是說,它完全面向棧資料結構的(你可以 “推入” 一個東西到棧 “頂”,或者,從棧 “頂” 上 “彈出” 一個東西來)。

CPython 使用三種型別的棧:

  1. 呼叫棧call stack。這是執行 Python 程式的主要結構。它為每個當前活動的函式呼叫使用了一個東西 —— “幀frame”,棧底是程式的入口點。每個函式呼叫推送一個新的幀到呼叫棧,每當函式呼叫返回後,這個幀被銷燬。
  2. 在每個幀中,有一個計算棧evaluation stack (也稱為資料棧data stack)。這個棧就是 Python 函式執行的地方,執行的 Python 程式碼大多數是由推入到這個棧中的東西組成的,操作它們,然後在返回後銷燬它們。
  3. 在每個幀中,還有一個塊棧block stack。它被 Python 用於去跟蹤某些型別的控制結構:迴圈、try / except 塊、以及 with 塊,全部推入到塊棧中,當你退出這些控制結構時,塊棧被銷燬。這將幫助 Python 瞭解任意給定時刻哪個塊是活動的,比如,一個 continue 或者 break 語句可能影響正確的塊。

大多數 Python 位元組碼指令操作的是當前呼叫棧幀的計算棧,雖然,還有一些指令可以做其它的事情(比如跳轉到指定指令,或者操作塊棧)。

為了更好地理解,假設我們有一些呼叫函式的程式碼,比如這個:my_function(my_variable, 2)。Python 將轉換為一系列位元組碼指令:

  1. 一個 LOAD_NAME 指令去查詢函式物件 my_function,然後將它推入到計算棧的頂部
  2. 另一個 LOAD_NAME 指令去查詢變數 my_variable,然後將它推入到計算棧的頂部
  3. 一個 LOAD_CONST 指令去推入一個實整數值 2 到計算棧的頂部
  4. 一個 CALL_FUNCTION 指令

這個 CALL_FUNCTION 指令將有 2 個引數,它表示那個 Python 需要從棧頂彈出兩個位置引數;然後函式將在它上面進行呼叫,並且它也同時被彈出(對於函式涉及的關鍵字引數,它使用另一個不同的指令 —— CALL_FUNCTION_KW,但使用的操作原則類似,以及第三個指令 —— CALL_FUNCTION_EX,它適用於函式呼叫涉及到引數使用 *** 操作符的情況)。一旦 Python 擁有了這些之後,它將在呼叫棧上分配一個新幀,填充到函式呼叫的本地變數上,然後,執行那個幀內的 my_function 位元組碼。執行完成後,這個幀將被呼叫棧銷燬,而在最初的幀內,my_function 的返回值將被推入到計算棧的頂部。

訪問和理解 Python 位元組碼

如果你想玩轉位元組碼,那麼,Python 標準庫中的 dis 模組將對你有非常大的幫助;dis 模組為 Python 位元組碼提供了一個 “反彙編”,它可以讓你更容易地得到一個人類可讀的版本,以及查詢各種位元組碼指令。dis 模組的文件 可以讓你遍歷它的內容,並且提供一個位元組碼指令能夠做什麼和有什麼樣的引數的完整清單。

例如,獲取上面的 hello() 函式的列表,可以在一個 Python 解析器中輸入如下內容,然後執行它:

函式 dis.dis() 將反彙編一個函式、方法、類、模組、編譯過的 Python 程式碼物件、或者字串包含的原始碼,以及顯示出一個人類可讀的版本。dis 模組中另一個方便的功能是 distb()。你可以給它傳遞一個 Python 追溯物件,或者在發生預期外情況時呼叫它,然後它將在發生預期外情況時反彙編呼叫棧上最頂端的函式,並顯示它的位元組碼,以及插入一個指向到引發意外情況的指令的指標。

它也可以用於檢視 Python 為每個函式構建的編譯後的程式碼物件,因為執行一個函式將會用到這些程式碼物件的屬性。這裡有一個檢視 hello() 函式的示例:

程式碼物件在函式中可以以屬性 __code__ 來訪問,並且攜帶了一些重要的屬性:

  • co_consts 是存在於函式體內的任意實數的元組
  • co_varnames 是函式體內使用的包含任意本地變數名字的元組
  • co_names 是在函式體內引用的任意非本地名字的元組

許多位元組碼指令 —— 尤其是那些推入到棧中的載入值,或者在變數和屬性中的儲存值 —— 在這些元組中的索引作為它們引數。

因此,現在我們能夠理解 hello() 函式中所列出的位元組碼:

  1. LOAD_GLOBAL 0:告訴 Python 通過 co_names (它是 print 函式)的索引 0 上的名字去查詢它指向的全域性物件,然後將它推入到計算棧
  2. LOAD_CONST 1:帶入 co_consts 在索引 1 上的字面值,並將它推入(索引 0 上的字面值是 None,它表示在 co_consts 中,因為 Python 函式呼叫有一個隱式的返回值 None,如果沒有顯式的返回表示式,就返回這個隱式的值 )。
  3. CALL_FUNCTION 1:告訴 Python 去呼叫一個函式;它需要從棧中彈出一個位置引數,然後,新的棧頂將被函式呼叫。

“原始的” 位元組碼 —— 是非人類可讀格式的位元組 —— 也可以在程式碼物件上作為 co_code 屬性可用。如果你有興趣嘗試手工反彙編一個函式時,你可以從它們的十進位制位元組值中,使用列出 dis.opname 的方式去檢視位元組碼指令的名字。

位元組碼的用處

現在,你已經瞭解的足夠多了,你可能會想 “OK,我認為它很酷,但是知道這些有什麼實際價值呢?”由於對它很好奇,我們去了解它,但是除了好奇之外,Python 位元組碼在幾個方面還是非常有用的。

首先,理解 Python 的執行模型可以幫你更好地理解你的程式碼。人們都開玩笑說,C 是一種 “可移植彙編器”,你可以很好地猜測出一段 C 程式碼轉換成什麼樣的機器指令。理解 Python 位元組碼之後,你在使用 Python 時也具備同樣的能力 —— 如果你能預料到你的 Python 原始碼將被轉換成什麼樣的位元組碼,那麼你可以知道如何更好地寫和優化 Python 原始碼。

第二,理解位元組碼可以幫你更好地回答有關 Python 的問題。比如,我經常看到一些 Python 新手困惑為什麼某些結構比其它結構執行的更快(比如,為什麼 {}dict() 快)。知道如何去訪問和閱讀 Python 位元組碼將讓你很容易回答這樣的問題(嘗試對比一下: dis.dis("{}")dis.dis("dict()") 就會明白)。

最後,理解位元組碼和 Python 如何執行它,為 Python 程式設計師不經常使用的一種特定的程式設計方式提供了有用的視角:面向棧的程式設計。如果你以前從來沒有使用過像 FORTH 或 Fator 這樣的面向棧的程式語言,它們可能有些古老,但是,如果你不熟悉這種方法,學習有關 Python 位元組碼的知識,以及理解面向棧的程式設計模型是如何工作的,將有助你開拓你的程式設計視野。

延伸閱讀

如果你想進一步瞭解有關 Python 位元組碼、Python 虛擬機器、以及它們是如何工作的更多知識,我推薦如下的這些資源:

  • Python 虛擬機器內幕,它是 Obi Ike-Nwosu 寫的一本免費線上電子書,它深入 Python 解析器,解釋了 Python 如何工作的細節。
  • 一個用 Python 編寫的 Python 解析器,它是由 Allison Kaptur 寫的一個教程,它是用 Python 構建的 Python 位元組碼解析器,並且它實現了執行 Python 位元組碼的全部構件。
  • 最後,CPython 解析器是一個開源軟體,你可以在 GitHub 上閱讀它。它在檔案 Python/ceval.c 中實現了位元組碼解析器。這是 Python 3.6.4 發行版中那個檔案的連結;位元組碼指令是由第 1266 行開始的 switch 語句來處理的。

學習更多內容,參與到 James Bennett 的演講,有關位元組的知識:理解 Python 位元組碼,將在 PyCon Cleveland 2018 召開。

相關文章