PEP487——更简单的自定义类创建

1. 摘要

目前,自定义类的创建需要使用自定义的元类。然而这个自定义的元类会在类的整个生命周期中持续存在,从而可能导致元类冲突。

该PEP建议通过在类主体中使用新的 __init_subclass__ 钩子以及用于初始化属性的钩子来支持广泛的自定义方案。

与实现自定义元类相比,新的机制应该更易于理解和使用,因此应该更全面地介绍Python元类机制的全部功能。

2. 背景

元类是自定义类创建的强大工具。但是,它们存在一个问题,即没有自动合并元类的方法。如果要为一个类使用两个元类,则通常需要手动创建一个将这两个元类合并在一起的新元类。

这种需求常常使用户感到意外:从两个不同的库继承的两个基类继承需要手动创建组合元类,通常情况下,人们对这些库的那些细节完全不感兴趣。如果一个库开始使用以前从未使用过的元类,这将变得更加糟糕。当库本身继续正常工作时,将这些类与另一个库中的类组合在一起的时候就出问题了。

3. 提案

尽管有多种使用元类的方法,但绝大多数用例可分为三类:在类创建后运行一些初始化代码,描述符的初始化以及保持类属性定义的顺序。

通过简单地勾入类的创建就可以轻松实现前两种用例:

  • __init_subclass__ 钩子,用于初始化给定类的所有子类;

  • 创建类时,对类中定义的所有属性(描述符)调用 __set_name__ 勾子;

而第三种用例则是 PEP 520 的主题。

例如,第一个用例如下所示:

>>> class QuestBase:
...    # 该方法是隐式的类方法
       # 该方法的第一个参数cls表示新的子类
...    def __init_subclass__(cls, swallow, **kwargs):
...        cls.swallow = swallow
...        super().__init_subclass__(**kwargs)

>>> class Quest(QuestBase, swallow="african"):
...    pass

>>> Quest.swallow
'african'

基类 object 包含一个空的 __init_subclass__ 方法,该方法用作协作式多重继承的端点。请注意,此方法没有关键字参数,这意味着所有更专门的方法都必须处理所有关键字参数。

这项一般性建议不是一个新主意(十多年前首次提出将其包含在语言定义中,并且类似的机制早已得到Zope的ExtensionClass 的支持),但是情况已经发生了足够的变化。近年来,这个想法值得重新考虑。

提案的第二部分为类属性添加了一个 __set_name__ 初始化器,尤其是当它们是描述符时。描述符是在类的主体中定义的,但它们对该类一无所知,甚至不知道用来访问它们的属性名。描述符确实会在调用 __get__ 方法之后知道自己属于那个类和实例,但仍然不知道用来访问它们的属性名。不幸的是,例如,由于它们不知道该属性名,因此不能将与自己关联的值放入与它们关联的对象的字典中名称为访问它们的属性名的项下。这个问题已经解决了很多次,并且是在库中拥有元类的最重要原因之一。尽管使用提案的第一部分来实现这种机制很容易,但为每个人提供一个解决此问题的方法是有意义的。

为了说明其用法,请想象一个表示弱引用值的描述符:

import weakref

class WeakAttribute:
    def __get__(self, instance, owner):
        return instance.__dict__[self.name]()

    def __set__(self, instance, value):
        instance.__dict__[self.name] = weakref.ref(value)

    # 新的初始化器:
    def __set_name__(self, owner, name):
        self.name = name

例如,可以在一种树结构中使用这种 WeakAttribute 来避免通过父级进行循环引用:

class TreeNode:
    parent = WeakAttribute()

    def __init__(self, parent):
        self.parent = parent

请注意,parent 属性的用法与普通属性一样,但是树不包含循环引用,因此在不使用时可以很容易地进行垃圾回收。一旦父级停止存在,父级属性就会神奇地变为 None

尽管此示例看起来很琐碎,但应注意的是,直到现在,如果不使用元类,就无法定义这样的属性。考虑实现这样的元类非常复杂,因此这种属性目前还不存在。

初始化描述符可以简单地在 __init_subclass__ 钩子中完成。但这意味着描述符只能在具有适当钩子的类中使用,而该示例中的通用版本通常无法正常工作。也可以从 object.__init_subclass__ 的基本实现中调用 __set_name__。但是鉴于忘记调用 super() 是一个很常见的错误,因此经常发生描述符没有被初始化的情况。

4. 主要的好处

4.1 定义时行为更易继承

要了解Python的元类,需要对类型系统和类构造过程有深入的了解。由于需要在脑海中清楚地区分多个活动部分(代码,元类提示,实际元类,类对象,类对象的实例),因此,这在正常情况下被视为具有挑战性。即使您了解规则,但如果不十分小心,仍然容易犯错。

了解所提出的隐式类初始化钩子仅需要普通方法继承,这并不像一项艰巨的任务。新的钩子为理解类定义过程中涉及的所有阶段提供了更为渐进的路径。

4.2 减少元类冲突的机会

使库作者不愿使用元类(即使它们是适当的)的一大问题是元类冲突的风险。每当类定义的期望父级使用两个不相关的元类时,就会发生这种情况。这种风险还使得很难将一个元类添加到以前已经发布的没有元类的类中。

相比之下,向现有类型添加 __init_subclass__ 方法所带来的风险与添加 __init__ 方法的风险相似:从技术上讲,有可能会破坏实现得很差的子类,但是当发生这种情况时,它被认为是子类中的错误,而不是库作者违反了向后兼容性保证。

5. 类的新的使用方法

5.1 子类注册

尤其是在编写插件系统时,人们喜欢注册插件基类的新子类。可以按以下步骤完成:

class PluginBase:
    subclasses = []

    def __init_subclass__(cls, **kwargs):
        super().__init_subclass__(**kwargs)
        cls.subclasses.append(cls)

在此示例中,PluginBase.subclasses 将包含整个继承树中所有子类的列表。应该注意的是,这作为混入类也很不错。

5.2 特性描述符

野外有许多设计的Python描述符,例如,检查值的边界。通常,这些 “特征” 需要元类的某种支持才能起作用。使用此 PEP 实现同样功能的示例如下:

class Trait:
    def __init__(self, minimum, maximum):
        self.minimum = minimum
        self.maximum = maximum

    def __get__(self, instance, owner):
        return instance.__dict__[self.key]

    def __set__(self, instance, value):
        if self.minimum < value < self.maximum:
            instance.__dict__[self.key] = value
        else:
            raise ValueError("value not in range")

    def __set_name__(self, owner, name):
        self.key = name

6. 实现细节

勾子按以下顺序调用:

  1. type.__new__ 在初始化新类后调用描述符的 __set_name__ 勾子。

  2. 然后,在基类(确切的说,是 super() )上调用 __init_subclass__勾子。

这意味着子类的初始化器已经看到了完全初始化的描述符。这样,使用 __init_subclass__ 的用户就可以在需要时再次修复所有描述符。

另一个选择是在 object.__init_subclass__ 的基本实现中调用 __set_name__。这样甚至可以防止 __set_name__ 被调用。但是,在大多数情况下,这种阻止是偶然的,因为经常会忘记对 super() 的调用。

第三种选择是,所有工作都可以在 type.__init__ 中完成。大多数元类在 __new__ 中进行工作,这是文档建议的。许多元类在将其参数传递给 super().__new__之 前先对其进行了修改。为了与这种类兼容,应从 __new__ 调用钩子。

应该做的另一个小变化:在CPython的当前实现中,type.__init__ 明确禁止使用关键字参数,而 type.__new__ 允许将其属性作为关键字参数提供。这是很奇怪且不连贯的,因此应该禁止。虽然可以保留当前行为,但如果将其修复,则可能会更好,因为它可能根本不使用:唯一的用例是在元类中使用 namebasesdict (是的,是 dict,而不是 namespacens,因为现代元类通常使用 ns)作为关键字参数调用 super().__new__。不应这样做。这个小小的改变大大简化了该PEP的实现,同时提高了Python的整体一致性。

作为第二个更改,新的 type.__init__ 仅忽略关键字参数。当前,它坚持没有给出关键字参数。如果元类不处理类声明的关键字参数,则会导致(想要的)错误。确实希望接受关键字参数的元类作者必须通过重写 __init__ 来将其过滤掉。

在新代码中,不是 __init__ 抱怨关键字参数,而是 __init_subclass__,其默认实现不带参数。在使用方法解析顺序的经典继承方案中,每个 __init_subclass__ 都可以取出其关键字参数,直到没有参数为止,这由 __init_subclass__ 的默认实现检查。

对于喜欢阅读Python代码而非英语的读者,本PEP建议用以下内容替换当前的 typeobject

class NewType(type):
    def __new__(cls, *args, **kwargs):
        if len(args) != 3:
            return super().__new__(cls, *args)
        name, bases, ns = args
        init = ns.get('__init_subclass__')
        if isinstance(init, types.FunctionType):
            ns['__init_subclass__'] = classmethod(init)
        self = super().__new__(cls, name, bases, ns)
        for k, v in self.__dict__.items():
            func = getattr(v, '__set_name__', None)
            if func is not None:
                func(self, k)
        super(self, self).__init_subclass__(**kwargs)
        return self

    def __init__(self, name, bases, ns, **kwargs):
        super().__init__(name, bases, ns)

class NewObject(object):
    @classmethod
    def __init_subclass__(cls):
        pass

7. 参考实现

本提案的参考实现附加在 issue 27366

8. 向后兼容性问题

type.__new__ 中的确切调用顺序稍有更改,增加了对向后兼容性的担心。通过测试应确保常见用例的行为符合预期。

由于传递了多余的类参数,以下类定义(不包括定义元类的代码)仍然会失败,并引发 TypeError

class MyMeta(type):
    pass

class MyClass(metaclass=MyMeta, otherarg=1):
    pass

MyMeta("MyClass", (), otherargs=1)

import types
types.new_class("MyClass", (), dict(metaclass=MyMeta, otherarg=1))
types.prepare_class("MyClass", (), dict(metaclass=MyMeta, otherarg=1))

现在,仅定义对关键字参数感兴趣的 __new__ 方法的元类不再需要定义 __init__ 方法,因为 type.__init__ 会默认忽略关键字参数。这与在元类中替代 __new__ 而不是 __init__ 的建议非常吻合。以下代码不再失败:

class MyMeta(type):
    def __new__(cls, name, bases, namespace, otherarg):
        return super().__new__(cls, name, bases, namespace)

class MyClass(metaclass=MyMeta, otherarg=1):
    pass

只定义 __init__ 方法且给出关键字参数的元类让仍然会失败,并会引发 TypeError 异常:

class MyMeta(type):
    def __init__(self, name, bases, namespace, otherarg):
        super().__init__(name, bases, namespace)

class MyClass(metaclass=MyMeta, otherarg=1):
    pass

以定义了 __init____new__ 两个方法的元类作为元类的类仍然会正常工作。

唯一会出问题的是将 type.__new__ 的参数作为关键字参数传递:

class MyMeta(type):
    def __new__(cls, name, bases, namespace):
        return super().__new__(cls, name=name, bases=bases,
                               dict=namespace)

class MyClass(metaclass=MyMeta):
    pass

9. 拒绝的设计选项

略。

  • 0
    点赞
  • 1
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值