建議從這裡下載這篇文章對應的.ipynb檔案和相關資源。這樣你就能在Jupyter中邊閱讀,邊測試文中的程式碼。
Closure(閉包) 和相關實現方案
python中, function本身也是object, 可以直接被作為變數傳入函式或者被作為結果返回(Java等語言就不能這麼幹)。 這種靈活性帶來很多有趣的應用,其中一個就是closure。 上一個最簡單的程式碼的例子。
1 2 3 4 5 6 |
def outer(): a = 1 def inner(b): print(a+b) return inner |
closure的三個基本要素
- 存在一個外層函式(outer)。在outer函式的內部,定義了一個(inner)函式
- inner函式內部使用outer函式中存在的資料, 這個例子中是a
- outer函式返回inner函式
下面例舉幾個可以用到closure的場景
場景一
需要很多功能類似, 但是具體功能又有一定變化的函式。
這時, 可以把outer函式看做是另外一個函式的“製造工廠”, inner函式看做是“工廠”的產出。 通過給outer函式傳入引數, 控制製造出的inner函式的具體行為。
例如有一個自動決策系統,可以接受使用者自己編寫的決策器函式。 這個例子裡面
- 假設決策器只是簡單的判斷傳入資料知否大於某個閾值。
- 假設框架約定, 傳入的決策器函式, 只能接受一個變數。 這個變數是待判斷的數值。
懶方案
預先定義一大堆決策器, 每個決策器中的閾值都不同
1 2 3 4 5 6 7 8 |
def decision_1(x): return x>1 def decision_2(x): return x>2 def decision_3(x): return x>3 |
顯然, 這種方法是不現實的。 我們顯然不可能窮盡所有可能的閾值並且會造成大量的重複程式碼。
closure方案
利用closure
1 2 3 4 |
def decision_factory(threshold): def inner(x): return x>=threshold return inner |
這樣如果要得到不用閾值的決策器, 只要把閾值傳給“工廠”, 讓它“生產”我們需要的決策器函式即可。 我們通過傳入不同的閾值, 可以得到幾百, 幾千個不同的決策器函式, 但是”工廠”函式只需要寫一個即可。
1 2 3 4 |
decision = decision_factory(1) print(decision(0.9),decision(1.1)) False True |
其它方案
實現一個特定的目的,不一定只有一種方案。 為了實現之前的需求,除了用closure, 當然也可以用其它的方式實現。可以先寫一個接受雙引數的函式
1 2 |
def decision(x, threshold): return x>=threshold |
這個函式不符合只接受“待判斷”數值的約定,不過可以用python的partial函式實現。
1 2 |
from functools import partial d = partial(decision, threshold = 1) |
上面這段程式碼把threshold引數固定成1, 並且返回只需要接受x的新函式,起到的功能和closure是一樣的。
1 2 3 |
print(d(0.99),d(1.1)) False True |
如果不想引入functools這個庫, 其實寫個lambda函式也是可以起到一樣的作用的
1 2 3 4 |
d = lambda x:decision(x,1) print(d(0.99),d(1.1)) False True |
場景二
closure的應用
outer函式中的資料, 在每次inner函式被執行的時候, 並不會被清除而是會記憶之前的狀態。 因此可以利用這點,創造一個能記錄狀態的函式。典型的場景是一個計數器。
1 2 3 4 5 6 7 8 9 10 11 12 13 |
def counter_factory(): state = {'count':0} def counter(): state['count'] = state['count']+1 return state['count'] return counter counter = counter_factory() print(counter()) print(counter()) 1 2 |
不錯要注意的是,inner函式只能修改outer函式中mutable型別的資料。 如果嘗試修改mutable型別的資料, 會導致出錯。
1 2 3 4 5 6 7 8 9 |
def counter_factory(): a = 0 def counter(): a = a+1 return a return counter counter = counter_factory() counter() |
1 2 3 4 5 6 7 8 9 10 11 12 13 |
--------------------------------------------------------------------------- UnboundLocalError Traceback (most recent call last) in () ----> 1 counter() in counter() 2 a = 0 3 def counter(): ----> 4 a = a+1 5 return a 6 return counter UnboundLocalError: local variable 'a' referenced before assignment |
object方案
建立一個object, 讓object內部的屬性去記錄自己的狀態
1 2 3 4 5 6 7 8 9 10 11 12 13 14 |
class Counter: def __init__(self): self.count = 0 def run(self): self.count+=1 return self.count counter = Counter() print(counter.run()) print(counter.run()) 1 2 |
通過新增一個__call__特殊method, 可以讓上面的counter像函式那樣被使用, 這樣和closure的例子就更像了。(能夠像函式那樣被呼叫的object稱為functor)
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 |
class Counter: def __init__(self): self.count = 0 def __call__(self): self.run() return self.count def run(self): self.count+=1 return self.count counter = Counter() print(counter()) print(counter()) 1 2 |
各種實現方式對效能的影響
有多種實現方案都能實現“記錄狀態”或是”固定部分行為”, 不同的實現方式對效能的影響如何?以下是一個實驗。
1 2 3 |
import pandas as pd df = pd.read_csv('pid_region.csv', usecols = ['code','region']) df.head() |
為了比較不同版本的程式碼消耗的時間, 先寫一個函式。功能是重複執行函式n次後顯示消耗的時間。
1 2 3 4 5 6 7 8 9 |
def timming(n, func, args): from datetime import datetime t1 = datetime.now() for i in range(n): func(*args) t2 = datetime.now() print("repeat {} times, elapsed time: {} seconds".format(n, (t2-t1).total_seconds())) |
接下來準備好五個版本的程式碼用於比較
雙引數版
1 2 |
def func_1(code, df): return df[df['code']==code]['region'].iloc[0] |
partial函式版
1 2 |
from functools import partial func_2 = partial(func_1, df = df) |
lambda函式版
1 |
func_3 = lambda code:func_1(code, df) |
closure版
1 2 3 4 5 6 |
def outer(df): def inner(code): return df[df['code']==code]['region'].iloc[0] return inner func_4 = outer(df) |
functor版
1 2 3 4 5 6 7 8 9 10 11 12 13 |
class Functor(): def __init__(self, df): self.df = df def __call__(self,code): return df[df['code']==code]['region'].iloc[0] func_5 = Functor(df) def func_6(code): func = lambda code:func_1(code, df) return func(code) |
然後比較一下它們的速度, 各執行1000次
1 2 3 4 5 6 7 8 9 10 11 12 13 14 |
timming(1000, func_1, [310115,df]) timming(1000, func_2, [310115]) timming(1000, func_3, [310115]) timming(1000, func_4, [310115]) timming(1000, func_5, [310115]) timming(1000, func_6, [310115]) repeat 1000 times, elapsed time: 0.539396 seconds repeat 1000 times, elapsed time: 0.564577 seconds repeat 1000 times, elapsed time: 0.571492 seconds repeat 1000 times, elapsed time: 0.631192 seconds repeat 1000 times, elapsed time: 0.556657 seconds repeat 1000 times, elapsed time: 0.619901 seconds |
似乎效果一樣
lambda lazy binding
lambda是不能配合for迴圈批量的”製造”函式的
1 2 3 4 5 6 7 8 9 10 11 |
def add(a, b): return a+b f_list = [lambda a:a+b for b in [10,20,30]] print(f_list[0](1)) print(f_list[1](1)) print(f_list[2](1)) 31 31 31 |
原因是, lambda函式的lazy binding機制, 導致只有lambda函式被呼叫的時候,才會去查詢b的值。這時迴圈已經結束,b的數值固定為20
但是closure函式並不會出現這個問題
1 2 3 4 5 6 7 8 9 10 11 12 13 14 |
def add_closure(b): def add(a): return a+b return add f_list = [add_closure(i) for i in [10,20,30]] print(f_list[0](1)) print(f_list[1](1)) print(f_list[2](1)) 11 21 31 |
如果一定要用lambda函式實現類似效果的話,可以利用預設值來傳入b的數值
1 2 3 4 5 6 7 8 9 |
f_list = [lambda a,b=b:a+b for b in [10,20, 30]] print(f_list[0](1)) print(f_list[1](1)) print(f_list[2](1)) 11 21 31 |
partial版的測試
1 2 3 4 5 6 7 8 |
def add(a,b): return a+b from functools import partial func_list = [partial(add, b = x) for x in [10,20,30]] func_list[0](1) # 11 func_list[1](1) # 21 func_list[2](1) # 31 |
可以看到, partial並沒有lazy binding的問題