Following system colour scheme Selected dark colour scheme Selected light colour scheme

Python 增强提案

PEP 487 – 简化类创建的自定义

作者:
Martin Teichmann <lkb.teichmann at gmail.com>
状态:
最终版
类型:
标准规范
创建日期:
2015年2月27日
Python 版本:
3.6
更新历史:
2015年2月27日,2016年2月5日,2016年6月24日,2016年7月2日,2016年7月13日
替换:
422
决议:
Python-Dev 消息

目录

摘要

目前,自定义类创建需要使用自定义元类。这个自定义元类随后会贯穿类的整个生命周期,从而可能导致无意义的元类冲突。

此 PEP 提出通过类体中的新__init_subclass__钩子和初始化属性的钩子来支持各种自定义场景。

新的机制应该比实现自定义元类更容易理解和使用,因此应该为充分了解 Python 元类机制提供一个更平缓的入门途径。

背景

元类是自定义类创建的强大工具。然而,它们存在一个问题,即没有自动组合元类的方法。如果想对一个类使用两个元类,则需要创建一个新的元类来组合这两个元类,通常需要手动完成。

这种需求往往让用户感到意外:继承来自两个不同库的两个基类,突然需要手动创建一个组合元类,而通常用户根本不关心这些库的细节。如果某个库开始使用之前未使用过的元类,情况会变得更糟。虽然库本身仍然可以完美运行,但突然所有将这些类与其他库的类结合在一起的代码都会失败。

提案

虽然元类有很多可能的用法,但绝大多数用例都属于以下三类:类创建后运行的一些初始化代码、描述符的初始化以及保持类属性定义顺序。

前两类可以通过在类创建中添加简单的钩子轻松实现

  1. 一个__init_subclass__钩子,用于初始化给定类的所有子类。
  2. 在类创建时,会对类中定义的所有属性(描述符)调用__set_name__钩子,并且

第三类是另一个 PEP 的主题,PEP 520

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

>>> class QuestBase:
...    # this is implicitly a @classmethod (see below for motivation)
...    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__方法,该方法作为协作多重继承的端点。请注意,此方法没有关键字参数,这意味着所有更专业的 method 都必须处理所有关键字参数。

这个通用提案并不是一个新想法(它最早是在十多年前被建议纳入语言定义中的,并且类似的机制长期以来一直受到Zope 的 ExtensionClass的支持),但近年来情况发生了足够的变化,因此值得重新考虑将其纳入其中。

提案的第二部分添加了一个__set_name__初始化程序,用于类属性,尤其是如果它们是描述符。描述符在类的主体中定义,但它们对该类一无所知,甚至不知道它们被访问时的名称。一旦调用__get__,它们就会知道自己的所有者,但仍然不知道自己的名称。这很不幸,例如,它们无法将其关联的值放入其对象的__dict__中,因为它们不知道该名称。这个问题已经被多次解决,并且是库中使用元类的最重要原因之一。虽然使用提案的第一部分很容易实现这样的机制,但为每个人提供一个解决方案是有意义的。

为了举例说明其用法,假设一个描述符表示弱引用值

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)

    # this is the new initializer:
    def __set_name__(self, owner, name):
        self.name = name

例如,这样的WeakAttribute可以用于树形结构中,在树形结构中,希望避免通过父节点产生循环引用

class TreeNode:
    parent = WeakAttribute()

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

请注意,parent属性像普通属性一样使用,但树中不包含循环引用,因此在不再使用时可以轻松地进行垃圾回收。一旦父节点停止存在,parent属性就会神奇地变为None

虽然这个例子看起来非常简单,但需要注意的是,到目前为止,如果没有使用元类,就无法定义这样的属性。并且鉴于这样的元类会让生活变得非常困难,所以这种属性目前还不存在。

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

主要优势

更轻松地继承定义时行为

理解 Python 的元类需要深入理解类型系统和类构造过程。由于需要在脑海中清楚地区分多个移动部件(代码、元类提示、实际元类、类对象、类对象的实例),因此这被认为具有挑战性。即使您知道规则,如果您不小心,也很容易出错。

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

降低元类冲突的可能性

导致库作者不愿使用元类(即使在合适的情况下)的一个主要问题是元类冲突的风险。每当类定义所需的父类使用两个不相关的元类时,就会发生这种情况。这种风险也使得在没有元类的类中添加元类变得非常困难。

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

使用类的新方法

子类注册

特别是在编写插件系统时,人们希望注册插件基类的新的子类。这可以通过以下方式完成

class PluginBase:
    subclasses = []

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

在此示例中,PluginBase.subclasses将包含整个继承树中所有子类的普通列表。需要注意的是,这也可以很好地用作 mixin 类。

特性描述符

在现实中,有很多 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

实现细节

钩子的调用顺序如下:type.__new__在新的类初始化后对描述符上的__set_name__钩子进行调用。然后它对基类上的__init_subclass__进行调用,更准确地说,是在super()上进行调用。这意味着子类初始化程序已经看到了完全初始化的描述符。这样,__init_subclass__用户可以根据需要再次修复所有描述符。

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

作为第三种选择,所有工作都可以在type.__init__中完成。大多数元类都在__new__中完成其工作,因为文档中推荐这样做。许多元类在将参数传递给super().__new__之前会修改它们的参数。为了与这些类型的类兼容,钩子应该从__new__中调用。

还应该进行另一个小的更改:在 CPython 的当前实现中,type.__init__显式禁止使用关键字参数,而type.__new__允许将其属性作为关键字参数传递。这有点不连贯,因此应该禁止。虽然可以保留当前的行为,但最好进行修复,因为它可能根本没有被使用:唯一的用例是元类使用namebasesdict(是的,是dict,而不是namespacens,后者大多与现代元类一起使用)作为关键字参数来调用其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

参考实现

本 PEP 的参考实现已附加到 issue 27366

向后兼容性问题

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

这现在将引发 TypeError,但这是一种奇怪的代码,即使有人使用了此功能,也很容易修复。

被拒绝的设计选项

在类本身调用钩子

添加一个将在类本身调用的 __autodecorate__ 钩子是 PEP 422 提出的想法。如果钩子仅在严格的子类上调用,大多数示例的工作方式相同甚至更好。通常,安排在定义它的类中显式调用钩子(选择加入这种行为)比选择退出(通过记住在钩子主体中检查 cls is __class)容易得多,这意味着人们不希望在定义它的类上调用钩子。

如果相关类被设计为混合类,这一点变得最为明显:混合类代码不太可能在混合类本身执行,因为它本身不应该是一个完整的类。

最初的提案还在类初始化过程中进行了重大更改,这使得无法将提案移植到旧版本的 Python。

当也希望在基类上调用钩子时,可以使用两种机制

  1. 引入一个额外的混合类,仅用于保存 __init_subclass__ 实现。然后,原始的“基”类可以将新的混合类列为其第一个父类。
  2. 将所需的行为实现为一个独立的类装饰器,并显式地将该装饰器应用于基类,然后通过 __init_subclass__ 隐式地应用于子类。

从类装饰器显式调用 __init_subclass__ 通常是不希望的,因为这通常也会在父类上第二次调用 __init_subclass__,这不太可能是期望的行为。

调用钩子的其他变体

还提出了钩子的其他名称,即 __decorate____autodecorate__。本提案选择 __init_subclass__,因为它非常接近 __init__ 方法,只是针对子类,而它与装饰器并不十分接近,因为它不返回类。

对于 __set_name__ 钩子,也提出了其他名称,例如 __set_owner____set_ownership____init_descriptor__

要求在__init_subclass__上使用显式装饰器

可以要求在 __init_subclass__ 装饰器上显式使用 @classmethod。它被设置为隐式,因为没有合理的解释可以将其省略,并且无论如何都需要检测这种情况以给出有用的错误消息。

在注意到定义 __prepare__ 并忘记 @classmethod 方法装饰器的用户体验非常难以理解之后,这一决定得到了加强(特别是由于 PEP 3115 将其记录为普通方法,而当前文档没有明确说明任何一方)。

更类似于__new__的钩子

PEP 422 中,钩子的工作方式更像 __new__ 方法而不是 __init__ 方法,这意味着它返回一个类而不是修改一个类。这允许更大的灵活性,但代价是更难实现和不希望的副作用。

添加具有属性顺序的类属性

这得到了自己的 PEP 520

历史

这曾经是 Alyssa Coghlan 和 Daniel Urban 对 PEP 422 的竞争提案。PEP 422 意在实现与本 PEP 相同的目标,但采用不同的实现方式。在此期间,PEP 422 已被撤回,支持这种方法。


来源: https://github.com/python/peps/blob/main/peps/pep-0487.rst

上次修改: 2023-10-11 12:05:51 GMT