簡單 12 步理解 Python 裝飾器

7even發表於2016-05-12

好吧,我標題黨了。作為 Python 教師,我發現理解裝飾器是學生們從接觸後就一直糾結的問題。那是因為裝飾器確實難以理解!想弄明白裝飾器,需要理解一些函數語言程式設計概念,並且要對Python中函式定義和函式呼叫語法中的特性有所瞭解。使用裝飾器非常簡單(見步驟10),但是寫裝飾器卻很複雜。

雖然我沒法讓裝飾器變得簡單,但也許通過將問題進行一步步的講解,可以幫助你更容易理解裝飾器。由於裝飾器較為複雜,文章會比較長,請堅持住!我會盡量使每個步驟簡單明瞭,這樣如果你理解了各個步驟,就能理解裝飾器的原理。本文假定你具備最基礎的 Python 知識,另外本文對工作中大量使用 Python 的人將大有幫助。

此外需要說明的是,本文中 Python 程式碼示例是用 doctest 模組來執行的。程式碼看起來像是互動式 Python 控制檯會話(>>> 表示 Python 語句,輸出則另起一行)。偶然有以“doctest”開頭的“奇怪”註釋——那些只是 doctest 的指令,可以忽略。

1. 函式

在 Python 中,使用關鍵字 def 和一個函式名以及一個可選的引數列表來定義函式。函式使用 return 關鍵字來返回值。定義和使用一個最簡單的函式例子:

函式體(和 Python 中所有的多行語句一樣)由強制性的縮排表示。在函式名後面加上括號就可以呼叫函式。

2. 作用域

在 Python 函式中會建立一個新的作用域。Python 高手也稱函式有自己的名稱空間。也就是說,當在函式體中遇到變數時,Python 會首先在該函式的名稱空間中尋找變數名。Python 有幾個函式用來檢視名稱空間。下面來寫一個簡單函式來看看區域性變數和全域性變數的區別。

內建函式 globals 返回一個包含所有 Python 能識別變數的字典。(為了更清楚的描述,輸出時省略了 Python 自動建立的變數。)在註釋 #2 處,呼叫了 foo 函式,在函式中列印區域性變數的內容。從中可以看到,函式 foo 有自己單獨的、此時為空的名稱空間。

3. 變數解析規則

當然,以上並不意味著我們不能在函式內部使用全域性變數。Python 的作用域規則是, 變數的建立總是會建立一個新的區域性變數但是變數的訪問(包括修改)在區域性作用域查詢然後是整個外層作用域來尋找匹配。所以如果修改 foo 函式來列印全部變數,結果將是我們希望的那樣:

#1 處,Python 在函式 foo 中搜尋區域性變數 a_string,但是沒有找到,然後繼續搜尋同名的全域性變數。

另一方面,如果嘗試在函式裡給全域性變數賦值,結果並不是我們想要的那樣:

從上面程式碼可見,全部變數可以被訪問(如果是可變型別,甚至可以被修改)但是(預設)不能被賦值。在函式 #1 處,實際上是建立了一個和全域性變數相同名字的區域性變數,並且“覆蓋”了全域性變數。通過在函式 foo 中列印區域性名稱空間可以印證這一點,並且發現區域性名稱空間有了一項資料。在 #2 處的輸出可以看到,全域性名稱空間裡變數 a_string 的值並沒有改變。

4. 變數生命週期

值得注意的是,變數不僅是在名稱空間中有效,它們也有生命週期。思考下面的程式碼:

這個問題不僅僅是因為 #1 處的作用域規則(雖然那是導致 NameError 的原因),也與 Python 和很多其他語言中函式呼叫的實現有關。沒有任何語法可以在該處取得變數 x 的值——它確確實實不存在!函式 foo 的名稱空間在每次函式被呼叫時重新建立,在函式結束時銷燬。

5. 函式的實參和形參

Python 允許向函式傳遞引數。形參名在函式裡為區域性變數。

Python 有一些不同的方法來定義和傳遞函式引數。想要深入的瞭解,請參考 Python 文件關於函式的定義。來說一個簡單版本:函式引數可以是強制的位置引數或者可選的有預設值的關鍵字引數。

#1 處,定義了有一個位置引數 x 和一個關鍵字引數 y的函式。接著可以看到,在 #2 處通過普通傳參的方式呼叫該函式——實參值按位置傳遞給了 foo 的引數,儘管其中一個引數是作為關鍵字引數定義的。在 #3 處可以看到,呼叫函式時可以無需給關鍵字引數傳遞實參——如果沒有給關鍵字引數 y 傳值,Python 將使用宣告的預設值 0 為其賦值。當然,引數 x (即位置引數)的值不能為空——在 #4 示範了這種錯誤異常。

都很清楚簡單,對吧?接下來有些複雜了—— Python 支援在函式呼叫時使用關鍵字實參。看 #5 處,雖然函式是用一個關鍵字形參和一個位置形參定義的,但此處使用了兩個關鍵字實參來呼叫該函式。因為引數都有名稱,所以傳遞引數的順序沒有影響。

反過來也是對的。函式 foo 的一個引數被定義為關鍵字引數,但是如果按位置順序傳遞一個實參——在 #2 處呼叫 foo(3, 1),給位置形參 x 傳實參 3 並給第二個形參 y 傳第二個實參(整數 1),儘管 y 被定義為關鍵字引數。

哇哦!說了這麼多看起來可以簡單概括為一點:函式的引數可以有名稱或位置。也就是說這其中稍許的不同取決於是函式定義還是函式呼叫。可以對用位置形參定義的函式傳遞關鍵字實參,反過來也可行!如果還想進一步瞭解請檢視 Python 文件

6. 內嵌函式

Python 允許建立內嵌函式。即可以在函式內部宣告函式,並且所有的作用域和生命週期規則仍然適用。

以上程式碼看起來有些複雜,但它仍是易於理解的。來看 #1 —— Python 搜尋區域性變數 x 失敗,然後在屬於另一個函式的外層作用域裡尋找。變數 x 是函式 outer 的區域性變數,但函式 inner 仍然有外層作用域的訪問許可權(至少有讀和修改的許可權)。在 #2 處呼叫函式 inner。值得注意的是,inner 在此處也只是一個變數名,遵循 Python 的變數查詢規則——Python 首先在 outer 的作用域查詢並找到了區域性變數 inner

7. 函式是 Python 中的一級物件

在 Python 中有個常識:函式和其他任何東西一樣,都是物件。函式包含變數,它並不那麼特殊。

也許你從未考慮過函式可以有屬性——但是函式在 Python 中,和其他任何東西一樣都是物件。(如果對此感覺困惑,稍後你會看到 Python 中的類也是物件,和其他任何東西一樣!)也許這有點學術的感覺——在 Python 中函式只是常規的值,就像其他任意型別的值一樣。這意味著可以將函式當做實參傳遞給函式,或者在函式中將函式作為返回值返回。如果你從未想過這樣使用,請看下面的可執行程式碼:

這個示例對你來說應該不陌生——addsub 是標準的 Python 函式,都是接受兩個值並返回一個計算的值。在 #1 處可以看到變數接收一個就像其他普通變數一樣的函式。在 #2 處呼叫了傳遞給 apply 的函式 fun——在 Python 中雙括號是呼叫操作符,呼叫變數名包含的值。在 #3 處展示了在 Python 中把函式作為值傳參並沒有特別的語法——和其他變數一樣,函式名就是變數標籤。

也許你之前見過這種寫法—— Python 使用函式作為實參,常見的操作如:通過傳遞一個函式給 key 引數,來自定義使用內建函式 sorted。但是,將函式作為值返回會怎樣?思考下面程式碼:

這看起來也許有點怪異。在 #1 處返回一個其實是函式標籤的變數 inner。也沒有什麼特殊語法——函式 outer 返回了並沒有被呼叫的函式 inner。還記得變數的生命週期嗎?每次呼叫函式 outer 的時候,函式 inner 會被重新定義,但是如果函式 ouer 沒有返回 inner,當 inner 超出 outer 的作用域,inner 的生命週期將結束。

#2 處將獲得返回值即函式 inner,並賦值給新變數 foo。可以看到如果鑑定 foo,它確實包含函式 inner,通過使用呼叫操作符(雙括號,還記得嗎?)來呼叫它。雖然看起來可能有點怪異,但是目前為止並沒有什麼很難理解的,對吧?hold 住,因為接下來會更怪異!

8. 閉包

先不著急看閉包的定義,讓我們從一段示例程式碼開始。如果將上一個示例稍微修改下:

從上一個示例可以看到,innerouter 返回的一個函式,儲存在變數 foo 裡然後用 foo() 來呼叫。但是它能執行嗎?先來思考一下作用域規則。

Python 中一切都按作用域規則執行—— x 是函式 outer 中的一個區域性變數,當函式 inner#1 處列印 x 時,Python 在 inner 中搜尋區域性變數但是沒有找到,然後在外層作用域即函式 outer 中搜尋找到了變數 x

但如果從變數的生命週期角度來看應該如何呢?變數 x 對函式 outer 來說是區域性變數,即只有當 outer 執行時它才存在。只有當 outer 返回後才能呼叫 inner,所以依據 Python 執行機制,在呼叫 innerx 就應該不存在了,那麼這裡應該有某種執行錯誤出現。

結果並不是如此,返回的 inner 函式正常執行。Python 支援一種名為函式閉包的特性,意味著 在非全域性作用域定義的 inner 函式在定義時記得外層名稱空間是怎樣的。inner 函式包含了外層作用域變數,通過檢視它的 func_closure 屬性可以看出這種函式閉包特性。

記住——每次呼叫函式 outer 時,函式 inner 都會被重新定義。此時 x 的值沒有變化,所以返回的每個 inner 函式和其它的 inner 函式執行結果相同,但是如果稍做一點修改呢?

從這個示例可以看到閉包——函式記住其外層作用域的事實——可以用來構建本質上有一個硬編碼引數的自定義函式。雖然沒有直接給 inner 函式傳參 1 或 2,但構建了能“記住”該列印什麼數的 inner 函式自定義版本。

閉包是強大的技術——在某些方面來看可能感覺它有點像物件導向技術:outer 作為 inner 的建構函式,有一個類似私有變數的 x。閉包的作用不勝列舉——如果你熟悉 Python中 sorted 函式的引數 key,也許你已經寫過 lambda 函式通過第二項而非第一項來排序一些列表。也可以寫一個 itemgetter 函式,接收一個用於檢索的索引並返回一個函式,然後就能恰當的傳遞給 key 引數了。

但是這麼用閉包太沒意思了!讓我們再次從頭開始,寫一個裝飾器。

9. 裝飾器

裝飾器其實就是一個以函式作為引數並返回一個替換函式的可執行函式。讓我們從簡單的開始,直到能寫出實用的裝飾器。

請仔細看這個裝飾器示例。首先,定義了一個帶單個引數 some_func 的名為 outer 的函式。然後在 outer 內部定義了一個內嵌函式 innerinner 函式將列印一行字串然後呼叫 some_func,並在 #1 處獲取其返回值。在每次 outer 被呼叫時,some_func 的值可能都會不同,但不論 some_func 是什麼函式,都將呼叫它。最後,inner 返回 some_func() 的返回值加 1。在 #2 處可以看到,當呼叫賦值給 decorated 的返回函式時,得到的是一行文字輸出和返回值 2,而非期望的呼叫 foo 的返回值 1。

我們可以說變數 decoratedfoo 的裝飾版——即 foo 加上一些東西。事實上,如果寫了一個實用的裝飾器,可能會想用裝飾版來代替 foo,這樣就總能得到“附帶其他東西”的 foo 版本。用不著學習任何新的語法,通過將包含函式的變數重新賦值就能輕鬆做到這一點:

現在任意呼叫 foo() 都不會得到原來的 foo,而是新的裝飾器版!明白了嗎?來寫一個更實用的裝飾器。

想象一個提供座標物件的庫。它們可能主要由一對對的 xy座標組成。遺憾的是座標物件不支援數學運算,並且我們也無法修改原始碼。然而我們需要做很多數學運算,所以要構造能夠接收兩個座標物件的 addsub 函式,並且做適當的數學運算。這些函式很容易實現(為方便演示,提供一個簡單的 Coordinate 類)。

但是如果 addsub 函式必須有邊界檢測功能呢?也許只能對正座標進行加或減,並且返回值也限制為正座標。如下:

但我們希望在不修改 onetwothree的基礎上,onetwo 的差值為 {x: 0, y: 0}onethree 的和為 {x: 100, y: 200}。接下來用一個邊界檢測裝飾器來實現這一點,而不用對每個函式裡的輸入引數和返回值新增邊界檢測。

裝飾器和之前一樣正常執行——返回了一個修改版函式,但在這次示例中通過檢測和修正輸入引數和返回值,將任何負值的 xy0 來代替,實現了上面的需求。

是否這麼做是見仁見智的,它讓程式碼更加簡潔:通過將邊界檢測從函式本身分離,使用裝飾器包裝它們,並應用到所有需要的函式。可替換的方案是:在每個數學運算函式返回前,對每個輸入引數和輸出結果呼叫一個函式,不可否認,就對函式應用邊界檢測的程式碼量而言,使用裝飾器至少是較少重複的。事實上,如果要裝飾的函式是我們自己實現的,可以使裝飾器應用得更明確一點。

10. 函式裝飾器 @ 符號的應用

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

這種模式可以隨時用來包裝任意函式。但是如果定義了一個函式,可以用 @ 符號來裝飾函式,如下:

值得注意的是,這種方式和簡單的使用 wrapper 函式的返回值來替換原始變數的做法沒有什麼不同—— Python 只是新增了一些語法糖來使之看起來更加明確。

使用裝飾器很簡單!雖說寫類似 staticmethod 或者 classmethod 的實用裝飾器比較難,但用起來僅僅需要在函式前新增 @裝飾器名 即可!

11. args 和 *kwargs

上面我們寫了一個實用的裝飾器,但它是硬編碼的,只適用於特定型別的函式——帶有兩個引數的函式。內部函式 checker 接收兩個引數,然後繼續將引數傳給閉包中的函式。如果我們想要一個能適用任何函式的裝飾器呢?讓我們來實現一個為每次被裝飾函式的呼叫新增一個計數器的裝飾器,但不改變被裝飾函式。這意味著這個裝飾器必須接收它所裝飾的任何函式的呼叫資訊,並且在呼叫這些函式時將傳遞給該裝飾器的任何引數都傳遞給它們。

碰巧,Python 對這種特性提供了語法支援。請務必閱讀 Python Tutorial 以瞭解更多,但在定義函式時使用 * 的用法意味著任何傳遞給函式的額外位置引數都是以 * 開頭的。如下:

第一個函式 one 簡單的列印了傳給它的任何位置引數(如果有)。在 #1 處可以看到,在函式內部只是簡單的用到了變數 args —— *args 只在定義函式時用來表示位置引數將會儲存在變數 args 中。Python 也允許指定一些變數,並捕獲任何在 args 裡的額外引數,如 #2 處所示。

* 符號也可以用在函式呼叫時,在這裡它也有類似的意義。在呼叫函式時,以 * 開頭的變數表示該變數內容需被取出用做位置引數。再舉例如下:

在 #1 處的程式碼和 #2 處的作用相同——可以手動做的事情,在 #2 處 Python 幫我們自動處理了。這看起來不錯,*args 可以表示在呼叫函式時從迭代器中取出位置引數, 也可以表示在定義函式時接收額外的位置引數。

接下來介紹稍微複雜一點的用來表示字典和鍵值對的 **,就像 * 用來表示迭代器和位置引數。很簡單吧?

當定義一個函式時,使用 **kwargs 來表示所有未捕獲的關鍵字引數將會被儲存在字典 kwargs 中。此前 argskwargs 都不是 Python 中語法的一部分,但在函式定義時使用這兩個變數名是一種慣例。和 * 的使用一樣,可以在函式呼叫和定義時使用 **

12. 更通用的裝飾器

用學到的新知識,可以寫一個記錄函式引數的裝飾器。為簡單起見,僅列印到標準輸出:

注意在 #1 處函式 inner 接收任意數量和任意型別的引數,然後在 #2 處將他們傳遞給被包裝的函式。這樣一來我們可以包裝或裝飾任意函式,而不用管它的簽名。

每一個函式的呼叫會有一行日誌輸出和預期的返回值。

再聊裝飾器

如果你一直看到了最後一個例項,祝賀你,你已經理解了裝飾器!你可以用新掌握的知識做更多的事了。

你也許考慮需要進一步的學習:Bruce Eckel 有一篇很讚的關於裝飾器文章,他使用了物件而非函式來實現了裝飾器。你會發現 OOP 程式碼比純函式版的可讀性更好。Bruce 還有一篇後續文章 providing arguments to decorators,用物件實現裝飾器也許比用函式實現更簡單。最後,你可以去研究一下內建包裝函式 functools,它是一個在裝飾器中用來修改替換函式簽名的裝飾器,使得這些函式更像是被裝飾的函式。

相關文章