簡單 12 步理解 Python 裝飾器

好吧,我標題黨了。作爲 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 中函數只是常規的值,就像其他任意類型的值一樣。這意味着可以將函數當做實參傳遞給函數,或者在函數中將函數作爲返回值返回。如果你從未想過這樣使用,請看下面的可執行代碼:

這個示例對你來說應該不陌生——add 和 sub 是標準的 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. 閉包

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

從上一個示例可以看到,inner 是 outer 返回的一個函數,存儲在變量 foo 裏然後用 foo() 來調用。但是它能運行嗎?先來思考一下作用域規則。

Python 中一切都按作用域規則運行—— x 是函數 outer 中的一個局部變量,當函數 inner 在 #1 處打印 x 時,Python 在 inner 中搜索局部變量但是沒有找到,然後在外層作用域即函數 outer 中搜索找到了變量 x

但如果從變量的生命週期角度來看應該如何呢?變量 x 對函數 outer 來說是局部變量,即只有當 outer 運行時它才存在。只有當 outer 返回後才能調用 inner,所以依據 Python 運行機制,在調用 inner 時 x 就應該不存在了,那麼這裏應該有某種運行錯誤出現。

結果並不是如此,返回的 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。

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

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

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

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

但我們希望在不修改 onetwo 和 three的基礎上,one 和 two 的差值爲 {x: 0, y: 0}one 和 three 的和爲 {x: 100, y: 200}。接下來用一個邊界檢測裝飾器來實現這一點,而不用對每個函數裏的輸入參數和返回值添加邊界檢測。

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

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

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 中。此前 args 和 kwargs 都不是 Python 中語法的一部分,但在函數定義時使用這兩個變量名是一種慣例。和 * 的使用一樣,可以在函數調用和定義時使用 **

12. 更通用的裝飾器

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

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

每一個函數的調用會有一行日誌輸出和預期的返回值。

再聊裝飾器

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

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



原文地址:http://python.jobbole.com/85056/

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章