注意: 雖然 JavaScript 對於本網站並非必不可少,但您與內容的互動將受到限制。請啟用 JavaScript 以獲得完整體驗。

Python 1.5 中的元類

Python 1.5 中的元類

(又名“殺手級笑話” :-)


附言:閱讀本文可能不是理解此處描述的元類鉤子的最佳方式。請參閱Vladimir Marangozov 釋出的訊息,其中可能對該主題進行了更溫和的介紹。您可能還想在 Deja News 中搜索主題中包含 “metaclass” 且釋出於 1998 年 7 月和 8 月的 comp.lang.python 訊息。)

在以前的 Python 版本中(仍然是 1.5),有一個被稱為“Don Beaudry 鉤子”的東西,以其發明者和倡導者命名。這允許 C 擴充套件提供替代的類行為,從而允許使用 Python 類語法來定義其他類似類的實體。Don Beaudry 在他臭名昭著的 MESS 包中使用了它;Jim Fulton 在他的 擴充套件類包中使用了它。(它也被稱為 “Don Beaudry 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 是一個字典,其中包含在執行類語句期間收集的區域性變數。

請注意,namespace 字典的內容只是在類語句中定義的名稱。一個鮮為人知的事實是,當 Python 執行類語句時,它會進入一個新的區域性名稱空間,並且所有賦值和函式定義都會在此名稱空間中進行。因此,在執行以下類語句之後

class C:
    a = 1
    def f(s): pass
類名稱空間的內容將是 {'a': 1, 'f': <function f ...>}。

關於在 C 中編寫 Python 元類的內容已經足夠了;有關更多資訊,請閱讀MESS擴充套件類的文件。


在 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__,並將其建構函式傳遞給與預設類建構函式或擴充套件元類相同的三個引數:namebasesnamespace

當使用元類時,很容易被實際發生的事情所迷惑,因為我們失去了類和例項之間的絕對區別:類是元類的例項(“元例項”),但在技術上(即在 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__ 方法,例如,在示例中稍後建立的 aninstance。它返回類 Instance 的一個例項,該例項在接下來定義。

類 Instance 用於所有使用 Tracing 元類構建的類的例項,例如 aninstance。它有兩個方法

  • 上面的 Tracing.__call__ 方法呼叫 __init__ 方法來初始化一個新例項。它將類引用儲存為例項變數。它使用一個奇怪的名稱,因為使用者的例項變數(例如示例中稍後的 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)。呼叫後,臨時例項將被丟棄。

  • 當進行建構函式呼叫 BoundMethod(method1, aninstance) 時,會呼叫 __init__ 方法。它只是儲存它的引數。

  • 當呼叫繫結方法例項時,如在 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__ 鉤子來做到這一點...” :-)