Following system colour scheme - Python 增强提案 Selected dark colour scheme - Python 增强提案 Selected light colour scheme - Python 增强提案

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__ 方法,作为协作多重继承的终点。请注意,此方法没有关键字参数,这意味着所有更专门的方法都必须处理所有关键字参数。

这个通用提案并非新想法(它首次被建议纳入语言定义 10多年前,并且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 的元类需要对类型系统和类构建过程有深入的理解。这被认为是具有挑战性的,因为需要将多个动态部分(代码、元类提示、实际元类、类对象、类对象的实例)在你的脑海中清晰地区分开来。即使你了解规则,如果你不极其小心,仍然很容易犯错。

理解所提议的隐式类初始化钩子只需要普通的 方法继承,这并不是一项令人生畏的任务。新的钩子为理解类定义过程中涉及的所有阶段提供了一个更渐进的路径。

减少元类冲突的可能性

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

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

使用类的新方法

子类注册

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

class PluginBase:
    subclasses = []

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

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

特性描述符

野外有许多 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__ 钩子。然后,它在基类上(确切地说,是在 super() 上)调用 __init_subclass__。这意味着子类初始化器已经看到了完全初始化的描述符。这样,__init_subclass__ 的用户可以在需要时再次修复所有描述符。

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

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

还应该进行一个小改动:在 CPython 当前的实现中,type.__init__ 明确禁止使用关键字参数,而 type.__new__ 允许其属性作为关键字参数传递。这奇怪地不一致,因此应该禁止。虽然可以保留当前行为,但最好修复它,因为它可能根本没有被使用:唯一的用例是元类以 *name*、*bases* 和 *dict*(是的,*dict*,而不是现代元类中大多使用的 *namespace* 或 *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

参考实现

本 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

最后修改:2025-02-01 08:59:27 GMT