注意: 虽然 JavaScript 对于本网站不是必需的,但您与内容的互动将受到限制。请开启 JavaScript 以获得完整的体验。

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 元类就到此为止;请阅读 MESSExtension 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__ 被实例化,将其构造函数传递给与默认类构造函数或扩展的元类相同的三个参数: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__ 方法,例如示例中稍后创建实例。它返回 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__ 钩子来做…” :-)