理解 __init_subclass__

123

我终于升级了我的 Python 版本并发现了其中新增的新特性。除了其他的东西之外,我还在探索新的 __init_subclass__ 方法。根据文档:

每当包含的类被子类化时,就会调用此方法。cls 现在是新的子类。如果将其定义为普通实例方法,则此方法隐式转换为类方法。

于是我开始按照文档中的示例尝试使用它:

class Philosopher:
    def __init_subclass__(cls, default_name, **kwargs):
        super().__init_subclass__(**kwargs)
        print(f"Called __init_subclass({cls}, {default_name})")
        cls.default_name = default_name

class AustralianPhilosopher(Philosopher, default_name="Bruce"):
    pass

class GermanPhilosopher(Philosopher, default_name="Nietzsche"):
    default_name = "Hegel"
    print("Set name to Hegel")

Bruce = AustralianPhilosopher()
Mistery = GermanPhilosopher()
print(Bruce.default_name)
print(Mistery.default_name)

产生以下输出:

Called __init_subclass(<class '__main__.AustralianPhilosopher'>, 'Bruce')
'Set name to Hegel'
Called __init_subclass(<class '__main__.GermanPhilosopher'>, 'Nietzsche')
'Bruce'
'Nietzsche'

我知道这种方法被称为子类定义后的after, 但我的问题特别关注于此功能的使用。我也阅读了PEP 487文章,但并没有对我有所帮助。这种方法在哪里会有用呢?是针对:

  • 超类在创建时注册子类?
  • 强制子类在定义时设置字段?

此外,我需要理解__set_name__来充分理解它的使用吗?

5个回答

104
PEP 487旨在提供两种常见元类用例的更易访问的方式,而无需了解元类的所有细节和内部工作原理。这两个新功能,__init_subclass____set_name__是独立的,它们不依赖于彼此。 __init_subclass__只是一个钩子方法。您可以将其用于任何想要的内容。它对于以某种方式注册子类以及为这些子类设置默认属性值非常有用。
我们最近使用此功能为不同版本控制系统提供“适配器”,例如:
class RepositoryType(Enum):
    HG = auto()
    GIT = auto()
    SVN = auto()
    PERFORCE = auto()

class Repository():
    _registry = {t: {} for t in RepositoryType}

    def __init_subclass__(cls, scm_type=None, name=None, **kwargs):
        super().__init_subclass__(**kwargs)
        if scm_type is not None:
            cls._registry[scm_type][name] = cls
    
class MainHgRepository(Repository, scm_type=RepositoryType.HG, name='main'):
    pass

class GenericGitRepository(Repository, scm_type=RepositoryType.GIT):
    pass

这使得我们可以轻松地为特定仓库定义处理程序类,而无需使用元类或装饰器。

7
好的例子。阅读其他答案后,我理解这就像是一种扩展基类功能的快捷方式,无需处理元类。 - EsotericVoid
1
@MartijnPieters 在 GenericGitRepository 类的定义中,你是否遗漏了 name 属性?非常棒的示例,我喜欢它。 - Julian Camilleri
2
@JulianCamilleri:不,重点是name参数是可选的。 - Martijn Pieters
2
抱歉@MartijnPieters,问了这么多问题,我很好奇在这个阶段会发生什么:当nameNone时,cls._registry[scm_type][name] = cls - 我是否错过了某种机制? - Julian Camilleri
6
在第二层中,“None”是一个键。这与字符串名称一样有效,因此您可以将一堆命名类和一个“默认”的未命名选项注册在“None”键下。请注意,这不会改变原意。 - Martijn Pieters

55
__init_subclass____set_name__ 是正交机制 - 它们彼此不相关,只是在同一 PEP 中描述。这两个功能都需要一个完整的元类才能实现。PEP 487 解决了元类的两个最常见应用场景:
  • 如何让父类知道其正被子类化(__init_subclass__)
  • 如何让描述符类知道其所使用的属性的名称(__set_name__)
正如PEP 487所说:

虽然可以使用多种元类,但大部分用例都属于以下三类:在类创建之后运行某些初始化代码、初始化描述符以及保留定义类属性的顺序。

前两种情况可以通过对类创建进行简单钩子处理轻松实现:

  • __init_subclass__ 钩子可初始化给定类的所有子类
  • 在类创建时,在类中定义的所有属性(描述符)上调用 __set_name__ 钩子,

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

注意,虽然 __init_subclass__ 是在这个类的继承树中使用元类的替代方法,但在描述符类中__set_name__则是对具有描述符实例属性的类使用元类的替代方法。

我对元类的处理并不是很熟悉,无法完全理解其用途,但感谢您的解释(以及其他人的解释),看起来有很多情况需要在子类声明后“完成”其定义。 - EsotericVoid
1
我的声明式模式系统 marrow.schema 展示了这样一个元类。它明确收集超类的贡献(L57),保留重新定义属性的原始定义顺序(L64/77),并告知属性其分配的名称(L74),通知属性其附加(L81),然后通知子类其构造(L99)。其中许多已被 __set_name____init_subclass__ 废弃,但不是全部。 - amcgregor
虽然从技术上讲是正确的,但这个答案解释很少,同时也让人感到困惑。相反,每个人都想要 Martijn Pieters'更易读的实际例子 - Cecil Curry

13
我想添加一些与元类和__init_subclass__相关的参考资料,这可能会有所帮助。
背景: __init_subclass__被引入作为创建元类的替代方法。以下是核心开发人员之一Brett Cannon在演讲中对PEP 487的两分钟概述。
推荐参考资料:
  • Guido van Rossum的博客文章介绍了Python中元类的早期历史。
  • Jake Vanderplas的博客文章深入探讨了实现元类。

9
__init_subclass__的主要目的是提供一个更简单的类自定义方式,正如PEP标题所示。
这是一个钩子函数,允许您在不需要了解元类、跟踪类构造的所有方面或担心后续出现元类冲突的情况下对类进行调整。正如Nick Coghlan在此PEP的早期阶段发表的一条消息中所述:
引用: “主要的可读性/可维护性好处来自于更清楚地区分“自定义子类初始化”案例和“自定义子类运行时行为”案例。”
“完全自定义元类不提供任何影响范围的指示,而__init_subclass__更清楚地指出,在子类创建后没有持久的行为影响。”
元类被认为是神奇的,因为在创建类后你不知道它们的影响会是什么。而另一方面,__init_subclass__只是另一个类方法,它只运行一次,然后就完成了。(请参阅其文档以了解确切的功能。)
PEP 487的整个重点在于简化(即消除使用)一些常见用途的元类。__init_subclass__负责处理后类初始化,而__set_name__(仅对描述符类有意义)被添加以简化初始化描述符。除此之外,它们没有关联。提到的第三种元类的常见情况(保持定义顺序)也得到了简化。这是通过在命名空间中使用有序映射来解决的(在Python 3.6中,它是一个字典,但这是一个实现细节 :-))。

2

您还可以使用它在类上执行一次性的昂贵初始化操作。

例如,我想将以我的用户开始的路径替换为家庭目录的标准波浪线缩写。

/Users/myuser/.profile -> ~/.profile

很简单,我可以这样写:


from pathlib import Path

class Replacer:
    def __init__(self):
        self.home = str(Path("~").expanduser())

    def replace(self, value):
        if isinstance(value,str) and value.startswith(self.home):
            value = value.replace(self.home,"~")
        return value

replacer = Replacer()
print(replacer.replace("/Users/myuser/.profile"))

然而,对于任何运行,主路径都是恒定的,没有必要在每次创建替换器时计算它。

使用__init_subclass,我只能为类执行一次。是的,在模块初始化时我也可以将变量分配给类:

class Replacer:

    home = str(Path("~").expanduser())
    ...


但是可能有理由想要推迟计算机操作,直到该类实际上被使用为止。例如,在使用Django时,当导入models.py时,在某些条件下,Django可能尚未完全初始化自己。
上面的说法是不正确的。init子类在类定义时执行,而不是首次使用。
然而,与类体中的home =赋值不同,当调用init子类时Replacer类实际上已经存在,并作为cls参数提供给该方法。
class UselessAncestorNeededToHouseInitSubclass:
    "do-nothing"

    def __init_subclass__(cls, /, **kwargs):
        print("__init_subclass__")
        super().__init_subclass__(**kwargs)
        cls.home = str(Path("~").expanduser())

class Replacer(UselessAncestorNeededToHouseInitSubclass):
    """__init_subclass__ wont work if defined here.  It has to be on
    an ancestor
    """

    def replace(self, value):
        if isinstance(value,str) and value.startswith(self.home):
            value = value.replace(self.home,"~")
        return value

for ix in range(0,10):
    replacer = Replacer()
    print(replacer.replace("/Users/myuser/.profile"))

输出结果:(注意 subclass_init 只被调用了一次):

__init_subclass__
~/.profile
~/.profile
~/.profile
~/.profile
~/.profile
~/.profile
~/.profile
~/.profile
~/.profile
~/.profile

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接