總有一天會用到的筆記

本站為減緩筆者下列疑難雜症誕生:記性很差,學過就忘;智商低落,囫圇吞棗。

0%

【Python】閉包(Closure)

由於 Python 中的函數也是物件,因此常常會出現在函數內定義函數、回傳函數的情形。若此時內部函數引用外部函數的區域變數,情況就會變得些微複雜,我們稱之為「閉包(Closure)」。

但只要理解以下 Python 的原理,閉包也能很好理解。

四個原理及示例

一、函數結束時區域變數不會直接被回收,而是等到無人參考。

實際上,被參考的外層變數會被綁到內層變數的 __closure__ 上。這個特性讓我們可以有閉包這種酷酷的操作。

二、呼叫函式時,會創造出一個命名空間

執行 outer 而產生一個命名空間。而因為 inner 參考至它,所以 outer 的命名空間不會被回收掉。

1
2
3
4
5
6
7
8
9
>>> def outer():
... owo = 1
... def inner():
... print(owo)
... return inner
...
>>> func = outer()
>>> func()
1

每次執行函式,都會創造出一個全新的命名空間。這裡要用 nonlocal 是因為,除非加上 nonlocal 關鍵字,外部名稱是唯讀的。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
>>> def outer():
... cnt = 0
... def inner():
... nonlocal cnt
... cnt += 1
... print(cnt)
... return inner
...
>>> f1 = outer()
>>> f2 = outer()
>>> f1(), f1(), f1(), f2(), f2(), f2()
1
2
3
1
2
3

三、作用域是在哪裡 def 決定的,而非在哪裡被呼叫

作用域和搜尋順序關乎於寫在哪裡,而非在哪裡被呼叫:

1
2
3
4
5
6
7
8
9
10
>>> owo = 'starbuststream'
>>> def outer():
... owo = 'mothersrosario'
... def inner():
... print(owo)
... return inner
...
>>> func = outer()
>>> func()
mothersrosario

四、呼叫函數時,會參考至當前時間的命名空間,和宣告時沒關係。

呼叫 f() 時,該函數會以當前的命名空間狀態為主。此時執行完 for 迴圈,名稱 i 在全域中指向 2,因此函數參考外部名稱 i 時會讀取到 2

注意,Python 的迴圈沒有獨立的命名空間,for i in range(3) 在全域新增了名稱 i

1
2
3
4
5
6
7
8
9
10
11
12
13
>>> func_list = []
>>>
>>> for i in range(3):
... def show():
... print(i)
... func_list.append(show)
...
>>> for f in func_list:
... f()
...
2
2
2

解決辦法是在外部再包一層函數。利用迴圈執行 outer,製造出許多獨立的命名空間。每個命名空間裡都會有自己的名稱 j,分別指向物件 012

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
>>> func_list = []
>>> for i in range(3):
... def outer():
... j = i
... def show():
... print(j)
... return show
... func_list.append(outer())
...
>>> for f in func_list:
... f()
...
0
1
2

寫成參數有相同療效:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
>>> func_list = []
>>> for i in range(3):
... def outer(j):
... def show():
... print(j)
... return show
... func_list.append(outer(i))
...
>>> for f in func_list:
... f()
...
0
1
2

另外這裡有一個雷點,以下的程式碼看似可以正常的運作,其實是誤打誤撞:

1
2
3
4
5
6
7
8
9
10
11
12
>>> func_list = []
>>> for i in range(3):
... def show():
... print(i)
... func_list.append(show)
...
>>> for i in range(3):
... func_list[i]()
...
0
1
2

由於使用了 fori 在每次迴圈被指向 012,而執行 func_list[i]() 時,函數又會參考至當前的命名空間,造成了這樣的笑話。若把下面的迴圈改成 j,就會印出 2 2 2 了。

應用

這項技巧經常用在

  • 批量產生函數,如按鈕的 handler
  • 裝飾器(Decorator)

以帶參數的裝飾器做示例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
>>> def deco_fac(message):
... def deco(func):
... def res():
... print(message)
... func()
... return res
... return deco
...
>>> @deco_fac("starbuststream") # 呼叫 deco_fac 會回傳 decorator
... def owo():
... print("kirito")
...
>>> owo()
starbuststream
kirito