注意: 虽然 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__ 钩子来做到这一点...” :-)