淺談Python閉包和late binding機制

從一個小問題開始

前幾天在寫代碼的時候需要在循環中定義函數,卻出現了預料之外的現象。先來看看下面這段簡單的代碼:

def generate_funcs():
	funcs = []
	for i in range(5):
		funcs.append(lambda: print(i))
	return funcs
	
for f in generate_funcs():
	f()

在循環過程中定義函數,我們希望5個函數分別輸出0、1、2、3、4,即期望的輸出是這樣的:

0
1
2
3
4

然而運行結果是下面這樣的:

4
4
4
4
4

大家不禁會納悶兒了,我定義函數的時候明明白白寫着 print(i)i 的值從 0 循環到 4,怎麼執行出來就打印的都是 4 了呢?沒錯,這就是 Python 閉包中的 late binding 機制在“作祟"。這篇文章我們就好好講講 Python 閉包和 late binding 機制,避免大家再次踩坑。

從閉包 (closure) 談起

我們要先介紹嵌套函數 (nested function) 和非局部變量 (non-local variable) 的概念。

定義在函數內部的函數叫做嵌套函數。我們都知道,函數有作用域,嵌套函數既可以訪問自身作用域的變量,也可以訪問外層函數作用域中的變量。看一個例子:

def print_function(msg):
	# printer is a nested function
    def printer():
        print(msg)
    printer()
    
print_function("Hello world!")

輸出爲

Hello world!

這裏 printer 是一個嵌套函數,通過程序輸出我們可以發現 printer 可以訪問到外層函數 print_function 作用域中的 msg 變量。我們通常把函數參數或者定義在函數內部的變量叫做函數的局部變量 (local variable),而對於 printer 這個嵌套函數,msg 可以被訪問到,但並不是 printer 的局部變量(而是外層函數的局部變量),我們把 msg 這樣被嵌套函數訪問的外層函數的局部變量稱爲嵌套函數的非局部變量 (non-local variable)。

我們把上面的程序稍作修改,得到下面的程序:

def print_function(msg):
	# printer is a nested function
    def printer():
        print(msg)
    return printer
    
p = print_function("Hello world!")
p()

輸出依然是

Hello world!

Amazing!縱使我們在得到 p 的時候 print_function 已經執行結束了,msg 看似已經從內存中消失了,但 p 依然能夠訪問到 msg 這個非局部變量!

這,就是閉包的力量。

return printer 語句返回的不僅僅是一個函數,還有一個閉包,這個閉包裏保存了函數要用到的所有非局部變量。事實上,我們可以通過函數對象的 __closure__ 方法查看它的閉包:

>>> p.__closure__
(<cell at 0x7eff9df1ef18: str object at 0x7eff9c5b1630>,)

正像我們之前所說,p 的閉包中存放了它需要用到的非局部變量 msg

閉包有很廣泛的應用,我們可以用閉包來減少全局變量的定義進而提供數據隱藏,還可以提供面向對象的編程風格,使代碼實現更優美。但這並不是本文的重點,在此不再詳述。

late binding 機制

終於進入正題了,late binding 機制是怎麼回事?爲什麼函數的運行結果不符合我們的期望?我們先來看一下 Python 手冊中對於 late binding 機制的解釋:

Python’s closures are late binding. This means that the values of variables used in closures are looked up at the time the inner function is called.

譯:Python 閉包是 late binding 的,即,閉包中用到的變量的值是在內層函數執行的時候纔去查詢的。

原來如此!這時候我們就有一種直覺,問什麼第一個例子中輸出的全都是 4?因爲在每一個 f 函數執行的時候,for i in range(5) 已經執行完畢,i 的值被固定在了 4。我們通過查看閉包內容進一步證明我們的猜想:

def generate_funcs():
	funcs = []
	for i in range(5):
		funcs.append(lambda: print(i))
	return funcs
	
for f in generate_funcs():
	f()
	print(f.__closure__)
	print('cell value {}'.format(f.__closure__[0].cell_contents))

輸出如下

4
(<cell at 0x7f0146578f18: int object at 0x8d5020>,)
cell value 4
4
(<cell at 0x7f0146578f18: int object at 0x8d5020>,)
cell value 4
4
(<cell at 0x7f0146578f18: int object at 0x8d5020>,)
cell value 4
4
(<cell at 0x7f0146578f18: int object at 0x8d5020>,)
cell value 4
4
(<cell at 0x7f0146578f18: int object at 0x8d5020>,)
cell value 4

果不其然!這些函數的閉包裏都是同一個變量,也就是值已經固定在 4 的那個循環變量 i程序並沒有在編譯期計算出 i 的值,而只是保存了 i 的引用,在運行期獲取 i 的值,這導致了上面的結果。

解決方法

我們已經清楚了 late binding 機制導致上面輸出結果的原理,那麼有沒有辦法讓程序輸出期望的結果呢?答案是肯定的。下面幾種方法都是可以的:

使用函數工廠模式(推薦)

def make_printer(i):
	def printer():
		print(i)
	return printer
	
def generate_funcs():
	funcs = []
	for i in range(5):
		funcs.append(make_printer(i))
	return funcs
	
for f in generate_funcs():
	f()

使用函數默認參數

def generate_funcs():
	funcs = []
	for i in range(5):
		funcs.append(lambda i=i: print(i))
	return funcs
	
for f in generate_funcs():
	f()

使用 yield 方法

def generate_funcs():
	for i in range(5):
		yield lambda i=i: print(i)
		
for f in generate_funcs():
	f()

使用 functools 模塊中的 partial 方法

from functools import partial

def generate_funcs():
	funcs = []
	for i in range(5):
		funcs.append(partial(print, i))
	return funcs
	
for f in generate_funcs():
	f()

使用元組

def generate_funcs():
	funcs = (lambda: print(i) for i in range(5))
	return funcs
	
for f in generate_funcs():
	f() 

上面方法的原理都是讓程序在編譯期獲取 i 的值,從而得到我們期望的輸出:

0
1
2
3
4
發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章