深入理解 python 虛擬機器:原來虛擬機器是這麼實現閉包的

一無是處的研究僧發表於2023-10-07

深入理解 python 虛擬機器:原來虛擬機器是這麼實現閉包的

在本篇文章當中主要從虛擬機器層面討論函式閉包是如何實現的,當能夠從設計者的層面去理解閉包就再也不用死記硬背一些閉包的概念了,因為如果你理解閉包的設計原理之後,這些都是非常自然的。

根據 wiki 的描述,a closure is a record storing a function together with an environment。所謂閉包就是將函式和環境儲存在一起的記錄。這裡有三個重點一個是函式,一個是環境(簡單說來就是程式當中變數),最後一個需要將兩者組合在一起所形成的東西,才叫做閉包。

Python 中的閉包

我們現在用一種更加直觀的方式描述一下閉包:閉包是指在函式內部定義的函式,它可以訪問外部函式的區域性變數,並且可以在外部函式執行完後繼續使用這些變數。這是因為閉包在建立時會捕獲其所在作用域的變數,然後保持對這些變數的引用。下面是一個詳細的Python閉包示例:

def outer_function(x):
    # 外部函式定義了一個區域性變數 x

    def inner_function(y):
        # 內部函式可以訪問外部函式的區域性變數 x
        return x + y

    # 外部函式返回內部函式的引用,形成閉包
    return inner_function

# 建立兩個閉包例項,分別使用不同的 x 值
closure1 = outer_function(10)
closure2 = outer_function(20)

# 呼叫閉包,它們仍然可以訪問其所在外部函式的 x 變數
result1 = closure1(5)  # 計算 10 + 5,結果是 15
result2 = closure2(5)  # 計算 20 + 5,結果是 25

print(result1)
print(result2)

在上面的示例中,outer_function 是外部函式,它接受一個引數 x,然後定義了一個內部函式 inner_function,它接受另一個引數 y,並返回 x + y 的結果。當我們呼叫 outer_function 時,它返回了一個對 inner_function 的引用,形成了一個閉包。這個閉包可以保持對 x 的引用,即使 outer_function 已經執行完畢。

在上面的例子當中 outer_function 的返回值就是閉包,這個閉包包含函式和環境,函式是 inner_function ,環境就是 x,從程式語義的層面來說返回值是一個閉包,但是如果直接從 Python 層面來看,返回值也是一個函式,現在我們列印兩個閉包看一下結果:

>>> print(closure1)
<function outer_function.<locals>.inner_function at 0x102e17a60>
>>> print(closure2)
<function outer_function.<locals>.inner_function at 0x1168bc430>

從上面的輸出結果可以看到兩個閉包(從 Python 層面來說也是函式)所在的記憶體地址是不一樣的,因此每次呼叫都會返回一個不同的函式(閉包),因此兩個閉包相互不影響。

再來看下面的程式,他們的執行結果是什麼?

def outer_function(x):
	def inner_function(y):
		nonlocal x
		x += 1
		return x + y

	return inner_function


closure1 = outer_function(10)
closure2 = outer_function(20)

result1 = closure1(5)
print(result1)
result1 = closure1(5)
print(result1)
result2 = closure2(5)
print(result2)

輸出結果為:

16
17
26

根據上面的分析 closure1 和 closure2 分別是兩個不同的閉包,兩個閉包的 x 也是各自的 x ,因此前一個閉包的 x 變化並不會影響第二個閉包,所以 result2 的輸出結果為 26。

閉包相關的位元組碼

在正式瞭解閉包相關的位元組碼之前我們首先來重新回顧一下 CodeObject 當中的欄位:

def outer_function(x):
	def inner_function(y):
		nonlocal x
		x += 1
		return x + y

	print(inner_function.__code__.co_freevars)  # ('x',)
	print(inner_function.__code__.co_cellvars)  # ()
	return inner_function


if __name__ == '__main__':
	out = outer_function(1)
	print(outer_function.__code__.co_freevars)  # ()
	print(outer_function.__code__.co_cellvars)  # ('x', )

cellvars 表示在其他函式當中會使用本地定義的變數,freevars 表示本地會使用其他函式定義的變數。在上面的例子當中,outer_function 當中的變數 x 會被 inner_function 使用,而cellvars 表示在其他函式當中會使用本地定義的變數,所以 outer_function 的這個欄位為 ('x', )。如果要了解詳細的資訊可以參考這篇文章 深入理解 python 虛擬機器:位元組碼靈魂——Code obejct

上面的內容我們簡要回顧了一下 CodeObject 當中的兩個非常重要的欄位,這兩個欄位在進行傳遞引數的時候非常重要,當我們在進行函式呼叫的時候,虛擬機器會新建一個棧幀,在進行新建棧幀的過程當中,如果發現 co_cellvars 儲存的字串變數也是函式引數的時候,除了會在區域性變數當中儲存一份引數之外,還會將傳遞過來的引數儲存到棧幀物件的其他位置當中(這裡需要注意一下,CodeObject 當中的 co_freevars 儲存的是字串,也就是變數名,棧幀當中儲存的是變數名字對應的真實物件,也就是函式引數),這麼做的目的是為了方面後面位元組碼 LOAD_CLOSURE 的操作,因為實際虛擬機器儲存的是指向物件的指標,因此浪費不了多少空間。

實際在虛擬機器的棧幀物件當中 freevars 是一個陣列,後續的位元組碼都是會根據陣列下標對這些變數進行操作。

下面我們分析一下和閉包相關的位元組碼操作

def outer_function(x):
	def inner_function(y):
		nonlocal x
		x += 1
		return x + y

	return inner_function


if __name__ == '__main__':
	import dis

	dis.dis(outer_function)

上面的程式碼回輸出 outer_function 和 inner_function 對應的位元組碼:

  2           0 LOAD_CLOSURE             0 (x)
              2 BUILD_TUPLE              1
              4 LOAD_CONST               1 (<code object inner_function at 0x100757a80, file "closure_bytecode.py", line 2>)
              6 LOAD_CONST               2 ('outer_function.<locals>.inner_function')
              8 MAKE_FUNCTION            8 (closure)
             10 STORE_FAST               1 (inner_function)

  7          12 LOAD_FAST                1 (inner_function)
             14 RETURN_VALUE

Disassembly of <code object inner_function at 0x100757a80, file "closure_bytecode.py", line 2>:
  4           0 LOAD_DEREF               0 (x)
              2 LOAD_CONST               1 (1)
              4 INPLACE_ADD
              6 STORE_DEREF              0 (x)

  5           8 LOAD_DEREF               0 (x)
             10 LOAD_FAST                0 (y)
             12 BINARY_ADD
             14 RETURN_VALUE

我們現在來詳細解釋一下上面的位元組碼含義:

  • LOAD_CLOSURE:這個就是從棧幀物件當中載入指定下標的 cellvars 變數,在上面的位元組碼當中就是載入棧幀物件 cellvars 當中下標為 0 的物件,對應的引數就是 x 。也就是將引數 x 載入到棧幀上。
  • BUILD_TUPLE:從棧幀當中彈出 oparg (位元組碼引數) 個引數,並且將這些引數封裝成元祖,在上面的程式當中 oparg = 1 。
  • LOAD_CONST:載入對應的常量到棧幀當中,這裡是會載入兩個常量,分別是函式對應的 CodeObject 和函式名。

在執行完上的位元組碼之後棧幀當中 valuestack 如下所示:

  • MAKE_FUNCTION:這條位元組碼的主要作用是根據上面三個棧裡面的物件建立一個函式,其中最重要的欄位就是 CodeObject 這裡面儲存了函式最重要的程式碼,最下面的元祖就是 inner_function 的 freevars,當虛擬機器在建立函式的時候就已經把這個物件儲存下來了,然後在建立棧幀的時候會將這個物件儲存到棧幀。需要注意的是這裡所儲存的變數就是函式引數 x,他們是同一個物件。這就使得內部函式每次呼叫的時候都可以使用引數 x 。

我們再來看一下函式 inner_function 的位元組碼

  • LOAD_DEREF:這個位元組碼會從棧幀的 freevars 陣列當中載入下標為 oparg 的物件,freevars 就是剛剛在建立函式的時候所儲存的,也就是 outter_function 傳遞給 inner_function 的元祖。直觀的來說就是將外部函式的 x 載入到 valuestack 當中。
  • STORE_DEREF:就是將棧頂的元素彈出,儲存到 cellvars 陣列對應的下標 (oparg) 當中。

後續的位元組碼就很簡單了,這裡不做詳細分析了。

如果上面的過程太複雜,我們在這裡從整體的角度再敘述一下,簡單說來就是當有程式碼呼叫 outer_function 的時候,傳遞進來的引數,會在 outer_function 建立函式 inner_function 的時候當作閉包引數傳遞給 inner_function,這樣 inner_function 就能夠使用 outer_function 的引數了,因此這也不難理解,每次我們呼叫函式 outer_function 都會返回一個新的閉包(實際就是返回的新建立的函式),因為我們每次呼叫函式 outer_function 時,它都會建立一個新的函式,而這些被建立的函式唯一的區別就是他們的閉包引數不同。這也就解釋了再之前的例子當中為什麼兩個閉包他們互不影響,因為函式 outer_function 建立了兩個不同的函式。

總結

在本篇文章當中詳細介紹了閉包的使用例子和使用原理,理解閉包最重要的一點就是函式和環境,也就是和函式繫結在一起的變數。當進行函式呼叫的時候函式就會建立一個新的內部函式,也就是閉包。在虛擬機器內部實現閉包主要是透過函式引數傳遞和函式生成實現的,當執行 MAKE_FUNCTION 建立新函式的時候,會將外部函式的閉包變數 (在文章中就是 x ) 傳遞給內部函式,然後儲存在內部函式當中,之後的每一次呼叫都是用這個變數,從而實現閉包的效果。


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

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

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

相關文章