Python 1.5 中的元類
Python 1.5 中的元類
(又名。殺手級笑話 :-)
(附言:閱讀本文可能不是理解此處描述的元類鉤子的最佳方式。請參閱 Vladimir Marangozov 釋出的訊息,其中可能對該問題進行了更溫和的介紹。您可能還想在 Deja News 中搜索 1998 年 7 月和 8 月釋出到 comp.lang.python 且主題包含“metaclass”的訊息。)
在之前的 Python 版本中(以及 1.5 版中仍然存在),有一個被稱為“Don Beaudry 鉤子”的東西,以其發明者和倡導者命名。它允許 C 擴充套件提供替代的類行為,從而允許使用 Python 類語法來定義其他類實體。Don Beaudry 在他臭名昭著的 MESS 包中使用了它;Jim Fulton 在他的 Extension Classes 包中使用了它。(它也被稱為“Don Beaudry hack”,但這是一種誤稱。它沒有任何 hack 的地方——事實上,它相當優雅和深刻,儘管它有一些黑暗之處。)
(首次閱讀時,您可能希望直接跳到下面“在 Python 中編寫元類”部分中的示例,除非您想讓您的頭爆炸。)
Don Beaudry 鉤子的文件有意保持最少,因為它是一個功能強大的功能,很容易被濫用。基本上,它檢查基類的型別是否可呼叫,如果可呼叫,則呼叫它來建立新類。
注意這兩個間接級別。舉一個簡單的例子
class B:
pass
class C(B):
pass
看第二個類定義,並嘗試理解“基類的型別是可呼叫的”。(順便說一下,型別不是類。有關此主題的更多資訊,請參閱 Python FAQ 中的問題 4.2、4.19,特別是 6.22。)
- 基類是 B;這很簡單。
- 由於 B 是一個類,它的型別是“class”;所以基類的型別是型別“class”。這也被稱為 types.ClassType,假設標準模組
types已被匯入。 - 那麼型別“class”可呼叫嗎?不,因為型別(在核心 Python 中)從不可呼叫。類是可呼叫的(呼叫一個類會建立一個新例項),但型別不是。
所以我們的結論是,在我們的例子中,基類(C)的型別不可呼叫。因此 Don Beaudry 鉤子不適用,並且使用預設的類建立機制(當沒有基類時也使用)。事實上,當只使用核心 Python 時,Don Beaudry 鉤子從不適用,因為核心物件的型別從不可呼叫。
那麼 Don 和 Jim 如何使用 Don 的鉤子呢?編寫一個擴充套件,至少定義兩個新的 Python 物件型別。第一個將是可作為基類使用的“類”物件的型別,以觸發 Don 的鉤子。此型別必須可呼叫。這就是為什麼我們需要第二個型別。一個物件是否可呼叫取決於其型別。因此,型別物件是否可呼叫取決於其型別,這是一個元型別。(在核心 Python 中,只有一個元型別,即型別“type”(types.TypeType),它是所有型別物件的型別,甚至包括它自身。)必須定義一個新的元型別,使類物件的型別可呼叫。(通常,還需要第三個型別,即新的“例項”型別,但這不是絕對要求——新的類型別在呼叫建立例項時可以返回某個現有型別的物件。)
仍然感到困惑嗎?這裡有一個 Don 自己用來解釋元類的簡單方法。取一個簡單的類定義;假設 B 是一個觸發 Don 鉤子的特殊類
class C(B):
a = 1
b = 2
這可以被認為是等同於C = type(B)('C', (B,), {'a': 1, 'b': 2})
如果這對你來說太難理解,這裡是用臨時變數寫出的相同內容creator = type(B) # The type of the base class
name = 'C' # The name of the new class
bases = (B,) # A tuple containing the base class(es)
namespace = {'a': 1, 'b': 2} # The namespace of the class statement
C = creator(name, bases, namespace)
這與沒有 Don Beaudry 鉤子時發生的情況類似,只不過在這種情況下,建立器函式被設定為預設的類建立器。在任何一種情況下,建立器都以三個引數呼叫。第一個引數 name 是新類的名稱(如類語句頂部所示)。bases 引數是基類的元組(如果只有一個基類,如示例所示,則為單例元組)。最後,namespace 是一個字典,包含在類語句執行期間收集的區域性變數。
請注意,名稱空間字典的內容只是在類語句中定義的任何名稱。一個鮮為人知的事實是,當 Python 執行類語句時,它會進入一個新的區域性名稱空間,所有賦值和函式定義都在這個名稱空間中進行。因此,在執行以下類語句之後
class C:
a = 1
def f(s): pass
類名稱空間的內容將是 {'a': 1, 'f': <function f ...>}。但關於在 C 中編寫 Python 元類就到此為止;請閱讀 MESS 或 Extension Classes 的文件以獲取更多資訊。
在 Python 中編寫元類
在 Python 1.5 中,編寫元類需要編寫 C 擴充套件的要求已被取消(當然,您仍然可以這樣做)。除了檢查“基類的型別是否可呼叫”之外,還會檢查“基類是否具有 __class__ 屬性”。如果是,則假定 __class__ 屬性引用一個類。
讓我們重複上面的簡單示例
class C(B):
a = 1
b = 2
假設 B 有一個 __class__ 屬性,這會轉換為C = B.__class__('C', (B,), {'a': 1, 'b': 2})
這與之前完全相同,只是呼叫了 B.__class__ 而不是 type(B)。如果您閱讀了 FAQ 問題 6.22,您會明白,雖然 type(B) 和 B.__class__ 之間存在很大的技術差異,但它們在不同的抽象層次上扮演著相同的角色。或許在未來的某個時刻,它們將真正是相同的東西(到那時,您將能夠從內建型別派生子類)。此時值得一提的是,C.__class__ 和 B.__class__ 是同一個物件,即 C 的元類與 B 的元類相同。換句話說,子類化一個現有類會建立基類的元類的新(元)例項。
回到示例,類 B.__class__ 被例項化,將其建構函式傳遞給與預設類建構函式或擴充套件的元類相同的三個引數:name、bases 和 namespace。
使用元類時,很容易對實際發生的事情感到困惑,因為我們失去了類和例項之間的絕對區別:類是元類的例項(一個“元例項”),但從技術上講(即在 Python 執行時系統看來),元類只是一個類,而元例項只是一個例項。在類語句的末尾,其元例項用作基類的元類被例項化,生成第二個元例項(相同的元類)。然後這個元例項用作一個(正常的,非元)類;類的例項化意味著呼叫元例項,這將返回一個真實的例項。那它是什麼類的例項呢?從概念上講,它當然是我們的元例項的一個例項;但在大多數情況下,Python 執行時系統會將其視為元類用於實現其(非元)例項的幫助類的一個例項...
希望一個例子能讓事情更清楚。假設我們有一個元類 MetaClass1。它的幫助類(用於非元例項)稱為 HelperClass1。我們現在(手動)例項化 MetaClass1 一次以獲得一個空的特殊基類
BaseClass1 = MetaClass1("BaseClass1", (), {})
我們現在可以在類語句中使用 BaseClass1 作為基類class MySpecialClass(BaseClass1):
i = 1
def f(s): pass
此時,MySpecialClass 已定義;它是 MetaClass1 的元例項,就像 BaseClass1 一樣,實際上表達式“BaseClass1.__class__ == MySpecialClass.__class__ == MetaClass1”返回 True。我們現在準備建立 MySpecialClass 的例項。假設不需要建構函式引數
x = MySpecialClass() y = MySpecialClass() print x.__class__, y.__class__print 語句顯示 x 和 y 是 HelperClass1 的例項。這是如何發生的?MySpecialClass 是 MetaClass1 的一個例項(“meta”在這裡無關緊要);當呼叫一個例項時,它的 __call__ 方法被呼叫,並且 MetaClass1 定義的 __call__ 方法可能返回 HelperClass1 的一個例項。
現在讓我們看看如何使用元類——我們用元類能做些什麼,而沒有元類就不能輕易做到?這裡有一個想法:元類可以自動為所有方法呼叫插入跟蹤呼叫。讓我們首先開發一個簡化的例子,不支援繼承或其他“高階”Python 特性(我們稍後會新增這些)。
import types
class Tracing:
def __init__(self, name, bases, namespace):
"""Create a new class."""
self.__name__ = name
self.__bases__ = bases
self.__namespace__ = namespace
def __call__(self):
"""Create a new instance."""
return Instance(self)
class Instance:
def __init__(self, klass):
self.__klass__ = klass
def __getattr__(self, name):
try:
value = self.__klass__.__namespace__[name]
except KeyError:
raise AttributeError, name
if type(value) is not types.FunctionType:
return value
return BoundMethod(value, self)
class BoundMethod:
def __init__(self, function, instance):
self.function = function
self.instance = instance
def __call__(self, *args):
print "calling", self.function, "for", self.instance, "with", args
return apply(self.function, (self.instance,) + args)
Trace = Tracing('Trace', (), {})
class MyTracedClass(Trace):
def method1(self, a):
self.a = a
def method2(self):
return self.a
aninstance = MyTracedClass()
aninstance.method1(10)
print "the answer is %d" % aninstance.method2()
已經糊塗了?意圖是從上到下閱讀。Tracing 類是我們正在定義的元類。它的結構非常簡單。
- 當建立新的 Tracing 例項時會呼叫 __init__ 方法,例如示例中稍後定義的 MyTracedClass 類。它只是將類名、基類和名稱空間作為例項變數儲存。
- 當呼叫 Tracing 例項時會呼叫 __call__ 方法,例如示例中稍後建立例項。它返回 Instance 類的例項,該類在接下來定義。
Instance 類是所有使用 Tracing 元類構建的類的例項所使用的類,例如 aninstance。它有兩個方法
- __init__ 方法從上面的 Tracing.__call__ 方法呼叫,以初始化新例項。它將類引用儲存為例項變數。它使用一個奇怪的名稱,因為使用者的例項變數(例如示例中稍後的 self.a)生活在相同的名稱空間中。
- 當用戶程式碼引用例項中不是例項變數(也不是類變數;但除了 __init__ 和 __getattr__ 之外沒有類變數)的屬性時,會呼叫 __getattr__ 方法。例如,當示例中引用 aninstance.method1 時,它將被呼叫,self 設定為 aninstance,name 設定為字串“method1”。
__getattr__ 方法在 __namespace__ 字典中查詢名稱。如果未找到,則引發 AttributeError 異常。(在一個更真實的示例中,它必須首先也遍歷基類。)如果找到,則有兩種可能性:它要麼是一個函式,要麼不是。如果不是函式,則假定它是一個類變數,並返回其值。如果它是一個函式,我們必須將其“包裝”到另一個幫助類 BoundMethod 的例項中。
需要 BoundMethod 類來實現一個熟悉的功能:當定義一個方法時,它有一個初始引數 self,當它被呼叫時,該引數會自動繫結到相關的例項。例如,aninstance.method1(10) 等同於 method1(aninstance, 10)。在這個呼叫示例中,首先使用以下建構函式呼叫建立一個臨時的 BoundMethod 例項:temp = BoundMethod(method1, aninstance);然後呼叫此例項,如 temp(10)。呼叫之後,臨時例項被丟棄。
- __init__ 方法在建構函式呼叫 BoundMethod(method1, aninstance) 時被呼叫。它只是儲存其引數。
- 當繫結方法例項被呼叫時,如 temp(10),會呼叫 __call__ 方法。它需要呼叫 method1(aninstance, 10)。然而,即使 self.function 現在是 method1 並且 self.instance 是 aninstance,它也不能直接呼叫 self.function(self.instance, args),因為它應該無論傳遞的引數數量如何都能工作。(為簡單起見,已省略對關鍵字引數的支援。)
為了支援任意引數列表,__call__ 方法首先構造一個新的引數元組。方便的是,由於 __call__ 自己的引數列表中的 *args 符號,__call__ 的引數(除了 self)都放在元組 args 中。為了構造所需的引數列表,我們將一個包含例項的單例元組與 args 元組連線起來:(self.instance,) + args。(注意用於構造單例元組的逗號。)在我們的示例中,生成的引數元組是 (aninstance, 10)。
內建函式 apply() 接受一個函式和一個引數元組,並呼叫該函式。在我們的示例中,我們呼叫 apply(method1, (aninstance, 10)),這等同於呼叫 method(aninstance, 10)。
從這裡開始,事情應該很容易理解。示例程式碼的輸出如下所示
calling <function method1 at ae8d8> for <Instance instance at 95ab0> with (10,) calling <function method2 at ae900> for <Instance instance at 95ab0> with () the answer is 10
這是我能想到的最短的、有意義的例子。一個真實的跟蹤元類(例如,下面討論的 Trace.py)需要在兩個維度上更加複雜。
首先,它需要支援更高階的 Python 特性,例如類變數、繼承、__init__ 方法和關鍵字引數。
其次,它需要提供更靈活的方式來處理實際的跟蹤資訊;也許應該可以編寫自己的跟蹤函式來呼叫,也許應該可以根據每個類或每個例項啟用和停用跟蹤,也許還需要一個過濾器,以便只跟蹤感興趣的呼叫;它還應該能夠跟蹤呼叫的返回值(或發生錯誤時引發的異常)。即使 Trace.py 示例也尚未支援所有這些功能。
真實世界的例子
看看我為了自學如何編寫元類而編寫的一些非常初步的例子
- Enum.py
- 這(濫)用類語法作為定義列舉型別的一種優雅方式。生成的類從不例項化——相反,它們的類屬性是列舉值。例如
class Color(Enum): red = 1 green = 2 blue = 3 print Color.red將列印字串“Color.red”,而“Color.red==1”為真,“Color.red + 1”將引發 TypeError 異常。 - Trace.py
- 生成的類的工作方式與標準類非常相似,但透過將特殊類或例項屬性 __trace_output__ 設定為指向檔案,所有對該類方法的呼叫都會被跟蹤。要做到這一點有些困難。這可能應該使用下面的通用元類重新完成。
- Meta.py
- 一個通用元類。這是為了探究元類可以模仿多少標準類行為的嘗試。初步答案似乎是,只要類(或其客戶端)不檢視例項的 __class__ 屬性,也不檢視類的 __dict__ 屬性,一切都很好。內部使用 __getattr__ 使 __getattr__ 鉤子的經典實現變得困難;我們提供了類似的鉤子 _getattr_ 作為替代。(__setattr__ 和 __delattr__ 不受影響。)(XXX 嗯。可以檢測 __getattr__ 的存在並重命名它。)
- Eiffel.py
- 使用上述通用元類實現 Eiffel 風格的前置條件和後置條件。
- Synch.py
- 使用上述通用元類實現同步方法。
- Simple.py
- 上面使用的示例模組。
一種模式似乎正在浮現:幾乎所有這些元類的使用(除了 Enum,它可能更多是可愛而非有用)主要透過在方法呼叫周圍放置包裝器來工作。一個明顯的問題是,不同元類的功能不容易組合,而這實際上會非常有用:例如,我不介意從 Synch 模組的測試執行中獲取跟蹤,而且新增前置條件也會很有趣。這需要更多研究。也許可以提供一個允許可堆疊包裝器的元類...
你可以用元類做的事情
你可以用元類做很多事情。其中大部分也可以透過巧妙地使用 __getattr__ 來完成,但元類使得修改類的屬性查詢行為變得更容易。這是一個不完整的列表。
- 強制不同的繼承語義,例如當派生類重寫時自動呼叫基類方法
- 實現類方法(例如,如果第一個引數不命名為 'self')
- 實現每個例項都用所有類變數的副本初始化
- 實現一種不同的方式來儲存例項變數(例如,在一個儲存在例項之外但由例項的 id() 索引的列表中)
- 自動包裝或攔截所有或某些方法
- 用於跟蹤
- 用於前置條件和後置條件檢查
- 用於同步方法
- 用於自動值快取
- 當屬性是無引數函式時,在引用時呼叫它(以模擬它是例項變數);賦值時也一樣
- 儀器儀表:檢視各種屬性使用了多少次
- __setattr__ 和 __getattr__ 的不同語義(例如,當它們被遞迴使用時停用它們)
- 濫用類語法做其他事情
- 嘗試自動型別檢查
- 委託(或獲取)
- 動態繼承模式
- 方法自動快取
致謝
非常感謝 David Ascher 和 Donald Beaudry 對本文早期草稿的評論。還要感謝 Matt Conway 和 Tommy Burnette 大約三年前在我腦海中播下了元類的種子,儘管當時我的回應是“你可以用 __getattr__ 鉤子來做…” :-)
