Python高階特性(3): Classes和Metaclasses

熊崽Kevin發表於2014-05-15

Python高階特性(1):Iterators、Generators和itertools

Python高階特性(2):Closures、Decorators和functools

類和物件

類和函式一樣都是Python中的物件。當一個類定義完成之後,Python將建立一個“類物件”並將其賦值給一個同名變數。類是type型別的物件(是不是有點拗口?)。

類物件是可呼叫的(callable,實現了 __call__方法),並且呼叫它能夠建立類的物件。你可以將類當做其他物件那麼處理。例如,你能夠給它們的屬性賦值,你能夠將它們賦值給一個變數,你可以在任何可呼叫物件能夠用的地方使用它們,比如在一個map中。事實上當你在使用map(str, [1,2,3])的時候,是將一個整數型別的list轉換為字串型別的list,因為str是一個類。可以看看下面的程式碼:

正因如此,Python中的“class”關鍵字不像其他語言(例如C++)那樣必須出現在程式碼main scope中。在Python中,它能夠在一個函式中巢狀出現,舉個例子,我們能夠這樣在函式執行的過程中動態的建立類。看程式碼:

請注意,在這裡通過make_class建立的兩個類是不同的物件,因此通過它們建立的物件就不屬於同一個型別。正如我們在裝飾器中做的那樣,我們在類被建立之後手動設定了類名。同樣也請注意所建立類的print_class_name方法在一個closure cell中捕捉到了類的closure和class_name。如果你對closure的概念還不是很清楚,那麼最好去看看前篇,複習一下closures和decorators相關的內容。

Metaclasses

如果類是能夠製造物件的物件,那製造類的物件又該叫做什麼呢(相信我,這並不是一個先有雞還是先有蛋的問題)?答案是元類(Metaclasses)。大部分常見的基礎元類都是type。當輸入一個引數時,type將簡單的返回輸入物件的型別,這就不涉及元類。然而當輸入三個引數時,type將扮演元類的角色,基於輸入引數建立一個類並返回。輸入引數相當簡單:類名,父類及其引數的字典。後面兩者可以為空,來看一個例子:

特別注意第二個引數是一個tuple(語法看起來很奇怪,以逗號結尾)。如果你需要在類中安排一個方法,那麼建立一個函式並且將其以屬性的方式傳遞作為第三個引數,像這樣:

我們可以通過一個可呼叫物件(函式或是類)來自定義元類,這個物件需要三個輸入引數並返回一個物件。這樣一個元類在一個類上實現只要定義了它的__metaclass__屬性。第一個例子,讓我們做一些有趣的事情看看我們能夠用元類做些什麼:

請注意以上的程式碼,C只是簡單地將一個變數引用指向了字串“Hello”。當然了,沒人會在實際中寫這樣的程式碼,這只是為了演示元類的用法而舉的一個簡單例子。接下來我們來做一些更有用的操作。在本系列的第二部分我們曾看到如何使用裝飾器類來記錄目標類每個方法的輸出,現在我們來做同樣的事情,不過這一次我們使用元類。我們借用之前的裝飾器定義:

如你所見,類裝飾器與元類有著很多共同點。事實上,任何能夠用類裝飾器完成的功能都能夠用元類來實現。類裝飾器有著很簡單的語法結構易於閱讀,所以提倡使用。但就元類而言,它能夠做的更多,因為它在類被建立之前就執行了,而類裝飾器則是在類建立之後才執行的。記住這點,讓我們來同時執行一下兩者,請注意執行的先後順序:

元類的一個實際用例

讓我們來考慮一個更有用的例項。假設我們正在構思一個類集合來處理MP3音樂檔案中使用到的ID3v2標籤Wikipedia。簡而言之,標籤由幀(frames)組成,而每幀通過一個四字元的識別碼(identifier)進行標記。舉個例子,TOPE標識了原作者幀,TOAL標識了原專輯名稱等。如果我們希望為每個幀型別寫一個單獨的類,並且允許ID3v2標籤庫使用者自定義他們自己的幀類。那麼我們可以使用元類來實現一個類工廠模式,具體實現方式可以這樣:

當然了,以上的程式碼同樣可以用類裝飾器來完成,以下是對應程式碼:

如你所見,我們可以直接給裝飾器傳遞引數,而元類卻不能。給元類傳遞引數必須通過屬性。正因如此,這裡裝飾器的解決方案更為清晰,同時也更容易維護。然而,同時也需要注意當裝飾器被呼叫的時候,類已經建立完畢,這意味著此時就不能夠修改其屬性了。例如,一旦類建立完成,你就不能夠修改__doc__。來看實際例子:

通過type生成元類

正如我們所說,最基本的元類就是type並且類通常都是type型別。那麼問題很自然來了,type型別本身是一種什麼型別呢?答案也是type。這也就是說type就是它自身的元類。雖然聽起來有點詭異,但這在Python直譯器層面而言是可行的。

type自身就是一個類,並且我們可以從它繼承出新類。這些生成的類也能作為元類,並且使用它們的類可以得到跟使用type一樣的型別。來看以下的例子:

請注意當類建立物件時,元類的__call__函式就被呼叫,進而呼叫type.__call__建立物件。在下一節,我們將把上面的內容融合在一起。

要點集合

假定一個類C自己的元類為my_metaclass並被裝飾器my_class_decorator裝飾。並且,假定my_metaclass本身就是一個類,從type生成。讓我們將上面提到的內容融合到一起做一個總結來顯示C類以及它的物件都是怎麼被建立的。首先,讓我們來看看程式碼:

現在,你可以花幾分鐘時間測試一下你的理解,並且猜一猜列印輸出的順序。

首先,讓我們來看看Python的直譯器是如何閱讀這部分程式碼的,然後我們會對應輸出來加深我們的理解。

1. Python首先看類宣告,準備三個傳遞給元類的引數。這三個引數分別為類名(class_name),父類(parent)以及屬性列表(attributs)。

2. Python會檢查__metaclass__屬性,如果設定了此屬性,它將呼叫metaclass,傳遞三個引數,並且返回一個類。

3. 在這個例子中,metaclass自身就是一個類,所以呼叫它的過程類似建立一個新類。這就意味著my_metaclass.__new__將首先被呼叫,輸入四個引數,這將新建一個metaclass類的例項。然後這個例項的my_metaclass.__init__將被呼叫呼叫結果是作為一個新的類物件返回。所以此時C將被設定成這個類物件。

4. 接下來Python將檢視所有裝飾了此類的裝飾器。在這個例子中,只有一個裝飾器。Python將呼叫這個裝飾器,將從元類哪裡得到的類傳遞給它作為引數。然後這個類將被裝飾器返回的物件所替代。

5. 裝飾器返回的類型別與元類設定的相同。

6. 當類被呼叫建立一個新的物件例項時,因為類的型別是metaclass,因此Python將會呼叫元類的__call__方法。在這個例子中,my_metaclass.__call__只是簡單的呼叫了type.__call__,目的是建立一個傳遞給它的類的物件例項。

7. 下一步type.__call__通過C.__new__建立一個物件。

8. 最後type.__call__通過C.__new__返回的結果執行C.__init__。

9. 返回的物件已經準備完畢。

所以基於以上的分析,我們可以看到呼叫的順序如下:my_metaclass.__new__首先被呼叫,然後是my_metaclass.__init__,然後是my_class_decorator。至此C類已經準備完畢(返回結果就是C)。當我們呼叫C來建立一個物件的時候,首先會呼叫my_metaclass.__call__(任何物件被建立的時候,Python都首先會去呼叫其類的__call__方法),然後C.__new__將會被type.__call__呼叫(my_metaclass.__call__簡單呼叫了type.__call__),最後是C.__init__被呼叫。現在讓我們來看看輸出:

關於元類多說幾句

元類,一門強大而晦澀的技法。在GitHub上搜尋__metaclass__得到的結果多半是指向”cookbook”或其他Python教學材料的連結。一些測試用例(諸如Jython中的一些測試用例),或是其他一些寫有__metaclass__ = type的地方只是為了確保新類被正常使用了。坦白地說,這些用例都沒有真正地使用元類。過濾了下結果,我只能找到兩個地方真正使用了元類:ABCMeta和djangoplugins。

ABCMeta是一個允許註冊抽象基類的元類。如果想了解多些請檢視其官方文件,本文將不會討論它。

對於djangoplugins而言,基本的思想是基於這篇文章article on a simple plugin framework for Python,使用元類是為了建立一個外掛掛載系統。我並沒有對其有深入的研究,不過我感覺這個功能可以使用裝飾器來實現。如果你有相關的想法請在 本文後留言。

總結筆記

通過理解元類能夠幫助我們更深入的理解Python中類和物件的行為,現實中使用它們的情況可能比文中的例子要複雜得多。大部分元類完成的功能都可以使用裝飾器來實現。所以當你的第一直覺是使用元類來解決你的問題,那麼請你停下來先想想這是否必要。如果不是非要使用元類,那麼請三思而行。這會使你的程式碼更易懂,更易除錯和維護。

Good luck :)

相關文章