Python 中的閉包和自由變數

海天之涯發表於2022-03-04
1.定義

在函式內部再定義一個函式,並且這個函式用到了外部函式的變數(LEGB),最後返回新建函式的函式名索引,那麼將這樣的能夠訪問其定義時所在的作用域的函式以及用到的一些變數稱之為閉包。被引用的非全域性變數也稱為自由變數 。這個自由變數儲存在外部函式的只讀屬性 __closure__ 中,會與內層函式產生一個繫結關係,也就是自由變數將不會在記憶體中輕易消失。如下例所示:

# 計算函式被呼叫的次數
def counter(FIRST=0):
   -----------------__closure__---------------
   |cnt = [FIRST]                            |  # 之所以選列表是因為作用域問題,詳見後文
   |										 |
   |def add_one():                           |
   |    cnt[0] += 1                          |
   |    return cnt[0]                        |
    ------------------------------------------
    return add_one

# 每當外部函式被呼叫時,都將重新定義內部的函式,而變數 cnt 的值也可能不同
num5 = counter(5)
num10 = counter(10)

print(num5())  # 6
print(num5())  # 7
print(num10())  # 11
print(num10())  # 12

# 如果這個函式僅僅是巢狀函式,那麼它的 __closure__ 應該是 None
print(num5.__closure__)  # (<cell at 0x0163FE30: list object at 0x01514A80>,)
print(num5.__closure__[0].cell_contents)  # 7
print(num10.__closure__[0].cell_contents)  # 12

# 或者通過 __code__.co_freevars 檢視函式中是否有自由變數,如果有自由變數,即為閉包
print(num10.__code__.co_freevars)  # ('cnt',)
2.nonlocal 關鍵字

上面程式碼中的 cnt 變數是一個列表,可變物件,但如果是不可變物件,如:numer、tuple 等呢?

def counter(FIRST=0):
    cnt = FIRST  # number
    
    def add_one():
        cnt += 1
        return cnt
    return add_one

num5 = counter(5)
print(num5.__closure__)
print(num5.__code__.co_freevars)
print(num5())
----------------------------------------------------------------------------
def counter(FIRST=0):
    cnt = (FIRST,)  # tuple
    
    def add_one():
        cnt[0] += 1
        return cnt[0]
    return add_one

num5 = counter(5)
print(num5.__closure__)
print(num5.__code__.co_freevars)
print(num5())

以上例項輸出結果:

None
()
Traceback (most recent call last):
  File "test.py", line, in <module>
    print(num5())
  File "test.py", line, in add_one
    cnt += 1
UnboundLocalError: local variable 'cnt' referenced before assignment
----------------------------------------------------------------------------
(<cell at 0x0180FE10: tuple object at 0x0173A750>,)
('cnt',)
Traceback (most recent call last):
  File "test.py", line, in <module>
    print(num5())
  File "test.py", line, in add_one
    cnt[0] += 1
TypeError: 'tuple' object does not support item assignment

可以看出,此時 cnt 不再是自由變數,而是變成了區域性變數,且提示 UnboundLocalError 未繫結區域性錯誤。為什麼不是自由變數了呢?為什麼列表就沒問題呢?

這是因為 Python 中並沒有要求先宣告一個變數才能使用它,Python 直譯器認為:在函式體內,只要對一個變數進行賦值操作,那麼這個變數就是區域性變數。
Python的模組程式碼執行之前,並不會經過預編譯,模組內的函式體程式碼在執行前會經過預編譯,因此不管變數名的繫結發生在作用域的那個位置,都能被編譯器知道。

而 cnt += 1 相當於 cnt = cnt + 1,對 cnt 進行了賦值操作,所以 Python 直譯器認為 cnt 是函式內的區域性變數,但是執行的時候,先執行 cnt+1 時發現:
因為先前已經認定 cnt 為區域性變數了,現在在區域性作用域內找不到 cnt 的值,也不會再到外部作用域找了,就會報錯。所以說現在 cnt 已經不是自由變數了。

那麼 tuple 型別的 cnt 呢?首先 cnt[0] = cnt[0] + 1,雖然有賦值,但是其左邊也是 cnt[0],cnt 是從外邊作用域索引了的。
所以,你看它顯示的結果:此時,cnt 確實也是自由變數的,但是它是不可變物件啊,所以報了 TypeError 錯誤。這下列表為什麼行,你應該知道了。

或者你使用 nonolocal 關鍵字,這個關鍵字的用法與 global 很像,讓你能夠給外部作用域(非全域性作用域)內的變數賦值。它可以使得一個被賦值的區域性變數變為自由變數,並且 nonlocal宣告的變數發生變化時,__closure__中儲存的值也會發生變化:

def counter(FIRST=0):
    cnt = FIRST  # number
    
    def add_one():
        nonlocal cnt
        cnt += 1
        return cnt
    return add_one

num5 = counter(5)
print(num5.__closure__)
print(num5.__code__.co_freevars)
print(num5())
(<cell at 0x01BFFE30: int object at 0x53E064D0>,)
('cnt',)
6

nonlocal 和 global

def scope_test():
    spam = "test spam"
    
    def do_nonlocal():
        nonlocal spam
        spam = "nonlocal spam"

    def do_global():
        global spam
        spam = "global spam"

    do_nonlocal()
    print("After nonlocal assignment:", spam)  # nonlocal spam 

    do_global()
    print("After global assignment:", spam)  # nonlocal spam
    
scope_test()
print("In global scope:", spam)  # global spam
After nonlocal assignment: nonlocal spam
After global assignment: nonlocal spam
In global scope: global spam
3.注意事項

lambda 自由引數之坑,特別是和列表解析或for迴圈結合使用時。lambda para_list : expression == > def (para_list): return expression

#---CASE1
fs = [lambda j:i*j for i in range(3)]
print([f(2) for f in fs])

#---CASE2
fs = map(lambda i:(lambda j: i*j), range(3))
print([f(2) for f in fs])

#---CASE3
fs = [(lambda i:lambda j:i*j)(i) for i in range(3)]
print([f(2) for f in fs])
[4, 4, 4]
[0, 2, 4]
[0, 2, 4]

首先,CASE1 和 CASE3 顯然都是每迴圈一次,就新增一個 lambda 函式到列表中,不同的是,CASE1 新增的 lambda 函式中的 i 每次並沒有接收 for 迴圈中 i 的值,它只是定義的時候指了下 i,所以說,CASE1 中的幾個 lambda 函式的 i,是最後呼叫的時候,也就是 f(2) 時才到外層作用域找它的值的,此時找到的 i 的值就是裡面 for 迴圈結束時的 i 的值。CASE3 則是一開始定義、新增的時候就給 i 賦好了初值。CASE2 則是因為 map 每次迭代的時候都會將一個可迭代物件的元素傳給了 i,所以 CASE2 裡面的每個 lambda 函式的 i 也是各有各的值的。

像這種 lambda 的自由引數的問題的話,如果你不是故意這麼做的話,還是轉為預設引數的好:

fs = [lambda x: x+i for i in range(3)]
print([f(2) for f in fs])

fs = [lambda x, i=i: x+i for i in range(3)]
print([f(2) for f in fs])
[4, 4, 4]
[2, 3, 4]

另外,就是列表解析裡面的作用域是一個全新的作用域,和普通的 for 迴圈則有所不同:

#---CASE4
fs = [lambda j:i*j for i in range(3)]
print([f(2) for f in fs])

i = 4
print([f(2) for f in fs])

#---CASE5
fs = []

for i in range(3):
    fs.append(lambda j:i*j)
print([f(2) for f in fs])

i = 4
print([f(2) for f in fs])
[10, 10, 10]
[10, 10, 10]
[10, 10, 10]
[8, 8, 8]
4.使用場景
  • 裝飾器

  • 惰性求值,比較常見的是在資料庫訪問的時候,可參考 Django 的 queryset 的實現

  • 需要對某個函式的引數提前賦值的情況;當然也可以使用 functools.parial 的偏函式:functools.partial(func, *args, **kw),返回一個 partial 函式物件。

# y = a*x + b, a 和 b 可能只出現一次, x 會出現多次
def line(a, b, x):
    return a*x + b

print(line(3, 4, 5))
print(line(3, 4, 6))
print(line(7, 4, 5))
print(line(7, 4, 6))

# 2.使用閉包
def line(a, b):
    def value(x):
        return a*x + b
    return value

# y = 3x + 4
line1 = line(3, 4)
print(line1(5))
print(line1(6))
print(line1(7))

# y = 9x + 7
line2 = line(9, 7)
print(line2(5))
print(line2(6))
print(line2(7))

# 3.使用 functools.partial 偏函式
from functools import partial

line3 = partial(line, 3)
print(line3)  # functools.partial(<function line at 0x011237C8>, 3)
print(line3(4, 5))

line4 = partial(line, 3, 4)
print(line4(5))
print(line4(6))
print(line4(7))

line5 = partial(line, 9, 7)
print(line5(5))
print(line5(6))
print(line5(7))

簡單總結functools.partial的作用就是:其能把一個函式的某些引數給固定住(也就是設定預設值),並返回一個新的函式,呼叫這個新函式會更簡單。

···

相關文章