1. 程式人生 > 實用技巧 >Python - Python中的位元組碼(轉帖)

Python - Python中的位元組碼(轉帖)

今天看書又看到了一些關於位元組碼的文章,網上找到一篇我個人覺的寫的不錯的。

原帖地址:https://www.cnblogs.com/Neeo/articles/10688677.html

目錄

前言#

這裡以CPython為扯淡物件!
毋庸置疑,Python是解釋性語言,因為我們常常這樣解釋:Python程式碼將被逐行解釋並執行......這,確實忽略了一些細節...........
現在,溫故知新,讓我們再次從hello world出發,不忘初心!

def hello():
    """hello function"""
    print('hello world', '你好')

當上述程式碼被執行時發生了什麼?答案是什!麼!都!沒!發!生!因為函式只定義沒呼叫嘛!那麼如果有呼叫的話,會發生什麼,你可能說,會執行一行列印........

位元組碼:bytecode#

CPython直譯器在內部會將Python原始碼編譯成位元組碼,並快取在.pyc檔案中,目的是當再次執行該檔案時,直接讀取.pyc檔案會更快,這樣可以避免從原始碼重新編譯到位元組碼,當然,Python再找到符合檔案後,檢查此檔案的時間戳,如果發現位元組碼檔案(檔案在匯入時就被編譯完成)比原始碼檔案時間戳早(比如你修改過原檔案),那麼就會重新生成位元組碼,否則就會跳過此步驟。如果,Python在搜尋時只找到了位元組碼而沒有找到原始碼檔案,那麼就會直接執行位元組碼檔案(如果沒有印象,請回想在模組匯入時發生了什麼)。
然後,Python虛擬機器執行位元組碼編譯器發出的位元組碼。
現在,我們來研究位元組碼是什麼鬼東西!

面向物件?面向棧!#

CPython使用一個基於棧的虛擬機器,也就是說,它完全是面向棧,這種資料結構的(想象出棧入棧)。
CPython使用3種類型的棧:

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

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

程式碼物件#

為了方便理解,現在強勢插入(學習)一下程式碼物件這個鬼東西。
如果你要是不知道什麼是程式碼物件,那麼你對這個熟悉嗎?

>>> def hello():
...     print('hello world')
...
>>> hello.__code__
<code object hello at 0x00FA7A70, file "<stdin>", line 1>

這個code就是程式碼物件,表示已經編譯的函式體。當然不僅僅是這些,程式碼物件表示可執行的Python程式碼或者位元組碼,程式碼物件和函式物件之間的區別在於函式物件包含對函式的全域性變數(定義它的模組)的顯式引用。而程式碼物件不包含上下文,預設引數值也儲存在函式物件中,而不是儲存在程式碼物件中(因為它們表示在執行時計算的值)。與函式物件不同,程式碼物件是不可變的,並且不包含(直接或間接)可變物件的引用。
你以為這就完了?__code__沒那麼簡單:

def hello():
    print('hello world')

print(hello.__code__)  # <code object hello at 0x035B4F98, file "M:/demo/MyAI/AI/part2/demo.py", line 20>
print(hello.__code__.co_name)  # hello
print(hello.__code__.co_argcount)  # 0
print(hello.__code__.co_nlocals)  # 0
print(hello.__code__.co_varnames)  # ()
print(hello.__code__.co_cellvars)  # ()
print(hello.__code__.co_freevars)  # ()
print(hello.__code__.co_code)  # b't\x00d\x01\x83\x01\x01\x00d\x00S\x00'
print(hello.__code__.co_consts)  # (None, 'hello world')
print(hello.__code__.co_names)  # ('print',)
print(hello.__code__.co_filename)  # M:/demo/MyAI/AI/part2/demo.py
print(hello.__code__.co_firstlineno)  # 20
print(hello.__code__.co_lnotab)  # b'\x00\x01'
print(hello.__code__.co_stacksize)  # 2
print(hello.__code__.co_flags)  # 67

程式碼物件還包括一些特殊的只讀屬性:

  • co_name給出函式名稱。
  • co_argcount是位置引數的數量,包括具有預設值的引數。
  • co_nlocals是函式使用的區域性變數數,包括引數。
  • co_varnames是一個包含區域性變數名稱的元組,以引數名稱開頭。
  • co_cellvars是一個元組,包含巢狀函式引用的區域性變數的名稱。
  • co_freevars是一個包含自由變數名稱的的元組。
  • co_code是表示位元組碼指令序列的字串。
  • co_consts是一個包含位元組碼使用的文字的元組。如果程式碼物件表示函式,則co_consts的第一項是函式的文件字串,如果文件字串未定義,則是None。
  • co_names是一個包含位元組碼使用的名稱的元組。
  • co_filename是編譯程式碼的檔名。
  • co_firstlineno是函式的第一個行號。
  • co_lnotab是一個字串,用於編碼從位元組碼偏移到行號的對映(更詳細的資訊參考解釋的原始碼)。
  • co_stacksize是所需的堆疊大小(包括區域性變數)。
  • co_flags是一個整數,作為編碼直譯器的標誌使用。以下標誌位定義為co_flags:0x04如果函式使用arguments語法接受任意數量的位置引數,則設定位; 0x08如果函式使用keywords語法接受任意關鍵字引數,則設定 bit 。

現在,大致理解了程式碼物件是什麼鬼東西,我們繼續往下走。

位元組碼如何工作#

結合程式碼物件和棧相關的知識,我們來研究位元組碼是如何在棧內工作的。
要想理解這些東西,我們還需要藉助Python標準庫中的dis模組,dis模組通過反彙編支援CPython位元組碼的分析,該模組作為輸入的CPython位元組碼在檔案中定義,Include/opcode.h並由編譯器和直譯器使用。
我們如何使用呢?一般,通過dis.dis()將反彙編一個函式、方法、類或者模組編譯過的Python程式碼物件、字串包含的原始碼,顯示出一個人類可讀的版本。
另外就是dis.distb(),我們可以給這個方法傳遞一個Python追溯物件,或者在發生預期外情況是呼叫它,然後它將在發生預期外情況時反彙編呼叫棧上最頂端的函式,並顯示它的位元組碼,以及插入一個指向到引發意外情況的指令的指標。
需要注意的是:在版本3.6中更改,為每條指令使用2個位元組。以前位元組數因指令而異。
扯了半天,來個示例玩玩:

def hello():
    """hello function"""
    print('hello world', '你好')
dis.dis(hello)
"""dis.dis(hello)輸出結果
  8           0 LOAD_GLOBAL              0 (print)
              2 LOAD_CONST               1 ('hello world')
              4 LOAD_CONST               2 ('你好')
              6 CALL_FUNCTION            2
              8 POP_TOP
             10 LOAD_CONST               3 (None)
             12 RETURN_VALUE
"""
print(hello.__code__.co_names)  # ('print',)
print(hello.__code__.co_consts)  # ('hello function', 'hello world', '你好', None)
print(hello.__code__.co_argcount)  # 0
  • LOAD_GLOBAL(namei),將全域性命名載入co_names[namei]到堆疊中。其中namei是程式碼物件屬性中的name索引。
  • LOAD_CONST(consti),推co_consts[consti]到堆疊上。
  • CALL_FUNCTION(argc),使用位置引數呼叫可呼叫物件。 argc表示位置引數的數量。堆疊的頂部包含位置引數,最右側的引數位於頂部。引數下面是一個可呼叫的物件。 CALL_FUNCTION將所有引數和可呼叫物件彈出堆疊,使用這些引數呼叫可呼叫物件,並推送可呼叫物件返回的返回值。版本3.6中更改:此操作碼僅用於具有位置引數的呼叫。
  • POP_TOP,刪除堆疊頂部(TOS)項。
  • RETURN_VALUE,返回TOS到函式的呼叫者。

首先說dis.dis(hello)輸出結果,左上角的8表示print這一行程式碼所在檔案的行數(下圖為證),然後:

  • 指令0LOAD_GLOBAL(namei)從元組(print)索引0的print推入棧(呼叫棧,這部分不理解的話需要參考前文的棧相關的內容)中。
  • 指令2 4:其中有兩個位置引數LOAD_CONSTLOAD_CONST 11 ('hello world')LOAD_CONST 22 ('你好')
  • 指令6CALL_FUNCTION表示Python需要從棧頂彈出2個位置引數供print函式呼叫,其實說白了就是print函式呼叫就是開了一個新的幀,print函式在新的幀內執行,需要的引數由CALL_FUNCTION給。
  • 指令8:當新的幀內的位元組碼執行完了,就從棧頂刪除。
  • 指令10LOAD_CONSTprint函式呼叫返回值推入最開始的呼叫棧。可以看到元組的索引0的位置是None,因為在Python中函式呼叫預設有返回值,如果沒有顯示的返回值,就隱式的返回一個值——None。
  • 指令12RETURN_VALUE將新幀(這個幀執行完畢就銷燬了)的執行結果返回給呼叫棧。

在Github上,我們可以檢視Python的原始碼,這裡以Python 3.6.4 發行版為例,在Python/ceval.c檔案中,位元組碼指令由第1266行的swith語句來處理.....

位元組碼有啥用處#

說了一大推,我想你肯定想問,這鬼東西辣麼複雜,有啥實際價值?
首先,理解了Python的執行模式可以幫助我們更好的理解程式碼,如果我們能預料到我們的原始碼將被轉換成什麼樣的位元組碼,那麼就可以做一些針對性的優化。
再者,理解位元組碼可以幫助我們很好地回答一些問題:為什麼某些結構要比其他的結構效能更高。再我們知道位元組碼是如何執行的之後。我們就可以很容易的回答這些問題。

>>> import dis
>>> dis.dis("[]")
  1           0 BUILD_LIST               0
              2 RETURN_VALUE
>>> dis.dis("list()")
  1           0 LOAD_NAME                0 (list)
              2 CALL_FUNCTION            0
              4 RETURN_VALUE
>>> >>> dis.dis("{}")
  1           0 BUILD_MAP                0
              2 RETURN_VALUE
>>> dis.dis("dist()")
  1           0 LOAD_NAME                0 (dist)
              2 CALL_FUNCTION            0
              4 RETURN_VALUE

這個時候看上面的程式碼,是不是就容易很多了。
最後,通過位元組碼我們瞭解了面向棧的程式設計方式,以及這種程式設計方式是如何運作的。拓展我們的視野。