深入理解 python 虛擬機器:位元組碼教程(3)——深入剖析迴圈實現原理

一無是處的研究僧發表於2023-04-15

深入理解 python 虛擬機器:位元組碼教程(3)——深入剖析迴圈實現原理

在本篇文章當中主要給大家介紹 cpython 當中跟迴圈相關的位元組碼,這部分位元組碼相比起其他位元組碼來說相對複雜一點,透過分析這部分位元組碼我們對程式的執行過程將會有更加深刻的理解。

迴圈

普通 for 迴圈實現原理

我們使用各種例子來理解和迴圈相關的位元組碼:

def test_loop():
    for i in range(10):
        print(i)

上面的程式碼對應的位元組碼如下所示:

  8           0 LOAD_GLOBAL              0 (range)
              2 LOAD_CONST               1 (10)
              4 CALL_FUNCTION            1
              6 GET_ITER
        >>    8 FOR_ITER                12 (to 22)
             10 STORE_FAST               0 (i)

  9          12 LOAD_GLOBAL              1 (print)
             14 LOAD_FAST                0 (i)
             16 CALL_FUNCTION            1
             18 POP_TOP
             20 JUMP_ABSOLUTE            8
        >>   22 LOAD_CONST               0 (None)
             24 RETURN_VALUE

首先是 range 他對應一個 builtin 的型別,在執行上面的位元組碼的過程當中,首先先將 range 將在進入棧空間當中,然後將常量 10 載入進入棧空間當中,最後會呼叫指令 CALL_FUNCTION,這個時候會將棧頂的兩個元素彈出,呼叫 range 型別的建立函式,這個函式會返回一個 range 的例項物件。

這個時候棧的結果如下所示:

接下來的一條位元組碼為 GET_ITER,這條位元組碼的含義為,彈出棧頂的物件,並且將彈出的物件變成一個迭代器,並且將得到的迭代器物件再壓入棧空間當中。

接下來的一條指令是 FOR_ITER,這條指令的含義為:已知棧頂物件是一個迭代器,呼叫這個迭代器的 __next__ 函式 :

  • 如果迭代器已經迭代完成了,則將棧頂的迭代器彈出,並且將 bytecode 的 counter 加上對應的引數值,在上面的函式位元組碼當中這個引數值等於 12 ,也就是說下一條指令為位元組碼序列的 22 這個位置。
  • 如果沒有迭代完成則將函式的返回值壓入棧頂,並且不需要彈出迭代器,比如當我們第一次呼叫 __next__ 函式的時候,range 的返回值為0,那麼此時棧空間的內容如下所示:

接下來執行的位元組碼為 STORE_FAST,這條位元組碼的含義就是彈出棧頂的元素,並且將這個元素儲存到 co_varnames[var_num] 當中,var_num 就是這條位元組碼的引數,在上面的函式當中就是 0,對應的變數為 i ,因此這條位元組碼的含義就是彈出棧頂的元素並且儲存到變數 i 當中。

LOAD_GLOBAL,將內嵌函式 print 載入進入棧中,LOAD_FAST 將變數 i 載入進入棧空間當中,此時棧空間的內容如下所示:

CALL_FUNCTION 會呼叫 print 函式列印數字 0,並且將函式的返回值壓入棧空間當中,print 函式的返回值為 None,此時棧空間的內容如下所示:

POP_TOP 將棧頂的元素彈出,JUMP_ABSOLUTE 位元組碼有一個引數,在上面的函式當中這個引數為 8 ,當執行這條位元組碼的時候直接將 bytecode 的 counter 直接設定成這個引數值,因此執行完這條位元組碼之後下一條被執行的位元組碼又是 FOR_ITER,這便實現了迴圈的效果。

綜合分析上面的分析過程,實現迴圈的效果主要是有兩個位元組碼實現的,一個是 FOR_ITER,當迭代器迭代完成之後,會直接跳出迴圈,實現這個的原理是在位元組碼的 counter 上加上一個值,另外一個就是 JUMP_ABSOLUTE,他可以直接跳到某一處的位元組碼位置進行執行。

continue 關鍵字實現原理

def test_continue():
    for i in range(10):
        data = random.randint(0, 10)
        if data < 5:
            continue
        print(f"{data = }")

其實透過對上面的位元組碼的分析之後,我們可以大致分析出 continue 的實現原理,首先我們知道 continue 的語意直接進行下一次迴圈,這個語意其實和迴圈體執行完之後的語意是一樣的,在上一份程式碼的分析當中實現這個語意的位元組碼是 JUMP_ABSOLUTE,直接跳到 FOR_ITER 指令的位置繼續開始執行。我們現在來看看上面的函式對應的位元組碼是什麼:

 13           0 LOAD_GLOBAL              0 (range)
              2 LOAD_CONST               1 (10)
              4 CALL_FUNCTION            1
              6 GET_ITER
        >>    8 FOR_ITER                40 (to 50)
             10 STORE_FAST               0 (i)

 14          12 LOAD_GLOBAL              1 (random)
             14 LOAD_METHOD              2 (randint)
             16 LOAD_CONST               2 (0)
             18 LOAD_CONST               1 (10)
             20 CALL_METHOD              2
             22 STORE_FAST               1 (data)

 15          24 LOAD_FAST                1 (data)
             26 LOAD_CONST               3 (5)
             28 COMPARE_OP               0 (<)
             30 POP_JUMP_IF_FALSE       34

 16          32 JUMP_ABSOLUTE            8

 17     >>   34 LOAD_GLOBAL              3 (print)
             36 LOAD_CONST               4 ('data = ')
             38 LOAD_FAST                1 (data)
             40 FORMAT_VALUE             2 (repr)
             42 BUILD_STRING             2
             44 CALL_FUNCTION            1
             46 POP_TOP
             48 JUMP_ABSOLUTE            8
        >>   50 LOAD_CONST               0 (None)
             52 RETURN_VALUE
  • LOAD_GLOBAL 0 (range): 載入全域性變數 range,將其壓入棧頂。
  • LOAD_CONST 1 (10): 載入常量值 10,將其壓入棧頂。
  • CALL_FUNCTION 1: 呼叫棧頂的函式,此處為 range 函式,並傳入一個引數,引數個數為 1。
  • GET_ITER: 獲取迭代器物件。
  • FOR_ITER 40 (to 50): 迭代迴圈的開始,當迭代完成之後將位元組碼的 counter 加上 40 ,也就是跳轉到 50 的位置執行。
  • STORE_FAST 0 (i): 將迭代器的值儲存到區域性變數 i 中。
  • LOAD_GLOBAL 1 (random): 載入全域性變數 random,將其壓入棧頂。
  • LOAD_METHOD 2 (randint): 載入物件 random 的屬性 randint,將其壓入棧頂。
  • LOAD_CONST 2 (0): 載入常量值 0,將其壓入棧頂。
  • LOAD_CONST 1 (10): 載入常量值 10,將其壓入棧頂。
  • CALL_METHOD 2: 呼叫棧頂的方法,此處為 random.randint 方法,並傳入兩個引數,引數個數為 2。
  • STORE_FAST 1 (data): 將方法返回值儲存到區域性變數 data 中。
  • LOAD_FAST 1 (data): 載入區域性變數 data,將其壓入棧頂。
  • LOAD_CONST 3 (5): 載入常量值 5,將其壓入棧頂。
  • COMPARE_OP 0 (<): 執行比較操作 <,將結果壓入棧頂。
  • POP_JUMP_IF_FALSE 34: 如果棧頂的比較結果為假,則跳轉到位元組碼偏移為 34 的位置。
  • JUMP_ABSOLUTE 8: 無條件跳轉到位元組碼偏移為 8 的位置,即迴圈的下一次迭代。
  • LOAD_GLOBAL 3 (print): 載入全域性變數 print,將其壓入棧頂。
  • LOAD_CONST 4 ('data = '): 載入常量字串 'data = ',將其壓入棧頂。
  • LOAD_FAST 1 (data): 載入區域性變數 data,將其壓入棧頂。
  • FORMAT_VALUE 2 (repr): 格式化棧頂的值,並指定格式化方式為 repr。
  • BUILD_STRING 2: 構建字串物件,包含兩個格式化後的值。
  • CALL_FUNCTION 1: 呼叫棧頂的函式,此處為 print 函式,並傳入一個引數,引數個數為 1。
  • POP_TOP: 彈出棧頂的值,也就是函式 print 的返回值,print 函式返回值為 None 。
  • JUMP_ABSOLUTE 8: 無條件跳轉到位元組碼偏移為 8 的位置,即迴圈的下一次迭代。
  • LOAD_CONST 0 (None): 載入常量值 None,將其壓入棧頂。
  • RETURN_VALUE: 返回棧頂的值,即 None。

這段位元組碼實現了一個簡單的迴圈,使用 range 函式生成一個迭代器,然後對迭代器進行遍歷,每次遍歷都會呼叫 random.randint 方法生成一個隨機數並儲存到區域性變數 data 中,然後根據 data 的值進行條件判斷,如果小於 5 則列印 "data = " 和 data 的值,否則繼續下一次迴圈,直到迭代器結束。最後返回 None。

break 關鍵字實現原理

def test_break():
    for i in range(10):
        data = random.randint(0, 10)
        if data < 5:
            break
    return "BREAK"

上面的函式對應的位元組碼如下所示:

 21           0 LOAD_GLOBAL              0 (range)
              2 LOAD_CONST               1 (10)
              4 CALL_FUNCTION            1
              6 GET_ITER
        >>    8 FOR_ITER                28 (to 38)
             10 STORE_FAST               0 (i)

 22          12 LOAD_GLOBAL              1 (random)
             14 LOAD_METHOD              2 (randint)
             16 LOAD_CONST               2 (0)
             18 LOAD_CONST               1 (10)
             20 CALL_METHOD              2
             22 STORE_FAST               1 (data)

 23          24 LOAD_FAST                1 (data)
             26 LOAD_CONST               3 (5)
             28 COMPARE_OP               0 (<)
             30 POP_JUMP_IF_FALSE        8

 24          32 POP_TOP
             34 JUMP_ABSOLUTE           38
             36 JUMP_ABSOLUTE            8

 26     >>   38 LOAD_CONST               4 ('BREAK')
             40 RETURN_VALUE

這段位元組碼與之前的位元組碼相似,但有一些細微的不同。

  • LOAD_GLOBAL 0 (range): 載入全域性變數 range,將其壓入棧頂。
  • LOAD_CONST 1 (10): 載入常量值 10,將其壓入棧頂。
  • CALL_FUNCTION 1: 呼叫函式,函式引數個數為 1。
  • GET_ITER: 從棧頂獲取可迭代物件,並返回迭代器物件。
  • FOR_ITER 28 (to 38): 遍歷迭代器,如果迭代器為空,則跳轉到位元組碼偏移為 38 的位置,即跳出迴圈,否則繼續執行下一條位元組碼。
  • STORE_FAST 0 (i): 將迭代器的當前值儲存到區域性變數 i 中。

接下來的位元組碼與之前的位元組碼相似,都是呼叫 random.randint 方法生成隨機數,並將隨機數儲存到區域性變數 data 中。然後,對區域性變數 data 進行條件判斷,如果小於 5 則跳出迴圈,否則繼續下一次迴圈。不同的是,這裡使用了 POP_TOP 操作來彈出棧頂的值,即格式化後的字串,無需使用。

  • POP_JUMP_IF_FALSE 8: 如果棧頂的值(即 data)不滿足條件(小於 5),則跳轉到位元組碼偏移為 8 的位置,即迴圈的下一次迭代。
  • POP_TOP: 彈出棧頂的值,也就是將迭代器彈出。
  • JUMP_ABSOLUTE 38: 無條件跳轉到位元組碼偏移為 38 的位置,即跳出迴圈。
  • JUMP_ABSOLUTE 8: 無條件跳轉到位元組碼偏移為 8 的位置,即迴圈的下一次迭代。

最後,位元組碼載入了一個常量字串 'BREAK',並透過 RETURN_VALUE 操作將其作為返回值返回。這段位元組碼實現了類似於之前的迴圈,但在滿足條件時使用了 POP_TOP 和跳轉指令來最佳化迴圈的執行。

從上面的分析過程可以看出來 break 的實現也是透過 JUMP_ABSOLUTE 來做到的,直接跳轉到迴圈外部的下一行程式碼。

總結

在本本篇文章當中主要給大家分析了在python當中也迴圈有關的位元組碼,實現迴圈操作的主要是幾個核心的位元組碼 FOR_ITER ,JUMP_ABSOLUTE,GET_ITER 等等。只要深入瞭解了這幾個位元組碼的功能理解迴圈的過程就很簡單了。


本篇文章是深入理解 python 虛擬機器系列文章之一,文章地址:https://github.com/Chang-LeHung/dive-into-cpython

更多精彩內容合集可訪問專案:https://github.com/Chang-LeHung/CSCore

關注公眾號:一無是處的研究僧,瞭解更多計算機(Java、Python、計算機系統基礎、演算法與資料結構)知識。

相關文章