Python 中的元类编程,第 3 部分
David Mertz (mertz@gnosis.cx), 开发人员, Gnosis Software, Inc.
2007 年 11 月 22 日
编程时太多的聪明反而会使设计更复杂、代码更脆弱、学习曲线更陡峭,最糟的是,调试也更加困难。Michele 和 David 觉得,这在一定程度上归因于对他们
早期的 Python 元类文章 的接受而引起的聪明过度。在本文中,他们试图帮助程序员避免小聪明,以修正这些错误。
简介
去 年,我参加了 EuroPython 2006 会议。这个会议非常好,组织得很完美,谈话都具有很高的水平,人们也都特别友好。然而,我在这篇文章归属的 Python 社区中注意到了一种令人烦恼的趋势。几乎同时,我的合著者 David Mertz 也在思考一个类似的关于一些提交给 Gnosis Utilities 的补丁程序的问题。这种有争议的趋势就是趋向于耍小聪明。不幸的是,Python 社区的这种聪明以前只局限于 Zope 和 Twisted,现在已变得无处不在。
我们在试验项目和学习过程中并不反对这种聪明。我们的烦恼是,在产品框架上必须符合用户的要求。在本文中,我们希望为避免这种聪明做出小小的贡献,至少在我们比较精通的领域避免元类滥用。
对于本文,我们坚持严肃的立场:我们把在不用元类也能解决问题的情况下使用元类都视为元类滥用。当然,作者的过错也很明显:我们的 关于 Python 中的元类的前几部分 助长了这种做法的流行。Nostra culpa。
使用元编程最普通的情况就是创建具有动态生成的属性和方法的类。跟流行的观点相反,这是一个在大多数时候都不需要 而且不想要 自定义元类的工作。
本文适用于两类读者:普通程序员和聪明的程序员。前者知道一些元编程技巧,但是并没有在大脑中形成具体的概念;后者很聪明,而且理解得深一些。后者 的问题 在于变得聪明很容易,要变得不那么聪明就得花不少时间了。例如,花几个月时间就能理解如何使用元类,但是要花几年时间才能明白如何不 使用它们。
关于类初始化
创建类的过程中,类的所有属性和方法只设置一次。而在 Python 中,方法和属性随时都可以更改,但是只有不遵守规则的程序员才会这样做。
在各种条件下,创建类时,也许想用比简单地运行静态编码更加动态的方法。例如,可能想根据从配置文件读取的参数来设置一些默认的类属性;或者想根据数据库表中的字段来设置类特性。利用强制方式动态自定义类行为最简单的方法是:首先创建类,然后添加方法和属性。
例如,Anand Pillai(我们熟悉的一个优秀程序员)提出了一个到 Gnosis Utilities 的分包 gnosis.xml.objectify
的路径,该分包就是这么做的。一个专门用来保存 “xml 节点对象” 的叫做 gnosis.xml.objectify._XO_
的基类就被许多增强的行为 “装饰” 成如下这样:
清单 1. 基类的动态增强
setattr(_XO_, 'orig_tagname', orig_tagname) setattr(_XO_, 'findelem', findelem) setattr(_XO_, 'XPath', XPath) setattr(_XO_, 'change_pcdata', change_pcdata) setattr(_XO_,'addChild',addChild) |
您可能会非常合理地想到,也可以定义 XO 基类的子类来实现同样的增强。在感觉上这是对的,但 Anand 已经提供了 20 多种可能的增强,并且一些特定的用户可能想要其中的一些增强,但不想要 另外一些增强。有太多的替代方法可以轻易地为每种增强情形创建子类。尽管如此,上面的代码未必是恰到好处的。应该用一个附加到 XO、行为是动态决定的自定义元类来完成上述工作。但是这又让我们回到了希望避免的聪明过度(和不透明性)上。
上述问题的一种干净、漂亮的解决方案可能需要向 Python 添加类装饰器。如果拥有这些装饰器,编写的代码可能就像这样:
清单 2. 向 Python 添加类装饰器
features = [('XPath',XPath), ('addChild',addChild), ('is_root',is_root)] @enhance(features) class _XO_plus(gnosis.xml.objectify._XO_): pass gnosis.xml.objectify._XO_ = _XO_plus |
然而,目前没有这种语法。
当元类变复杂时
表面上看本文除了大惊小怪之外,似乎毫无意义。例如,为什么不直接把 XO 元类定义为 Enhance
,然后就一切 OK 了呢。 Enhance.__init__()
可以为所讨论的特定用途添加所需的任何功能。可能看起来像这样:
清单 3. 将 XO 定义为 Enhance
class _XO_plus(gnosis.xml.objectify._XO_): __metaclass__ = Enhance features = [('XPath',XPath), ('addChild',addChild)] gnosis.xml.objectify._XO_ = _XO_plus |
不幸的是,当考虑到继承时,问题却没有这么简单。一旦为基类定义了一个自定义元类,所有派生类都将继承此元类,所以初始化代码将魔法般隐式地在所有 派生类 上运行。这在特定的情形中可能还不错(例如,假设必须将所有类都注册到您自己的框架中:使用元类可以确保不会忘记注册派生类),然而,在许多情况下则可能 不喜欢这种行为,因为:
- 您相信显式比隐式更好。
- 派生类具有跟基类相同的动态类属性。为每个派生类 再次设置这些属性是一种浪费,因为通过继承它们就会拥有这些属性。如果初始化代码很慢或者需要大量的计 算,那么这一特性就显得特别重要。也许会在元类代码中添加一个检查,以查看是否在父类中设置了这些属性,但是这样会增加负担,并且不会控制所有的类。
- 自定义元类将会使类有些不可思议和不标准:您肯定不想增加元类冲突、“ __slots__ ” 问题、跟扩展类( Zope )斗争和其他复杂问题的几率。元类比很多人认识到的更加脆弱。我们甚至在试验代码中用了四年之后还很少在生产代码中使用它们。
- 您觉得对于类初始化这类简单的工作使用自定义元类是杀鸡用牛刀,所以想要使用一种更为简单的解决方案。
换句话说,只有当想在派生类上运行代码,又不想让用户注意到时,才应该使用自定义元类。如果不属于这种情形,那就跳过元类,使您(和您的用户)的生活更加惬意。
classinitializer 装饰器
本文以下部分可能会被谴责为聪明过度。但是聪明不应该加重用户的负担,只应该加重我们作者的负担。读者可以做一些与我们假设的理想类装饰器类似的事 情,但 是要避免在元类方法中出现的继承及元类冲突问题。我们后面给出的 “不可思议的” 装饰器通常情况下只能增强直观的(但稍微有些难看的)强制方法,并且跟下面的例子在 “精神上相当”:
清单 4. 强制方法
def Enhance(cls, **kw): for k, v in kw.iteritems(): setattr(cls, k, v) class ClassToBeInitialized(object): pass Enhance(ClassToBeInitialized, a=1, b=2) |
上面的强制增强器并不是很坏。但是也有一些缺馅:它要求重复输入类名称;可读性不够理想,因为类定义和类初始化是分开的 —— 长的类定义可能会漏掉最后一行;并且它会认为首先定义一些内容然后又立即更改是不对的。
classinitializer
装饰器提供了一个说明性解决方案。装饰器将 Enhance(cls,**kw)
转换为一个能够用于类定义中的方法:
清单 5. 基本操作中神奇的装饰器
>>> @classinitializer # add magic to Enhance ... def Enhance(cls, **kw): ... for k, v in kw.iteritems(): ... setattr(cls, k, v) >>> class ClassToBeInitialized(object): ... Enhance(a=1, b=2) >>> ClassToBeInitialized.a 1 >>> ClassToBeInitialized.b 2 |
如果使用过 zope 界面,也许见过类初始化器的例子 (zope.interface.implements
)。事实上,classinitializer
是使用一个从 Phillip J. Eby 开创的 zope.interface.advice
复制过来的技巧来实现的。此技巧使用 “ __metaclass__ ” 钩子,但是它不使用 自定义类。ClassToBeInitialized
保留了它原始的元类,即新式类的普通内置元类 type
:
>>> type(ClassToBeInitialized) <type 'type'> |
原则上,此技巧也适用于老式类,并且应该容易编写一个实现来使老式类保持老的样式。然而,由于根据 Guido 所说的 “老式类在精神上是不受赞成的”,当前的实现将老式类转换为新式类:
清单 6. 升级为新式类
>>> class WasOldStyle: ... Enhance(a=1, b=2) >>> WasOldStyle.a, WasOldStyle.b (1, 2) >>> type(WasOldStyle) <type 'type'> |
classinitializer
装饰器的一个动机是要隐藏细节,使一般的人们能够用一种容易的方法实现他们自己的类初始化器,而不必知道类创建工作的细节和_metaclass_ 钩子的秘密。另一个动机是,即使对于 Python 奇才来说,每次编写新的类初始化器时都得重写管理 _metaclass_ 钩子的代码也是很不方便的。
最后应该注意,我们指出 Enhance
的已装饰版本当作类范围外的未装饰版本来运行已经足够漂亮了,假设传递给它一个显式类参数:
>>> Enhance(WasOldStyle, a=2) >>> WasOldStyle.a 2 |
极度不可思议
下面是 classinitializer
的代码。使用装饰器不需要理解该代码:
清单 7. classinitializer 装饰器
import sys def classinitializer(proc): # basic idea stolen from zope.interface.advice, P.J. Eby def newproc(*args, **kw): frame = sys._getframe(1) if '__module__' in frame.f_locals and not / '__module__' in frame.f_code.co_varnames: # we are in a class if '__metaclass__' in frame.f_locals: raise SyntaxError("Don't use two class initializers or/n" "a class initializer together with a __metaclass__ hook") def makecls(name, bases, dic): try: cls = type(name, bases, dic) except TypeError, e: if "can't have only classic bases" in str(e): cls = type(name, bases + (object,), dic) else: # other strange errs, e.g. __slots__ conflicts raise proc(cls, *args, **kw) return cls frame.f_locals["__metaclass__"] = makecls else: proc(*args, **kw) newproc.__name__ = proc.__name__ newproc.__module__ = proc.__module__ newproc.__doc__ = proc.__doc__ newproc.__dict__ = proc.__dict__ return newproc |
从实现上看,类初始化器是如何工作的就变得很清晰了:当在类中调用一个类初始化器时,实际上定义了一个 _metaclass_ 钩子,它将会被这个类的元类(一般是 type
) 调用。元类将创建此类(作为一个新式类)并将其传递给类初始化器过程。
技巧和警告
当类初始化器(重新)定义 _metaclass_ 钩子时,它们不能很好地与显式(与隐式继承的相反)定义 _metaclass_ 钩子的类协作。如果 _metaclass_ 钩子在类初始化器之后 定义,它会静静地 覆盖类初始化器。
清单 8.表项目 index.html主页
>>> class C: ... Enhance(a=1) ... def __metaclass__(name, bases, dic): ... cls = type(name, bases, dic) ... print 'Enhance is silently ignored' ... return cls ... Enhance is silently ignored >>> C.a Traceback (most recent call last): ... AttributeError: type object 'C' has no attribute 'a' |
然而不幸的是,这个问题没有通用的解决方案;我们只是简单地记录。另一方面,如果在 _metaclass_ 钩子之后 调用类初始化器,将会得到异常:
清单 9. 本地元类出现错误
>>> class C: ... def __metaclass__(name, bases, dic): ... cls = type(name, bases, dic) ... print 'calling explicit __metaclass__' ... return cls ... Enhance(a=1) ... Traceback (most recent call last): ... SyntaxError: Don't use two class initializers or a class initializer together with a __metaclass__ hook |
出现错误比静静地覆盖显式的 _metaclass_ 钩子要好。因此,如果试图同时使用两个类初始化器,或者两次调用同一个类初始化器,将导致错误:
清单 10. 双重增强导致了一个问题
>>> class C: ... Enhance(a=1) ... Enhance(b=2) Traceback (most recent call last): ... SyntaxError: Don't use two class initializers or a class initializer together with a__metaclass__ hook |
从好的方面看,继承的 _metaclass_ 钩子和自定义元类的所有问题都被解决了:
清单 11. 有效地增强继承的元类
>>> class B: # a base class with a custom metaclass ... class __metaclass__(type): ... pass >>> class C(B): # class with both custom metaclass AND class initializer ... Enhance(a=1) >>> C.a 1 >>> type(C) <class '_main.__metaclass__'> |
类初始化器并没有干扰到 C
的元类,它继承了基类 B
,并且继承的元类不但不会影响到类初始化器,而且会很好地运行。相反,如果试图在基类中直接调用 Enhance
,则可能会遇到问题。
总结
使用所有这些定义的方法,自定义类初始化将变得更加简单和美观。可能就像下面的清单这么简单:
清单 12. 最简形式的增强
class _XO_plus(gnosis.xml.objectify._XO_): Enhance(XPath=XPath, addChild=addChild, is_root=is_root) gnosis.xml.objectify._XO_ = _XO_plus |
这个例子仍然使用了“注入”,这对普通情况来说有些多余;也就是说,我们将增强的类放回到模块名称空间中的一个特定名称中。这对特定的模块是必要的,但是大多数时候都不需要。在任何情况下,Enhance()
的参数不需要像上面那样固定在代码中,您可以公平地对完全动态的事情使用 Enhance(**feature_set)
。
另一点需要注意的是,Enhance()
函数的功能远不只上面提到的简单版本。装饰器更擅长完成复杂的增强功能。例如,以下是一个将 “记录” 添加到类的 Enhance()
函数:
清单 13. 类增强的变体
@classinitializer def def_properties(cls, schema): """ Add properties to cls, according to the schema, which is a list of pairs (fieldname, typecast). A typecast is a callable converting the field value into a Python type. The initializer saves the attribute names in a list cls.fields and the typecasts in a list cls.types. Instances of cls are expected to have private attributes with names determined by the field names. """ cls.fields = [] cls.types = [] for name, typecast in schema: if hasattr(cls, name): # avoid accidental overriding raise AttributeError('You are overriding %s!' % name) def getter(self, name=name): return getattr(self, '_' + name) def setter(self, value, name=name, typecast=typecast): setattr(self, '_' + name, typecast(value)) setattr(cls, name, property(getter, setter)) cls.fields.append(name) cls.types.append(typecast) |
不同之处在于:(a)什么被增强了;(b)这种方法是如何工作的;(c)基类的工作都保持正交:
清单 14. 自定义记录类
>>> class Article(object): ... # fields and types are dynamically set by the initializer ... def_properties([('title', str), ('author', str), ('date', date)]) ... def __init__(self, values): # add error checking if you like ... for field, cast, value in zip(self.fields, self.types, values): ... setattr(self, '_' + field, cast(value))
>>> a=Article(['How to use class initializers', 'M. Simionato', '2006-07-10']) >>> a.title 'How to use class initializers' >>> a.author 'M. Simionato' >>> a.date datetime.date(2006, 7, 10) |
参考资料
学习
获得产品和技术
- 订购 SEK for Linux,两张 DVD,包含最新的用于 Linux 的 IBM 试用软件,这些软件来自 DB2®、Lotus®、Rational®、Tivoli® 和 WebSphere®。
- 从 developerWorks 下载 IBM 试用软件,构建下一个 Linux 开发项目。
讨论
关于作者