20. 新式类引入的新概念

Python新式类引入的新概念

原文by Guido van Rossum 2010-6-21 翻译by kant


[原作者警告:本篇博文很长,且严重技术向]

新式类与经典类表面上很相似,但实际上引入了很多新的概念:

  • 更底层的构造器 new()
  • 描述器(Descriptors),一种访问对象属性的通用方法
  • 静态方法和类方法
  • 属性函数(properties)
  • 装饰器(Python 2.4 中引入)
  • 属性插槽(slots)
  • 新的方法解析顺序(MRO)

接下来,我会逐一介绍这些概念。


1. 更底层的构造器与__new__()方法

一般来说,通过__init__()方法,类可以定义实例创建之后的初始化过程。不过有时候,用户需要自定义实例的创建过程——比如说,从数据库恢复对象数据的时候。虽然有一些模块可以让用户以非常规方式创建实例(比如 new 模块),但 Python 经典类其实并没有提供自定义实例创建的钩子。

因此,新式类引入了__new__()方法,使用户可以控制实例的创建过程。

通过重写这个方法,用户可以实现单例模式,直接返回之前创建的,或者另一个类的实例(比如子类的实例)。

不过,new()还有其它重要用途。比如,在 pickle 模块中,对对象进行反序列化操作时,通过__new__()方法创建实例,直接跳过了__init__()方法。

另外,当用户继承不可变类型时,也经常要用到__new__()方法。在不可变类型中,init()方法是没用的,用户必须在实例创建过程中执行初始化操作——比如说,如果想调整不可变对象的值,就可以通过__new__()方法。


2. 描述器(Descriptors)

绑定方法是经典类的核心概念之一,描述器则是绑定方法的通用化。

在经典类中,当实例调用属性时,如果在实例字典中没有找到该属性,就会在类字典中查找,依然没有的话,就在父类字典中查找,并递归下去。如果属性是在类字典(而不是实例字典)中找到的,解析器就会检查返回的对象,如果返回的是一个函数,Python 不会直接返回,而是再进行一次封装。当用户调用这个封装对象时,Python 会把实例加入并作为第一个参数,然后才调用原始函数。

例如,类 C 有一个实例 x,当用户调用 x.f(0) 时,Python 会首先查找 x 的属性 f,然后以参数 0 调用 f。如果 f 是类 C 定义的一个方法,那么,查找 f 所返回是一个封装函数,类似于如下伪代码:


    ''def bound_f(arg):
    '' return f(x, arg)

用户以参数 0 调用这个封装函数时,实际会传入两个参数,即 x 和 0。正是通过这种方式,类方法才能在运行时获取调用方法的具体实例,即 self 参数。

另一种调用 f 的方式,是通过类 C 直接调用。这样的话,Python 会直接返回函数而不进行封装。换句话说,C.f(x, 0) 和 x.f(0) 是等价的,这种等价是 Python 运行机制中非常关键的一部分。

在经典类中,如果查找属性时找到的不是函数,返回时就不会进行任何操作。于是,用户就可以通过类属性来设置实例变量的默认值。比如说,在上面的例子中,如果类 C 有一个属性 a,值为 1,而 x 的实例字典中没有 a 键,那么 x.a 的值即为 1。而给 x.a 赋值时,会在实例字典中创建一个键 a,之后的调用就会优先调用实例字典中的 a,如果删除 x.a,则其值又恢复为类字典中的 1。


然而,开发者们还是逐渐发现了这种设计的限制。其中一个限制就是,用户无法实现“混血”类,即一部分方法用 Python 实现,一部分用 C 语言实现的类,因为只有 Python 实现的方法才会被封装返回——这是被硬编码在语言中的。另外,用户也无法像在 C++ 和 Java 中一样定义不同种类的方法,比如静态方法。

为解决这个问题,Python 2.2 直接把上面的封装器通用化了。之前直接把封装行为硬编码在语言中,当查找属性时找到的是函数,就进行封装,否则就不封装,现在则把封装行为的控制权交给查找时返回的对象自身(比如前例中的 f)。

如果查找属性时返回的对象有__get__方法,就是一个“描述器”对象。这时,Python 就会调用__get__方法,并将这个方法返回的任意结果作为查找属性的返回值。如果没有__get__方法,就直接返回这个对象。为与之前保持一致,我们在函数对象中增加了__get__方法,这样就不用改动实例属性的查找代码了。而用户通过在自己的类中定义__get__方法,也可以在查找实例属性时,对返回的对象进行任意封装。

此外,既然已经走到这一步,似乎也可以更进一步,让类属性的赋值与删除行为也可以自定义。于是,我们也引入了类似方法来处理 x.a = 1 和 del x.a:如果属性 a 是在类字典(而不是实例字典)中找到的,Python 会检查 a 是否有 setdelete (这里不用__del__方法,因为它已经有完全不同的意思)方法。通过这些方法,描述器对象可以控制属性的获取、赋值与删除行为。

不过,还是要强调,这些自定义方法只对类字典中的属性有效,而不能用于实例字典中的属性。


3. 静态方法、类方法与属性函数(property)

利用描述器机制,Python 2.2 增加了三个预定义类:类方法、静态方法与属性函数。

类方法与静态方法其实只是对函数对象的简单封装,通过实现不同的__get__方法,为函数提供不同的封装器。例如,通过静态方法封装器调用函数时,不会对传入的参数进行调整,而通过类方法封装器调用函数时,传入的参数列表中会增加这个类(而不是实例)作为第一个参数。这两种封装器都可以通过实例进行调用,并保持传入的参数不变。

属性方法也是一种封装器,它让获取与赋值方法本身成为类的一个“属性”,比如有以下这个类:


    ''class C(object):
    '' def set_x(self, value):
    '' self.__x = value

    '' def get_x(self):
    '' return self.__x

属性方法封装器可以让用户在访问 x 时,隐式地调用 get_x 和 set_x 方法。

这三种封装器在最初引入时并没有提供什么特殊句法。当时,同时引入新特性与新句法显然是颇具争议的(总是引起激烈辩论)。因此,为使用这些新特性,用户需要在定义这些方法之后增加额外语句对这些方法进行封装。比如:


    ''class C:
    '' def foo(cls, arg):
    '' ...
    '' foo = classmethod(foo)
    '' def bar(arg):
    '' ...
    '' bar = staticmethod(bar)

属性方法也类似:


    ''class C:
    '' def set_x(self, value):
    '' self.__x = value
    '' def get_x(self):
    '' return self.__x
    '' x = property(get_x, set_x)


4. 装饰器

通过额外语句声明封装器的一个问题是,阅读代码的时候,必须读到最后才知道这个方法到底是类方法还是静态方法(或者用户定义的其它变量)。终于,Python 2.4 引入了新句法,于是用户可以用如下写法:


    ''class C:
    '' @classmethod
    '' def foo(cls, arg):
    '' ...
    '' @staticmethod
    '' def bar(arg):
    '' ...

在函数声明前独占一行的 @ 语句,称为装饰器。(不要与描述器混淆,描述器是指实现了__get__方法的封装器,见上文。)关于 Python 装饰器要采用哪种句法的问题,曾引起过无穷无尽的争论,直到在“终身仁慈独裁者声明(BDFL pronouncement)”中给出定论。(David Beazley 就 BDFL 的历史写过一篇文章,我会单独发布出来)

装饰器是 Python 引入的最成功的特性之一,其应用之广泛,远超我的预计。特别是 web 框架开发者们,使用的尤为经常。有鉴于此,我们在 Python 2.6 中,将装饰器句法的使用从函数定义扩展至类定义。


5. 属性插槽(Slots)

由于描述器的引入,我们得以引入另一项特性,即__slots__属性。

比如,可以这样定义一个类:


    ''class C:
    '' __slots__ = ['x', 'y']
    '' ...

slots__属性主要有几个功能。首先,限制对象的属性,只能用列表中给定的这几个名称。其次,由于对象属性已经是固定的,没有必要再使用实例字典,于是移除了__dict__属性(除非其父类已经有__dict__属性;另外,只要其子类不使用__slots,则子类的实例依然有__dict__属性),实例的每一个属性都存储在一个列表中的固定位置。

因此,每一个插槽属性,其实都是一个描述器,其 set 和 get 方法就是通过索引操作列表中的值。而底层实现又是完全基于 C 语言的,因此非常高效。

有些人可能以为引入__slots__是为了提高代码安全性(通过限制属性名称)。实际上,我的主要目标是提高效率。我担心新式类引入的各项特性都会影响代码效率,特别是描述器的引入,意味着对对象属性的任何操作都要先检查类字典,从而判断这个属性是不是一个描述器,如果是的话,又要通过描述器访问属性,而不是像之前那样直接修改实例字典。而检查类字典之前,依然要先检查实例字典。

因此,__slots__其实是查找对象属性的一种优化方法——你也可以把它当做一种回滚,以防用户对新式类的性能很不满意。后来证明,这其实是没有必要的,不过这时要移除__slots__也很麻烦了。当然,如果合理使用,slots 还是可以提高性能的,特别是要创建非常多实例的情况下,可以节约相当多的内存空间。


关于 Python 的方法解析顺序(MRO),我还是留到下一篇来介绍吧。


发表评论

评论列表,共 0 条评论