Python閉包和儲存自由變數

python学习者0發表於2024-10-11

1.閉包:用函式代替類

術語閉包(closure) 來自抽象代數。抽象代數里,一集元素稱為在某個運算(操作)之下封閉,如果將該運算應用於這一集合中的元素,產出的仍然是該集合中的元素。然而在Python社群中還用術語“閉包”表述於此一個毫不相干的概念。注意我們文章中所稱的閉包為Python中的閉包,而非抽象代數中的閉包。

Python中的閉包是一種特殊的被外層函式包圍的內層函式,它能夠在外層函式執行完畢後仍然能獲取在外層函式範圍中、但未繫結到內層函式作用域的自由變數(free variable))。因此閉包可以儲存額外的變數環境,用於在函式呼叫時使用。比如下列程式碼:

def make_printer(msg):
    def printer():
        print(msg)
    return printer

printer = make_printer('Foo!')
printer() #Foo!

在對內層函式printer()進行呼叫時,外層函式make_printer()已經執行完畢,但printer()仍可獲取自由變數msg。

注意,閉包必須要要有對自由變數獲取,如果沒有獲取自由變數,則只是一個普通的巢狀函式,如下圖所示:

def make_printer(msg):
    def printer():
         pass
    return printer
printer = make_printer('Foo!')

那麼閉包有什麼使用價值呢?

有時我們會定義只有一個方法(除了__init__()之外)的類,而這種類可以透過使用閉包(closure)來替代。考慮下面這個例子,這個類允許使用者透過某種模板方案來獲取URL。

from urllib.request import urlopen
class UrlTemplate:
     def __init__(self, template) -> None:
         self.template = template
     def open(self, **kwargs):
         return urlopen(self.template.format_map(kwargs))
    
 yahoo = UrlTemplate('http://finance.yahoo.com/d/quotes.csv?s={names}&f={fields}')
 for line in yahoo.open(names='IBM,AAPL,FB', fields = 'sllclv'):
     print(line.decode('utf-8'))

這個類可以用一個簡單的函式來替代:

def urltempalte(template):
    # 後面用物件再次呼叫函式的時候傳入kwargs
    def opener(**kwargs):
        return urlopen(template.format_map(kwargs))
    return opener

yahoo = urltempalte('http://finance.yahoo.com/d/quotes.csv?s={names}&f={fields}')
for line in yahoo(names='IBM,AAPL,FB', fields = 'sllclv'):
    print(line.decode('utf-8'))

在許多情況下,我們會使用只有單個方法的類的原因是需要儲存額外的狀態給類方法使用。我們上面提到的UrlTemplate類的唯一目的就是將template的值儲存在某處,然後在open()方法中使用它。而使用閉包來解決該問題會更加簡短和優雅,我們使用opener()函式記住引數template的值,然後再隨後的呼叫中使用該值。

所以大家在編寫程式碼中需要附加額外的狀態給函式時,一定要考慮使用閉包。

2.訪問已儲存的自由變數

我們知道閉包函式可以獲取到被額外保留的變數環境。但這些變數對閉包函式來說是自由變數,還未繫結到內層函式的作用域。如果我們嘗試修改這些變數:

def sample():
    n = 0
    # 閉包函式
    def func():
        n += 1
        print("n =", n)
    return func

f = sample()
f()

就會丟擲UnboundLocalError:local variable 'n' referenced before assignment。

此時我們就需要nonlocal關鍵字將自由變數與內層函式繫結,然後就可以對其進行修改了:

def sample():
    n = 0
    # 閉包函式
    def func():
        nonlocal n
        n += 1
        print("n =", n)
    return func

f = sample()
f() # n = 1
f() # n = 2
f2 = sample() # # another instance, with a different stack
f2() # n = 1

更進一步地,我們會透過函式來擴充套件閉包,使得儲存的自由變數可以在外界被訪問和修改。一般來說,儲存的自由變數對外界來說是完全隔離的,如果想要訪問和修改它們,需要編寫存取函式(accessor function, 即getter/setter方法),並將它們做為函式屬性附加到閉包上來提供對儲存的自由變數的訪問支援:

def sample():
    n = 0
    # 閉包函式
    def func():
        print("n =", n)
    
    # 存取函式(accessor function),即getter/setter方法
    def get_n():
        return n

    def set_n(value):
        # 必須要加nolocal才能修改儲存的自由變數
        nonlocal n
        n = value

    # 做為函式屬性附加
    func.get_n = get_n
    func.set_n = set_n
    return func

該演算法測試執行結果如下:

f = sample()
f() # n = 0
f.set_n(10)
f() # n = 10
print(f.get_n()) # 10

可以看到,get_n()set_n()工作起來很像例項的方法。注意一定要將get_n()set_n()做為函式屬性附加上去,否則在呼叫set_n()get_n()就會報錯:'function' object has no attribute 'set_n'。
如果我們希望讓閉包完全模擬成類例項,我們需要架構內層函式複製到一個例項的字典中然後將它返回。

示例如下:

import sys
class ClosureInstance:
    def __init__(self, locals=None) -> None:
        if locals is None:
            locals = sys._getframe(1).f_locals 
            
        # Update instance dictionary with callables
        self.__dict__.update(
            (key, value) for key, value in locals.items() if callable(value)
        )

    # Redirect special methods
    def __len__(self):
        return self.__dict__['__len__']()
    
# Example use
def Stack():
    items = []

    def push(item):
        items.append(item)
    
    def pop():
        return items.pop()
    
    def __len__():
        return len(items)
    
    return ClosureInstance()

下面展示了對應的測試結果:

s = Stack()
print(s) # <__main__.ClosureInstance object at 0x101efc280>
s.push(10)
s.push(20)
s.push('Hello')
print(len(s)) # 3
print(s.pop()) # Hello
print(s.pop()) # 20
print(s.pop()) # 10

用閉包模型類的功能比傳統的類實現方法要快一些。比如我們用下面這個類做為測試對比。

#學習中遇到問題沒人解答?小編建立了一個Python學習交流群:531509025

class Stack2:
    def __init__(self) -> None:
        self.items = []
    
    def push(self, item):
        self.items.append(item)

    def pop(self):
        return self.items.pop()
    
    def __len__(self):
        return len(self.items)

下面是我們的測試結果:

from timeit import timeit
s = Stack()
print(timeit('s.push(1);s.pop()', 'from __main__ import s'))
# 0.98746542
s = Stack2()
print(timeit('s.push(1);s.pop()', 'from __main__ import s'))
# 1.07070521

可以看到採用閉包的版本要快大約8%。因為對於例項而言,測試話費的大部分時間都在對例項變數的訪問上,而閉包要更快一些,因為不用涉及額外的self變數。

不過這種奇技淫巧在程式碼中還是要謹慎使用,因為相比一個真正的類,這種方法其實是相當怪異的。像繼承、屬性、描述符或者類方法這樣的特性在這種方法都是無法使用的。而且我們還需要一些花招才能讓特殊方法正常工作(比如我們上面ClosureInstance中對__len__()的實現)。不過,這仍然是一個非常有學術價值的例子,它告訴我們對閉包內部提供訪問機制能夠實現怎樣的功能。

相關文章