一步步教你理解Python裝飾器

pythontab發表於2016-07-18

或許你已經用過裝飾器,它的使用方式非常簡單但理解起來困難(其實真正理解的也很簡單),想要理解裝飾器,你需要懂點函數語言程式設計的概念,python函式的定義以及函式呼叫的語法規則等,雖然我沒法把裝飾器變得簡單,但是我希望可以透過下面的步驟讓你由淺入深明白裝飾器是什麼。假定你擁有最基本的Python知識,本文闡述的東西可能對那些在工作中經常接觸Python的人有很大的幫助。下面我們來一步步理解python的裝飾器:

1、函式(Functions)

在Python裡,函式是用def關鍵字後跟一個函式名稱和一個可選的參數列列來建立的,可以用關鍵字return指定返回值。下面讓我們建立和呼叫一個最簡單的函式:

>>> def foo():
...     return 1
>>> foo()
1

該函式的函式體(在Python裡將就是多行語句)是強制性的並且透過縮排來表明。我們可以透過在函式名後面新增雙括號來呼叫函式。


2、作用域(Scope)

在Python中,每個函式都會建立一個作用域。Pythonistas也可能稱函式擁有它們自己的名稱空間(namespace)。這意味著當在函式體裡遇到變數名時,Python首先在該函式的名稱空間中查詢,Python包含了一些讓我們檢視名稱空間的函式。讓我們寫一個簡單的函式來探查一下local和global作用域的區別。

>>> a_string = "This is a global variable"
>>> def foo():
...     print locals()
>>> print globals() # doctest: +ELLIPSIS
{..., 'a_strin': 'This ia a global variable'}
>>> foo() # 2
{}

內建的globals函式返回一個字典物件,它包含所有Python知道的變數名(為了清楚明瞭起見,我已經忽略了一些Python自動建立的變數)。在#2處我呼叫了函式foo,它將函式內部的local namespace裡的內容列印了出來。正如我們看到的foo函式擁有自己的獨立namespace,現在它還是空的。


3、變數解析規則(variable resolution rules)

當然,這並不意味著在函式內部我們不能訪問全域性變數。Python的作用域規則是,變數的建立總會建立一個新的local變數,但是變數的訪問(包括修改)會先查詢local作用域然後順著最鄰近的作用域去尋找匹配。因此,如果我們修改foo函式來讓它列印global變數,結果就會像我們希望的那樣:

>>> a_string = "This is global variable"
>>> def foo():
...     print a_string # 1
>>> foo()
This is a global variable

在#1處,Python在函式中尋找一個local變數,但是沒有找到,然後在global變數中找到了一個同名的變數。


另一方面,如果我們嘗試在函式里給global變數賦值,結果將不如我們所願:

>>> a_string = 'This is a global variable"
>>> def foo():
...     a_string = "test" # 1
...        print locals()
>>> foo()
{'a_string': 'test'}
>>> a_string # 2
'This is a global variable'

正如我們所見,全域性變數可以被訪問到(如果是可變型別,其甚至可以被改變),但是(預設情況下)不能被賦值。在函式內部的#1處我們實際上建立了一個新的local變數,它和全域性變數擁有相同的名字,它將全域性變數給覆蓋了。我們可以透過在foo函式內部列印local namespace來發現到它已經有了一個條目,透過對函式外部的#2處的輸出結果我們可以看到,變數a_string的值根本就沒有被改變。


4、變數的生命週期(Variable lifetime)

也要注意到,變數不僅“生活在”一個名稱空間裡,它們還有生命週期。考慮下面的程式碼:

>>> def foo():
...     x = 1
>>> foo()
>>> print x # 1
Traceback (most recent call last):
...
NameError: name 'x' is not defined

在#1處不僅因為作用域規則引發了問題(儘管這是出現了NameError的原因),而且也出於在Python和許多其它語言裡的函式呼叫實現的原因。此處,我們沒有任何可用的語法來獲取變數x的值——字面上是不存在的。每次當呼叫foo函式時,它的namespace被重新構建,並且當函式結束時被銷燬。


5、函式的引數(Function parameters)

Python允許我們向函式傳遞引數。引數名成為了該函式的local變數。

>>> def foo(x):
...        print locals()
>>> foo(1)
{'x': 1}

Python有許多不同的定義和傳遞函式引數的方法。要想更詳細深入地瞭解請參照the Python documentation on defining functions。這裡我展示一個簡版:函式引數既可以是強制的位置引數(positional parameters)或者是命名引數,引數的預設值是可選的。

>>> def foo(x, y=0): # 1
...     return x - y
>>> foo(3, 1) # 2
2
>>> foo(3) # 3
3
>>> foo() # 4
Traceback (most recent call last):
...
TypeError: foo() takes at least 1 argument (0 given)
>>> foo(y=1, x=3) # 5
2

在#1處我們定義了一個帶有一個位置引數x和一個命名引數y的函式。正如我們看到的,在#2處我們可以透過普通的值傳遞來呼叫函式,即使一個引數(譯者注:這裡指引數y)在函式定義裡被定義為一個命名引數。在#3處我們可以看到,我們甚至可以不為命名引數傳遞任何值就可以呼叫函式——如果foo函式沒有接收到傳給命名引數y的值,Python將會用我們宣告的預設值0來呼叫函式。當然,我們不能漏掉第一個(強制的,定好位置的)引數——#4以一個異常描述了這種錯誤。


都很清晰和直接,不是嗎?下面變得有點兒讓人疑惑——Python也支援函式呼叫時的命名引數而不只是在函式定義時。請看#5處,這裡我們用兩個命名引數呼叫函式,儘管這個函式是以一個命名和一個位置引數來定義的。因為我們的引數有名字,所以我們傳遞的引數的位置不會產生任何影響。 相反的情形當然也是正確的。我們的函式的一個引數被定義為一個命名引數但是我們透過位置傳遞引數—— #4處的呼叫foo(3, 1)將一個3作為第一個引數傳遞給我們排好序的引數x並將第二個引數(整數1)傳遞給第二個引數,儘管它被定義為一個命名引數。


Whoo!這就像用很多話來描述一個非常簡單的概念:函式的引數可以有名稱或者位置。


6、內嵌函式(Nested functions)

Python允許建立巢狀函式,這意味著我們可以在函式內宣告函式並且所有的作用域和宣告週期規則也同樣適用。

>>> def outer():
...     x = 1
...     def inner():
...         print x # 1
...     inner() # 2
...
>>> outer()
1

這看起來稍顯複雜,但其行為仍相當直接,易於理解。考慮一下在#1處發生了什麼——Python尋找一個名為x的local變數,失敗了,然後在最鄰近的外層作用域裡搜尋,這個作用域是另一個函式!變數x是函式outer的local變數,但是和前文提到的一樣,inner函式擁有對外層作用域的訪問許可權(最起碼有讀和修改的許可權)。在#2處我們呼叫了inner函式。請記住inner也只是一個變數名,它也遵從Python的變數查詢規則——Python首先在outer的作用域裡查詢之,找到了一個名為inner的local變數。


7、函式是一等公民(Functions are first class objects in Python)

在Python中,這是一個常識,函式是和其它任何東西一樣的物件。呃,函式包含變數,它不是那麼的特殊!

>>> issubclass(int, object) # all objects in Python inherit from a common baseclass
True
>>> def foo():
...     pass
>>> foo.__class__ # 1>>> issubclass(foo.__class__, object)
True

你也許從沒想到過函式也有屬性,但是在Python中,和其它任何東西一樣,函式是物件。(如果你發覺這令你感到困惑,請等一下,知道你瞭解到在Python中像其它任何東西一樣,class也是物件!)也許正是因為這一點使Python多少有點“學術”的意味——在Python中像其它任何值一樣只是常規的值而已。這意味著你可以將函式作為引數傳遞給函式或者在函式中將函式作為返回值返回!如果你從未考慮過這種事情請考慮下如下的合法Python程式碼:

>>> def add(x, y):
...     return x + y
>>> def sub(x, y):
...     return x - y
>>> def apply(func, x, y): # 1
...     return func(x, y) # 2
>>> apply(add, 2, 1) # 3
3
>>> apply(sub, 2, 1)
1

這個例子對你來說可能也不是太奇怪——add和sub是標準的Python函式,它們都接受兩個值並返回一個計算了的結果。在#1處你可以看到變數接受一個函式就像其它任何普通的變數。在#2處我們呼叫傳入apply的函式——在Python裡雙括號是呼叫運算子,並且呼叫變數名包含的值。在#3處你可以看出在Python中將函式當做值進行傳遞並沒有任何特殊語法——函式名就像任何其它變數一樣只是變數標籤。


你之前可能見過這種行為——Python將函式作為引數經常見於像透過為key引數提供一個函式來自定義sorted內建函式等操作中。但是,將函式作為返回值返回會怎樣呢?請考慮:

>>> def outer():
...     def inner():
...         print "Inside inner"
...     return inner # 1
...
>>> foo = outer() #2
>>> foo # doctest:+ELLIPSIS
<function inner at 0x...>
>>> foo()
Inside inner

這乍看起來有點奇怪。在#1處我返回了變數inner,它碰巧是一個函式標籤。這裡沒有特殊語法——我們的函式返回了inner函式(呼叫outer()函式並不產生可見的執行)。還記得變數的生命週期嗎?每當outer函式被呼叫時inner函式就會重新被定義一次,但是如果inner函式不被(outer)返回那麼當超出outer的作用域後,inner將不復存在了。


在#2處我們可以獲取到返回值,它是我們的inner函式,它被儲存於一個新的變數foo。我們可以看到,如果我們計算foo,它真的包含inner函式,我們可以透過使用呼叫運算子(雙括號,還記得嗎?)來呼叫它。這看起來可能有點怪異,但是到目前為止沒有什麼難以理解,不是麼?挺住,因為接下來的東西將會很怪異。


8、閉包(Closures)

讓我們不從定義而是從另一個程式碼示例開始。如果我們將上一個例子稍加修改會怎樣呢?

>>> def outer():
...     x = 1
...     def inner():
...         print x # 1
...     return inner
>>> foo = outer()
>>> foo.func_closure # doctest: +ELLIPSIS
(<cell at 0x...: int object at 0x...>,)

從上一個例子中我們看到inner是一個由outer返回的函式,儲存於一個名為foo的變數,我們可以透過foo()呼叫它。但是它能執行嗎?讓我們先來考慮一下作用域規則。


一切都依照Python的作用域規則而執行——x是outer函式了一個local變數。當inner在#1處列印x時,Python在inner中尋找一個local變數,沒有找到;然後它在外層作用域即outer函式中尋找並找到了它。


但是自此處從變數生命週期的角度來看又會如何呢?變數x是函式outer的local變數,這意味著只有當outer函式執行時它才存在。只有當outer返回後我們才能呼叫inner,因此依照我們關於Python如何運作的模型來看,在我們呼叫inner的時候x已經不復存在了,那麼某個執行時錯誤可能會出現。


事實與我們的預想並不一致,返回的inner函式的確正常執行。Python支援一種稱為閉包(function closures)的特性,這意味著定義於非全域性作用域的inner函式在定義時記得它們的外層作用域長什麼樣。這可以透過檢視inner函式的func_closure屬性來檢視,它包含了外層作用域裡的變數。


請記住,每次當outer函式被呼叫時inner函式都被重新定義一次。目前x的值沒有改變,因此我們得到的每個inner函式和其它的inner函式擁有相同的行為,但是如果我們將它做出一點改變呢?

>>> def outer(x):
...     def inner():
...         print x # 1
...        return inner
>>> print1 = outer(1)
>>> print2 = outer(2)
>>> print1()
1
>>> print2()
2

從這個例子中你可以看到closures——函式記住他們的外層作用域的事實——可以用來構建本質上有一個硬編碼引數的自定義函式。我們沒有將數字1或者2傳遞給我們的inner函式但是構建了能"記住"其應該列印數字的自定義版本。


closures就是一個強有力的技術——你甚至想到在某些方面它有點類似於物件導向技術:outer是inner的建構函式,x扮演著一個類似私有成員變數的角色。它的作用有很多,如果你熟悉Python的sorted函式的key引數,你可能已經寫過一個lambda函式透過第二項而不是第一項來排序一些列list。也許你現在可以寫一個itemgetter函式,它接收一個用於檢索的索引並返回一個函式,這個函式適合傳遞給key引數。


但是讓我們不要用閉包做任何噩夢般的事情!相反,讓我們重新從頭開始來寫一個decorator!


9、裝飾器(Decorators)

一個decorator只是一個帶有一個函式作為引數並返回一個替換函式的閉包。我們將從簡單的開始一直到寫出有用的decorators。

>>> def outer(some_func):
...        def inner():
...            print "before some_func"
...            ret = some_func() # 1
...            return ret + 1
...        return inner
>>> def foo():
...        return 1
>>> decorated = outer(foo) # 2
>>> decorated()
before some_func
2

請仔細看我們的decorator例項。我們定義了一個接受單個引數some_func的名為outer的函式。在outer內部我們定義了一個名為inner的巢狀函式。inner函式列印一個字串然後呼叫some_func,在#1處快取它的返回值。some_func的值可能在每次outer被呼叫時不同,但是無論它是什麼我們都將呼叫它。最終,inner返回some_func的返回值加1,並且我們可以看到,當我們呼叫儲存於#2處decorated裡的返回函式時我們得到了輸出的文字和一個返回值2而不是我們期望的呼叫foo產生的原始值1.


我們可以說decorated變數是foo的一個“裝飾”版本——由foo加上一些東西構成。實際上,如果我們寫了一個有用的decorator,我們可能想用裝飾後的版本來替換foo,從而可以得到foo的“增添某些東西”的版本。我們可以不用學習任何新語法而做到這一點——重新將包含我們函式的變數進行賦值:

>>> foo = outer(foo)
>>> foo # doctest: +ELLIPSIS
<function inner at 0x...>

現在任何對foo()的呼叫都不會得到原始的foo,而是會得到我們經過裝飾的版本!領悟到了一些decorator的思想嗎?


10、裝飾器的語法糖--@符號(The @ symbol applies a decorator to a function)

Python 2.4透過在函式定義前新增一個@符號實現對函式的包裝。在上面的程式碼示例中,我們用一個包裝了的函式來替換包含函式的變數來實現了包裝。

>>> add = wrapper(add)

這一模式任何時候都可以用來包裝任何函式,但是如果們定義了一個函式,我們可以用@符號像下面示例那樣包裝它:

>>> @wrapper
... def add(a, b):
...     return Coordinate(a.x + b.x, a.y + b.y)

請注意,這種方式和用wrapper函式的返回值來替換原始變數並沒有任何不同,Python只是增添了一些語法糖(syntactic sugar)讓它看起來更明顯一點。


11、*args and **kwargs

我們已經寫了一個有用的decorator,但是它是硬編碼的,它只適用於特定種類的函式——帶有兩個引數的函式。我們函式內部的checker函式接受了兩個引數,然後繼續將引數閉包裡的函式。如果我們想要一個能包裝任何型別函式的decorator呢?讓我們實現一個在不改變被包裝函式的前提下對每一次被包裝函式的呼叫增添一次計數的包裝器。這意味著這個decorator需要接受所有待包裝的任何函式並將傳遞給它的任何引數傳遞給被包裝的函式來呼叫它(被包裝的函式)。


這種情況很常見,所以Python為這一特性提供了語法支援。請確保閱讀Python Tutorial以瞭解更多,但是在函式定義時使用*運算子意味著任何傳遞給函式的額外位置引數最終以一個*作為前導。因此:

>>> def one(*args):
...     print args # 1
>>> one()
()
>>> one(1, 2, 3)
(1, 2, 3)
>>> def two(x, y, *args): # 2
...     print x, y, args
>>> two('a', 'b', 'c')
a b ('c')

第一個函式one只是簡單的將任何(如果有)傳遞給它的位置引數列印出來。正如你在#1處見到的,在函式內部我們只是引用了args變數——*args只是表明在函式定義中位置引數應該儲存在變數args中。Python也允許我們指定一些變數並捕獲到任何在args變數裡的其它引數,正如#2處所示。


*運算子也可以用於函式呼叫中,這時它也有著類似的意義。在呼叫一個函式時帶有一個以*為前導的變數作為參數列示這個變數內容需要被解析然後用作位置引數。再一次以例項來說明:

>>> def add(x, y):
...     return x + y
>>> lst = [1, 2]
>>> add(lst[0], lst[1]) # 1
3
>>> add(*lst) # 2
3

#1處的程式碼抽取出了和#2處相同的引數——在#2處Python為我們自動解析了引數,我們也可以像在#1處一樣自己解析出來。這看起來不錯,*args既表示當呼叫函式是從一個iterable抽取位置引數,也表示當定義一個函式是接受任何額外的位置變數。


當我們引入**時,事情變得更加複雜點,與*表示iterables和位置引數一樣,**表示dictionaries & key/value對。很簡單,不是麼?

>>> def foo(**kwargs):
...     print kwargs
>>> foo()
{}
>>> foo(x=1, y=2)
{'y': 2, 'x': 1}

當我們定義一個函式時我們可以用**kwargs表明所有未捕獲的keyword變數應該被儲存在一個名為kwargs的字典中。前面的例子中的args和本例中的kwargs都不是Python語法的一部分,但是在函式定義時使用這兩個作為變數名時一種慣例。就像一樣,我們可以在函式呼叫時使用\*。

>>> dct = {'x': 1, 'y': 2}
>>> def bar(x, y):
...     rturn x + y
>>> bar(**dct)
3


12、更通用的裝飾器(More generic decorators)

用我們掌握的新“武器”我們可以寫一個decorator用來“記錄”函式的引數。為了簡單起見,我們將其列印在stdout上:

>>> def logger(func):
...     def inner(*args, **kwargs): # 1
...     print "Arguments were: %s, %s" % (args, kwargs)
...     return func(*args, **kwargs) # 2
... return inner

注意到在#1處inner函式帶有任意數量的任何型別的引數,然後在#2處將它們傳遞到被包裝的函式中。這允許我們包裝或者裝飾任何函式。

>>> @logger
... def foo1(x, y=1):
...     return x * y
>>> @logger
... def foo2():
...     return 2
>>> foo1(5, 4)
Arguments were: (5, 4), {}
20
>>> foo1(1)
Arguments were: (1,), {}
1
>>> foo2()
Arguments were: (),{}
2

對函式的呼叫會產生一個"logging"輸出行,也會輸出一個如我們期望的函式返回值。


如果你一直跟到了最後一個例項,祝賀你,你已經理解了decorators了!


相關文章