第 24 章 类元编程

每个人都知道,一开始调试的难度是编写程序的两倍。因此,如果您在编写程序时尽可能地聪明,您将如何调试它?

Brian W. Kernighan and P. J. Plauger, The Elements of Programming Style

类元编程是在运行时创建或定制类的艺术。类是 Python 中的一等对象,因此可以随时使用函数创建新类,而无需使用 class 关键字。类装饰器也是函数,但旨在检查、更改甚至用另一个类替换被装饰的类。最后,元类是类元编程最先进的工具:它们使您可以创建具有特殊特征的全新类别的类,例如我们已经看到的抽象基类。

元类很强大,但很难证明其合理性,甚至更难做到正确。此外,Python 3.6 实现了  PEP 487—Simpler customisation of class creation,提供了支持以前需要元类或类装饰器的任务的特殊方法。

本章将逐步提高难度介绍类元编程技术。

WARNING

这是一个令人兴奋的话题,很容易被带走。所以我必须提供这个建议:

为了可读性和可维护性,您可能应该避免在应用程序代码中使用本章中描述的技术。

另一方面,如果你想编写下一个伟大的 Python 框架,这些都是行业的工具。

本章的新内容

Fluent Python, First Edition 的 Class Metaprogramming 章节中的所有代码仍然可以正常运行。但是,鉴于自 Python 3.6 以来添加的新功能,前面的一些示例不再是最简单的解决方案。

我用不同的示例替换了这些示例,突出了 Python 的新元编程功能或添加了进一步的要求以证明使用更高级技术的合理性。一些新示例利用类型提示来提供类似于 @dataclass 装饰器和 typing.NamedTuple 的类构建器。

“现实世界中的元类”是一个新章节,对元类的适用性进行了更高层次的思考。

TIP:

一些最好的重构是删除因解决相同问题的更新和更简单的方法而变得多余的代码。这适用于生产代码和书籍。

我们将从查看 Python 数据模型中为所有类定义的属性和方法开始。

类作为对象

与 Python 中的大多数程序实体一样,类也是对象。每个类都有许多在 Python 数据模型中定义的属性,记录在“4.13.库参考中“内置类型”一章的 “4.13. Special Attributes”。其中三个属性已经在书中多次出现:__class__、__name__ 和 __mro__。其他类标准属性是:

cls.__bases__:

        类的基类元组。

cls.__qualname__:   

        类或函数的限定名称,它是从模块的全局范围到类定义的虚线路径。当类在另一个类中定义时,这个属性是相关的。例如,在 Ox 等 Django 模型类中,有一个名为 Meta 的内部类。Meta 的 __qualname__ 是 Ox.Meta,但它的 __name__ 只是 Meta。这个属性的规范是 PEP-3155 — Qualified name for classes and functions.

cls.__subclasses__():

此方法返回该类的直接子类的列表。该实现使用弱引用来避免超类及其子类之间的循环引用——子类在其 __bases__ 属性中持有对超类的强引用。该方法仅列出当前在内存中的子类。尚未导入的模块中的子类不会出现在结果中。

cls.mro()

解释器在构建类时调用此方法来获取存储在类的 __mro__ 属性中的超类元组。元类可以重写此方法以自定义正在构建的类的方法解析顺序。

TIP:

本节中提到的所有属性都没有被 dir(...) 函数列出。

现在,如果一个类是一个对象,那么一个类的类是什么?

type:内置类工厂

我们通常认为 type 是返回对象对应的类的函数,因为 type(my_object) 就是这样做的:它返回 my_object.__class__。

但是,type 是一个类,它在传入3个参数调用时创建一个新类。

看下面这个简单的类:

class MyClass(MySuperClass, MyMixin):
    x = 42

    def x2(self):
        return self.x * 2

使用type的构造函数,您可以使用以下代码在运行时创建 MyClass: 

MyClass = type('MyClass',
               (MySuperClass, MyMixin),
               {'x': 42, 'x2': lambda self: self.x * 2},
          )

 调用type在功能上等同于前面的 class MyClass... 块语句。

当 Python 读取一个class语句时,它会调用 type 来使用这些参数构建类对象:

name:

    出现在 class 关键字之后的标识符;例如:MyClass。

bases:

        在类标识符之后的括号中给出的超类元组,如果在class语句中没有超类,则为 (object,)。

dict:

        属性名称到值的映射.可调用对象在这里方法,正如我们在“方法是描述符”中看到的那样。其他值成为类属性。

NOTE

type构造函数接受可选的关键字参数,这些参数会被type本身忽略,但会原封不动地传递给 __init_subclass__,后者必须使用它们。我们将在“介绍__init_subclass__”中研究这个特殊方法,但我不会介绍关键字参数的使用。更多信息,请阅读 PEP 487—Simpler customisation of class creation

type类就是一个元类:一个构建类的类。换句话说,type类的实例就是类。标准库提供了一些其他元类,但默认的是type。

>>> type(7)
<class 'int'>
>>> type(int)
<class 'type'>
>>> type(OSError)
<class 'type'>
>>> class Whatever:
...     pass
...
>>> type(Whatever)
<class 'type'>

我们将在“元类 101”中构建自定义元类。

接下来,我们将使用内置的type来创建一个构建类的函数。

类工厂函数

标准库有一个在本书中多次出现的类工厂函数:collections.namedtuple。在第 5 章中,我们还看到了 typing.NamedTuple 和 @dataclass。所有这些类构建器都利用了本章介绍的技术。

我们将从一个超级简单的可变对象类工厂开始——最简单的@dataclass 替代品。

假设我正在编写一个宠物店应用程序,并且我想将狗的数据存储为简单的记录。但我不想这样写样板:

class Dog:
    def __init__(self, name, weight, owner):
        self.name = name
        self.weight = weight
        self.owner = owner

无聊……每个字段名出现了 3 次,而且那个样板文件甚至没有给我们一个友好的repr:

>>> rex = Dog('Rex', 30, 'Bob')
>>> rex
<__main__.Dog object at 0x2865bac>

从 collections.namedtuple 中得到提示,让我们创建一个 record_factory,它可以动态创建像 Dog 这样的简单类。

示例 24-1 显示了工厂应该如何工作。

示例 24-1。测试record_factory,一个简单的类工厂

    >>> Dog = record_factory('Dog', 'name weight owner')  1
    >>> rex = Dog('Rex', 30, 'Bob')
    >>> rex  2
    Dog(name='Rex', weight=30, owner='Bob')
    >>> name, weight, _ = rex  3
    >>> name, weight
    ('Rex', 30)
    >>> "{2}'s dog weighs {1}kg".format(*rex)  4
    "Bob's dog weighs 30kg"
    >>> rex.weight = 32  5
    >>> rex
    Dog(name='Rex', weight=32, owner='Bob')
    >>> Dog.__mro__  6
    (<class 'factories.Dog'>, <class 'object'>)
  1. 工厂可以像 namedtuple 一样调用:类名,后跟属性名,在单个字符串中用空格分隔。
  2. 友好的repr
  3. 实例是可迭代的,因此它们可以在赋值时方便地拆包……
  4. …或者当传参给像format这样的函数时。
  5. 记录实例是可变的
  6. 新创建的类继承自object——与我们的工厂无关。

record_factory 的代码在示例 24-2 中

示例 24-2。 record_factory.py:一个简单的类工厂

from typing import Union, Any
from collections.abc import Iterable, Iterator

FieldNames = Union[str, Iterable[str]]  1

def record_factory(cls_name: str, field_names: FieldNames) -> type[tuple]:  2

    slots = parse_identifiers(field_names)  3

    def __init__(self, *args, **kwargs) -> None:  4
        attrs = dict(zip(self.__slots__, args))
        attrs.update(kwargs)
        for name, value in attrs.items():
            setattr(self, name, value)

    def __iter__(self) -> Iterator[Any]:  5
        for name in self.__slots__:
            yield getattr(self, name)

    def __repr__(self):  6
        values = ', '.join(f'{name}={value!r}'
            for name, value in zip(self.__slots__, self))
        cls_name = self.__class__.__name__
        return f'{cls_name}({values})'

    cls_attrs = dict(  7
        __slots__=slots,
        __init__=__init__,
        __iter__=__iter__,
        __repr__=__repr__,
    )

    return type(cls_name, (object,), cls_attrs)  8


def parse_identifiers(names: FieldNames) -> tuple[str, ...]:
    if isinstance(names, str):
        names = names.replace(',', ' ').split()  9
    if not all(s.isidentifier() for s in names):
        raise ValueError('names must all be valid identifiers')
    return tuple(names)
  1. 用户可以将字段名称提供为单个字符串或字符串组成的可迭代对象。
  2. 接受像 collections.namedtuple 的前两个这样的参数;返回一个type——即一个类——这个类表现为tuple。
  3. 构建一个属性名称元组,这将是新类的 __slots__ 属性。
  4. 这个函数将成为新类中的 __init__ 方法。它接受位置和/或关键字参数
  5. 按照 __slots__ 给出的顺序生成字段值。
  6. 友好的repr,迭代 __slots__ 和 self.
  7. 生成类属性字典。
  8. 构建并返回新类,调用type的构造函数。
  9. 将由空格或逗号分隔的name字符串转换为 str 列表。

示例 24-2 是我们第一次在类型提示中看到type。如果注解只是 -> type,那将意味着 record_factory 返回一个类——这样是正确的。但是注解 -> type[tuple] 更精确:它表示返回的类将是tuple的子类。

示例 24-2 中 record_factory 的最后一行构建了一个以 cls_name 的值命名的类,将 object 作为其单个直接基类,并使用加载了 __slots__、__init__、__iter__ 和 __repr__ 的命名空间,其中最后三个是实例方法。

我们可以将 __slots__ 类属性命名为其他任何名称,但随后我们必须实现 __setattr__ 来验证赋值的属性名称,因为对于保存记录的类,我们希望属性集始终相同且顺序相同。但是,请记住,__slots__ 的主要功能是在处理数百万个实例时节省内存,使用 __slots__ 有一些缺点,在“使用 __slots__ 节省内存”中进行了讨论。

WARNING

由 record_factory 创建的类的实例是不可序列化的——也就是说,它们不能用 pickle 模块的 dump 函数导出。解决这个问题超出了这个例子的范围,这个例子的目的是在一个简单的用例中展示type类的作用。要获得完整的解决方案,请研究 collections.namedtuple 的源代码;搜索“pickling”这个词。

现在让我们看看如何模拟更现代的类构建器,例如 typing.NamedTuple,它将用户定义的类写成类语句,并自动增强它的更多功能。

介绍 __init_subclass__

__init_subclass__ 和 __set_name__ 都在 PEP 487—Simpler customisation of class creation。我们在“LineItem Take #4:存储属性的自动命名”中第一次看到了描述符的 __set_name__ 特殊方法。现在让我们学习 __init_subclass__。

在第 5 章中,我们看到了 typing.NamedTuple 和 @dataclass 让程序员可以使用 class 语句来指定新类的属性,然后类构建器通过自动添加基本方法(如 __init__、__repr__、__eq__ 等)对其进行增强。

这两个类构建器都读取用户的class语句中的类型提示以增强类。这些类型提示还允许静态类型检查器验证设置或获取这些属性的代码。但是,NamedTuple 和 @dataclass 没有利用类型提示在运行时进行属性验证。下一个示例中的 Checked 类可以做到这点。

NOTE

运行时类型检查不可能支持所有可以想象的静态类型提示,这可能就是为什么 typing.NamedTuple 和 @dataclass 甚至不尝试它的原因。但是,某些也是具体类的类型可以与 Checked 一起使用。这包括通常用于字段内容的简单类型,例如 str、int、float 和 bool,以及这些类型的列表。

示例 24-3 展示了如何使用 Checked 来构建 Movie 类。

示例 24-3。 initsub/checkedlib.py:用于创建 Checked 的 子类Movie 的 doctest。

    >>> class Movie(Checked):  1
    ...     title: str  2
    ...     year: int
    ...     box_office: float
    ...
    >>> movie = Movie(title='The Godfather', year=1972, box_office=137)  3
    >>> movie.title
    'The Godfather'
    >>> movie  4
    Movie(title='The Godfather', year=1972, box_office=137.0)
  1.  Movie 继承自 Checked——我们将在后面的示例 24-5 中对其进行定义。
  2. 每个属性都使用构造函数进行注解。这里我使用了内置类型。
  3. Movie实例必须使用关键字参数创建。
  4. 作为回报,你会得到一个友好的 __repr__。

用作属性类型提示的构造函数可以是任何可调用的对象,它接受零个或一个参数并返回适合预期字段类型的值,或者通过抛出 TypeError 或 ValueError 来拒绝参数。

为示例 24-3 中的注解使用内置类型意味着该类型的构造函数必须可以接收这些值。对于 int,这意味着任何 x 使得 int(x) 返回一个 int。对于 str,所有情况都可以,因为 str(x) 可以与 Python 中的任何 x 一起使用。

当不带参数调用时,构造函数应返回其类型的默认值。

这是 Python 内置构造函数的标准行为:

>>> int(), float(), bool(), str(), list(), dict(), set()
(0, 0.0, False, '', [], {}, set())

在像 Movie 这样的 Checked 子类中,缺少参数会创建具有字段构造函数返回的默认值的实例。例如:

    >>> Movie(title='Life of Brian')
    Movie(title='Life of Brian', year=0, box_office=0.0)

 构造函数用于在实例化期间以及直接在实例上设置属性时进行验证:

    >>> blockbuster = Movie(title='Avatar', year=2009, box_office='billions')
    Traceback (most recent call last):
      ...
    TypeError: 'billions' is not compatible with box_office:float
    >>> movie.year = 'MCMLXXII'
    Traceback (most recent call last):
      ...
    TypeError: 'MCMLXXII' is not compatible with year:int


Checked子类和静态类型检查 

在示例 24-3 中定义的具有 Movie 实例movie的 .py 源文件中,Mypy 将赋值标记为类型错误:

movie.year = 'MCMLXXII'

但是,Mypy 无法检测此构造函数调用中的类型错误:

blockbuster = Movie(title='Avatar', year='MMIX')

 这是因为 Movie 继承了 Checked.__init__,并且该方法的签名必须接受任何关键字参数,以支持任意用户定义的类。

另一方面,如果使用类型提示 list[float] 声明 Checked 子类字段,Mypy 可以标记内容不兼容的列表赋值,但 Checked 将忽略类型参数并将其视为list。


现在让我们看看checkedlib.py的实现。第一个类是Field描述符:

示例 24-4。 initsub/checkedlib.py:Field描述符类。

from collections.abc import Callable  1
from typing import Any, NoReturn, get_type_hints


class Field:
    def __init__(self, name: str, constructor: Callable) -> None:  2
        if not callable(constructor) or constructor is type(None):  3
            raise TypeError(f'{name!r} type hint must be callable')
        self.name = name
        self.constructor = constructor

    def __set__(self, instance: Any, value: Any) -> None:
        if value is ...:  4
            value = self.constructor()
        else:
            try:
                value = self.constructor(value)  5
            except (TypeError, ValueError) as e:  6
                type_name = self.constructor.__name__
                msg = f'{value!r} is not compatible with {self.name}:{type_name}'
                raise TypeError(msg) from e
        instance.__dict__[self.name] = value  7
  1. 回想一下,从 Python 3.9 开始,注解的 Callable 类型是 collections.abc 中的 ABC,而不是废弃使用的 typing.Callable。
  2. 这是一个最小的 Callable 类型提示;构造函数的参数类型和返回值类型都是隐式 Any。
  3. 对于运行时检查,我们使用内置的callable.对 type(None) 的测试是必要的,因为 Python 将type中的 None 读取为 NoneType,None 所属的类(因此是可调用的),NoneType是只返回 None 的无用构造函数。
  4. 如果 Checked.__init__ 将值设置为 ...(Ellipsis 内置对象),我们将调用不带参数的构造函数。
  5. 否则,使用value调用constructor。
  6. 如果构造函数抛出了这些异常中的任何一个,我们将抛出 TypeError 并带有一个有用的消息,包括字段和构造函数的名称;例如'MMIX' is not compatible with year:int
  7. 如果没有抛出异常,则将value存储在 instance.__dict__ 中。

在 __set__ 我们需要捕获 TypeError 和 ValueError 因为内置构造函数可能会抛出它们中的任何一个,具体取决于参数。例如:float(None) 抛出 TypeError,但 float('A') 抛出 ValueError。另一方面, float('8') 不会抛出错误并返回 8.0。我在此声明这是这个演示示例的一个功能而不是一个错误。

TIP

在“LineItem Take #4:存储属性的自动命名”中,我们看到了方便的 __set_name__ 描述符特殊方法。我们在 Field 类中不需要它,因为描述符没有在客户端源代码中实例化;用户声明的类型是构造函数,正如我们在 Movie 类中看到的(示例 24-3)。相反,Field描述符实例是在运行时由 Checked.__init_subclass__ 方法创建的,我们将在示例 24-5 中看到。

现在让我们关注 Checked 类。我将其拆分为两个列表:示例 24-5 显示了类的顶部,其中包括本示例中最重要的方法。其余方法在示例 24-6 中。

示例 24-5。 initsub/checkedlib.py:Checked 类最重要的方法。

class Checked:
    @classmethod
    def _fields(cls) -> dict[str, type]:  1
        return get_type_hints(cls)

    def __init_subclass__(subclass) -> None:  2
        super().__init_subclass__()           3
        for name, constructor in subclass._fields().items():   4
            setattr(subclass, name, Field(name, constructor))  5

    def __init__(self, **kwargs: Any) -> None:
        for name in self._fields():             6
            value = kwargs.pop(name, ...)       7
            setattr(self, name, value)          8
        if kwargs:                              9
            self.__flag_unknown_attrs(*kwargs)  10
  1. 我编写了这个类方法来隐藏对类的其余部分的 typing.get_type_hints 的调用。如果我只需要支持 Python ≥ 3.10,我会改为调用 inspect.get_annotations。查看“Problems with Annotations at Runtime” 以了解这些功能的问题。
  2. __init_subclass__ 在定义当前类的子类时调用。它将新的子类作为它的第一个参数——这就是为什么我将参数命名为subclass而不是通常的 cls。有关这方面的更多信息,请参阅“__init_subclass__ is not a typical class method”.
  3. super().__init_subclass__() 不是绝对必要的,但应该调用它以与可能在同一继承图中实现 .__init_subclass__() 的其他类一起使用。请参阅“Multiple Inheritance and Method Resolution Order”.
  4. 遍历每个字段的name和constructor……
  5. …在subclass上创建一个熟悉,将name绑定到由name和constructor参数化的描述符Field
  6. 对于类字段中的每个name...
  7. …从 kwargs 中获取相应的value并从 kwargs 中删除。使用 ... - Ellipsis 对象 - 作为默认值允许我们区分给定值 None 的参数和未给定的参数。
  8. 此 setattr 调用触发 Checked.__setattr__,如示例 24-6 所示。
  9. 如果 kwargs 中还有剩余的项目,它们的名称与任何声明的字段都不匹配,并且 __init__ 将失败。
  10. 该错误由示例 24-6 中列出的 __flag_unknown_attrs 报告。它需要一个带有未知属性名称的 *names 参数。我在 *kwargs 中使用了一个星号将其键作为参数序列传递。

__INIT_SUBCLASS__ 不是典型的类方法

@classmethod 装饰器从不与 __init_subclass__ 一起使用,但这并不意味着什么,因为 __new__ 特殊方法即使没有 @classmethod 也表现得像一个类方法。Python 传递给 __init_subclass__ 的第一个参数是一个类。然而,它永远不是实现 __init_subclass__ 的类:它是该类的新定义的子类。这不同于 __new__ 和我所知道的所有其他类方法。因此,我认为__init_subclass__不是通常意义上的类方法,将第一个参数命名为cls是有误导性的。__init_suclass__ documentation将参数命名为 cls 但解释说:“……每当包含的类被子类化时调用。 cls 就是新的子类。

现在让我们看看 Checked 类的其余方法,从示例 24-5 继续。请注意,出于与 collections.namedtuple API 相同的原因,我在 _fields 和 _asdict 方法名称前添加了 _:以减少名称与用户定义的字段名称发生冲突的可能。

示例 24-6。 initsub/checkedlib.py:Checked 类的剩余方法。

    def __setattr__(self, name: str, value: Any) -> None:  1
        if name in self._fields():              2
            cls = self.__class__
            descriptor = getattr(cls, name)
            descriptor.__set__(self, value)     3
        else:                                   4
            self.__flag_unknown_attrs(name)

    def __flag_unknown_attrs(self, *names: str) -> NoReturn:  5
        plural = 's' if len(names) > 1 else ''
        extra = ', '.join(f'{name!r}' for name in names)
        cls_name = repr(self.__class__.__name__)
        raise AttributeError(f'{cls_name} object has no attribute{plural} {extra}')

    def _asdict(self) -> dict[str, Any]:  6
        return {
            name: getattr(self, name)
            for name, attr in self.__class__.__dict__.items()
            if isinstance(attr, Field)
        }

    def __repr__(self) -> str:  7
        kwargs = ', '.join(
            f'{key}={value!r}' for key, value in self._asdict().items()
        )
        return f'{self.__class__.__name__}({kwargs})'
  1. 拦截所有设置实例属性的操作。这是防止设置未知属性所必需的。
  2. 如果属性name已知,则获取相应的描述符。
  3. 通常我们不需要显式调用描述符__set__;在这种情况下这是必要的,因为 __setattr__ 会拦截所有在实例上设置属性的操作,包括存在覆盖描述符(如 Field)的情况。
  4. 否则,属性name未知,__flag_unknown_attrs 将抛出异常。
  5. 构建一个有用的错误消息,列出所有意外参数并引发 AttributeError。这是 NoReturn 特殊类型的一个罕见示例,在 “NoReturn”中进行了介绍。
  6. 从 Movie 对象的属性创建一个字典。我应该将此方法命名为 _as_dict,但我遵循由 collections.namedtuple 中的 _asdict 方法开始的约定。
  7. 实现一个不错的 __repr__ 是在这个例子中使用 _asdict 的主要原因。

Checked 示例说明了在实现 __setattr__ 以在实例化后阻止任意属性设置时如何处理覆盖描述符。在这个例子中实现 __setattr__ 是否值得值得商榷。没有它,设置 movie.director = 'Greta Gerwig' 会成功,但不会检查 director 属性,这个属性也不会出现在 __repr__ 中,也不会包含在 _asdict 返回的字典中——两者都在示例 24-6 中定义.

在 record_factory.py(示例 24-2)中,我使用 __slots__ 类属性解决了这个问题。然而,这种更简单的解决方案在这种情况下是不可行的,如下所述。

为什么 __init_subclass__ 不能配置 __slots__

__slots__ 属性仅在它是传递给 type.__new__ 的类命名空间中的条目之一时才有效。将 __slots__ 添加到已经存在的类没有任何效果。Python 仅在类构建后调用 __init_subclass__ ——到那时配置 __slots__ 为时已晚。类装饰器也不能配置 __slots__,因为类装饰器生效甚至晚于 __init_subclass__。我们将在 “What Happens When: Import Time Versus Runtime”中探讨这些时间问题。

要在运行时配置 __slots__,您自己的代码必须构建作为 type.__new__ 的最后一个参数传递的类命名空间。为此,您可以编写一个类工厂函数,如 record_factory.py,或者您选择nuclear选项并实现一个元类。我们将在“元类 101”中看到如何动态配置 __slots__。

在 PEP 487 在 Python 3.7 中使用 __init_subclass__ 简化类创建的自定义之前,必须使用类装饰器来实现类似的功能。这是下一节的重点。

使用类装饰器增强类

类装饰器是一个可调用对象,其行为类似于函数装饰器:它获取装饰类作为参数,并应返回一个类来替换装饰类。类装饰器通常在通过属性赋值向类中注入更多方法后返回被装饰的类本身。选择类装饰器而不是更简单的 __init_subclass__ 的最常见原因可能是避免干扰其他类的功能,例如继承和元类。

在本节中,我们将研究checkeddeco.py,它提供与checkedlib.py 相同的服务,但使用的是类装饰器。像往常一样,我们将首先查看一个使用示例,该示例是从 checkeddeco.py 中的文档测试中提取的。

示例 24-7。 checkeddeco.py:创建一个用@checked 装饰的电影类。

    >>> @checked
    ... class Movie:
    ...     title: str
    ...     year: int
    ...     box_office: float
    ...
    >>> movie = Movie(title='The Godfather', year=1972, box_office=137)
    >>> movie.title
    'The Godfather'
    >>> movie
    Movie(title='The Godfather', year=1972, box_office=137.0)

示例 24-7 和示例 24-3 之间的唯一区别是 Movie 类的声明方式:类使用@checked 装饰,而不是继承Checked。否则,外部行为是相同的,包括“引入 __init_subclass__”中的示例 24-3 之后显示的类型验证和默认值赋值。

现在让我们看看checkeddeco.py的实现。导入和 Field 类与示例 24-4 中列出的 checkedlib.py 中的相同。checkeddeco.py 中没有其他类,只有函数。

之前在 __init_subclass__ 中实现的逻辑现在是checked函数的一部分——示例 24-8 中列出的类装饰器。

示例 24-8。 checkeddeco.py:类装饰器。

def checked(cls: type) -> type:  1
    for name, constructor in _fields(cls).items():    2
        setattr(cls, name, Field(name, constructor))  3

    cls._fields = classmethod(_fields)  # type: ignore  4

    instance_methods = (  5
        __init__,
        __repr__,
        __setattr__,
        _asdict,
        __flag_unknown_attrs,
    )
    for method in instance_methods:  6
        setattr(cls, method.__name__, method)

    return cls  7
  1. 回想一下,类是type的实例。这些类型提示表明这是一个类装饰器:它接受一个类,并返回一个类。
  2. _fields 是模块后面定义的顶级函数(在示例 24-9 中)。
  3. 用字段描述符实例替换 _fields 返回的每个属性是 __init_subclass__ 在示例 24-5 中所做的。这里还有更多工作要做……
  4. 从 _fields 构建一个类方法,并将其添加到装饰类。需要 type: ignore 注释,因为 Mypy 提示 type 没有 _fields 属性。
  5. 将成为装饰类的实例方法的模块级函数。
  6. 将每个 instance_methods 添加到 cls。
  7. 返回装饰后的 cls,履行类装饰器的基本契约。

checkeddeco.py 中的每个顶级函数都带有下划线前缀,除了检查的装饰器。这种命名约定之所以有这样的约定,有几个原因:

  1. checked 是 checkeddeco.py 模块的公共接口的一部分,但其他函数不是。
  2. 示例 24-9 中的函数将被注入到装饰类中,并且前导 _ 减少了与装饰类的用户定义的属性和方法发生命名冲突的可能。

示例 24-9 中列出了 checkeddeco.py 的其余部分。这些模块级函数与checkedlib.py的Checked类的对应方法具有相同的代码。它们在示例 24-5 和示例 24-6 中进行了解释。

请注意,_fields 函数在 checkeddeco.py 中具有双重作用。在checked装饰器的第一行作为常规函数使用,也会作为被装饰类的类方法注入。

示例 24-9。 checkeddeco.py:要注入装饰类的方法。

def _fields(cls: type) -> dict[str, type]:
    return get_type_hints(cls)

def __init__(self: Any, **kwargs: Any) -> None:
    for name in self._fields():
        value = kwargs.pop(name, ...)
        setattr(self, name, value)
    if kwargs:
        self.__flag_unknown_attrs(*kwargs)

def __setattr__(self: Any, name: str, value: Any) -> None:
    if name in self._fields():
        cls = self.__class__
        descriptor = getattr(cls, name)
        descriptor.__set__(self, value)
    else:
        self.__flag_unknown_attrs(name)

def __flag_unknown_attrs(self: Any, *names: str) -> NoReturn:
    plural = 's' if len(names) > 1 else ''
    extra = ', '.join(f'{name!r}' for name in names)
    cls_name = repr(self.__class__.__name__)
    raise AttributeError(f'{cls_name} has no attribute{plural} {extra}')

def _asdict(self: Any) -> dict[str, Any]:
    return {
        name: getattr(self, name)
        for name, attr in self.__class__.__dict__.items()
        if isinstance(attr, Field)
    }

def __repr__(self: Any) -> str:
    kwargs = ', '.join(
        f'{key}={value!r}' for key, value in self._asdict().items()
    )
    return f'{self.__class__.__name__}({kwargs})'

checkeddeco.py 模块实现了一个简单但实用的类装饰器。 Python 的 @dataclass 实现了更多的功能。它支持许多配置选项,向装饰类添加更多方法,处理或警告与装饰类中用户定义方法的冲突,甚至遍历 __mro__ 以收集在装饰类的超类中声明的用户定义属性。Python 3.9 中的 dataclasses 包的源代码长达 1200 多行。

对于元编程类,我们必须知道 Python 解释器在构建类期间何时评估每个代码块。接下来将介绍这一点。

什么时候发生:导入时与运行时

Python 程序员谈论“导入时”与“运行时”,但这些术语没有严格定义,它们之间存在灰色区域。

在导入时,解释器:

  1. 从上到下一次性解析 .py 模块的源代码。这是可能发生 SyntaxError 的时候。
  2. 编译要执行的字节码。
  3. 执行编译模块的顶层代码。

如果本地 __pycache__ 中有可用的最新 .pyc 文件,则跳过解析和编译,因为字节码已经可以运行。

虽然解析和编译肯定是“导入时”的行为,但是那个时候可能会发生其他事情,因为 Python 中的几乎每条语句都是可执行的,这些语句可能运行用户代码并可能改变用户程序的状态。

特别是,import 语句不仅仅是一个声明语句,它实际上在进程中第一次导入时运行了模块的所有顶层代码--同一模块的后续导入将使用缓存,然后唯一的效果是将导入的对象绑定到客户端模块中的名称。顶级代码可以做任何事情,包括“运行时”的典型操作,例如写入日志或连接到数据库。这就是为什么“import time”和“runtime”之间的界限是模糊的:import语句可以触发各种“runtime”行为。相反,“导入时”也可能发生在运行时的深处,因为 import 语句和 __import__() 内置函数可以在任何常规函数中使用。

这一切都相当抽象和微妙,所以让我们做一些实验来看看什么时候会发生什么。

评估导入时和运行时的实验

给定一个 evaldemo.py 脚本,它使用类装饰器、描述符和基于 __init_subclass__ 的类构建器,所有这些都在 builderlib.py 模块中定义。这些模块有几个print语句调用来显示后台发生的事情。并且,它们不会执行任何有用的操作。这些实验的目的是观察这些print调用发生的顺序。

WARNING

在单个类中同时应用类装饰器和带有__init_subclass__ 的类构建器可能是过度设计的迹象。这种不常见的组合在这些实验中很有用,可以显示类装饰器和 __init_subclass__ 可以应用于类的生效时间。

让我们先从脚本 builderlib.py 开始,它分为两部分:示例 24-10 和示例 24-11。

示例 24-10。 builderlib.py:模块上半部分

print('@ builderlib module start')

class Builder:  1
    print('@ Builder body')

    def __init_subclass__(cls):  2
        print(f'@ Builder.__init_subclass__({cls!r})')

        def inner_0(self):  3
            print(f'@ SuperA.__init_subclass__:inner_0({self!r})')

        cls.method_a = inner_0

    def __init__(self):
        super().__init__()
        print(f'@ Builder.__init__({self!r})')


def deco(cls):  4
    print(f'@ deco({cls!r})')

    def inner_1(self):  5
        print(f'@ deco:inner_1({self!r})')

    cls.method_b = inner_1
    return cls  6
  1. 这是一个类构建器来实现......
  2. …一个 __init_subclass__ 方法
  3. 在下面的赋值中定义一个要添加到子类中的函数。
  4. 类装饰器。
  5. 要添加到被装饰类的函数。
  6. 返回作为参数传入的类。

继续 builderlib.py…

class Descriptor:  1
    print('@ Descriptor body')

    def __init__(self):  2
        print(f'@ Descriptor.__init__({self!r})')

    def __set_name__(self, owner, name):  3
        args = (self, owner, name)
        print(f'@ Descriptor.__set_name__{args!r}')

    def __set__(self, instance, value):  4
        args = (self, instance, value)
        print(f'@ Descriptor.__set__{args!r}')

    def __repr__(self):
        return '<Descriptor instance>'


print('@ builderlib module end')
  1. 一个描述符类,用于演示何时…… 
  2. …创建一个描述符实例,当…
  3. …__set_name__ 将在owner类构造期间调用。
  4. 和其他方法一样,这个 __set__ 除了显示它的参数之外什么都不做。

如果你在 Python 控制台中导入 builderlib.py,你会得到下面的结果:

>>> import builderlib
@ builderlib module start
@ Builder body
@ Descriptor body
@ builderlib module end

请注意,builderlib.py 打印的行以 @ 为前缀。

现在让我们转向 evaldemo.py,它将触发 builderlib 中的特殊方法。

示例 24-12。 evaldemo.py:用于试验 builderlib.py 的脚本。


from builderlib import Builder, deco, Descriptor

print('# evaldemo module start')

@deco  1
class Klass(Builder):  2
    print('# Klass body')

    attr = Descriptor()  3

    def __init__(self):
        super().__init__()
        print(f'# Klass.__init__({self!r})')

    def __repr__(self):
        return '<Klass instance>'


def main():  4
    obj = Klass()
    obj.method_a()
    obj.method_b()
    obj.attr = 999

if __name__ == '__main__':
    main()

print('# evaldemo module end')
  1. 应用装饰器。
  2. 集成builder来触发它的 __init_subclass__。
  3. 实例化描述符。
  4. 只有当模块作为主程序运行时才会调用它。

evaldemo.py 中的打印调用显示 # 前缀。如果再次打开控制台并导入 evaldemo.py,输出如下:

示例 24-13。使用 evaldemo.py 进行实验的控制台输出。

>>> import evaldemo
@ builderlib module start  1
@ Builder body
@ Descriptor body
@ builderlib module end
# evaldemo module start
# Klass body  2
@ Descriptor.__init__(<Descriptor instance>)  3
@ Descriptor.__set_name__(<Descriptor instance>,
      <class 'evaldemo.Klass'>, 'attr')                4
@ Builder.__init_subclass__(<class 'evaldemo.Klass'>)  5
@ deco(<class 'evaldemo.Klass'>)  6
# evaldemo module end
  1. 前 4 行是 from builderlib import... 的结果。如果您在上一个实验后没有关闭控制台,它们将不会出现,因为 builderlib.py 已经加载。
  2. 这标志着 Python 开始读取 Klass 的主体。此时,类对象还不存在。
  3. 描述符实例被创建并绑定到 Python 将传递给默认类对象构造函数的命名空间中的 attr:type.__new__。
  4. 此时,Python 的内置 type.__new__ 已创建 Klass 对象,并在提供__set_name__方法的描述符类的每个描述符实例上调用 __set_name__,并将 Klass 作为owner参数传递。
  5. type.__new__ 然后在 Klass 的超类上调用 __init_subclass__,将 Klass 作为唯一的参数传递。
  6. 当 type.__new__ 返回类对象时,Python 应用装饰器。在本例中,deco 返回的类绑定到模块命名空间中的 Klass。

type.__new__ 的实现是用 C 编写的。我刚刚描述的行为记录在 Python 数据模型参考的Creating the class object 部分。

请注意,evaldemo.py(示例 24-12)的 main() 函数没有在控制台会话(示例 24-13)中执行,因此没有创建 Klass 的实例。我们看到的所有动作都是由“导入时”操作触发的:导入 builderlib 和定义 Klass。

如果您将 evaldemo.py 作为脚本运行,您将看到与示例 24-13 相同的输出,但在最后一行之前有额外的行。额外的行是运行 main() 的结果:

示例 24-14。将 evaldemo.py 作为程序运行。

$ ./evaldemo.py
[... 9 lines omitted ...]
@ deco(<class '__main__.Klass'>)  1
@ Builder.__init__(<Klass instance>)  2
# Klass.__init__(<Klass instance>)
@ SuperA.__init_subclass__:inner_0(<Klass instance>)  3
@ deco:inner_1(<Klass instance>)  4
@ Descriptor.__set__(<Descriptor instance>, <Klass instance>, 999)  5
# evaldemo module end
  1. 前 10 行(包括这一行)与示例 24-13 中所示的相同。
  2. 由 Klass.__init__ 中的 super().__init__() 触发。
  3. 由 main 中的 obj.method_a() 触发; method_a 由 SuperA.__init_subclass__ 添加。
  4. 由 main 中的 obj.method_b() 触发; method_b 由 deco 注入。
  5. 由 main 中的 obj.attr = 999 触发。

带有 __init_subclass__ 的基类和类装饰器是强大的工具,但它们仅限于使用已经由 type.__new__ 构建的类。在极少数情况下,当您需要调整传递给 type.__new__ 的参数时,您需要一个元类。这就是本章和本书的最终目的地。

元类 101

[元类] 是比 99% 的用户所担心的更深层次的魔法。如果你想知道你是否需要元类,那么你不需要(真正需要它们的人肯定知道为什么要用,并且不需要解释为什么)。

               ------- Tim Peters, Inventor of the timsort algorithm and prolific Python contributor

元类是一个类工厂。与示例 24-2 中的 record_factory 相比,元类被编写为类。换句话说,元类是类,它的实例也是类。图 24-1 描述了一个使用 Mills & Gizmos 表示法的元类:一个工厂生产另一个工厂。

考虑 Python 对象模型:类是一个对象,因此每个类都必须是其他类的实例。 默认情况下,Python 类是类型的实例。换句话说,type 是大多数内置类和用户定义类的元类:

>>> str.__class__
<class 'type'>
>>> from bulkfood_v5 import LineItem
>>> LineItem.__class__
<class 'type'>
>>> type.__class__
<class 'type'>

为了避免无限回归,type的类是type,如最后一行所示。

请注意,我并不是说 str 或 LineItem 是type的子类。我要说的是 str 和 LineItem 是type的实例。它们都是Object的子类。图 24-2 可以帮助你面对这个奇怪的现实。

NOTE

类object和type具有独特的关系: object 是 type 的实例,type 是 object 的子类。这种关系是“神奇的”:它不能用 Python 表达,因为在定义另一个类之前必须存在一个类。type是自身的一个实例这一事实也很神奇。

下一个片段显示 collections.Iterable 的类是 abc.ABCMeta。请注意,Iterable 是一个抽象类,但 ABCMeta 是一个具体类——毕竟,Iterable 是 ABCMeta 的一个实例:

>>> from collections.abc import Iterable
>>> Iterable.__class__
<class 'abc.ABCMeta'>
>>> import abc
>>> from abc import ABCMeta
>>> ABCMeta.__class__
<class 'type'>

最终,ABCMeta 的类也是type。每个类都是直接或间接的type类的实例,但只有元类也是type的子类。这是理解元类最重要的关系:元类,例如 ABCMeta,从 type 继承构造类的能力。图 24-3 演示了这个重要的关系。

这里重要的一点是元类是type的子类,这就是为什么它们作为类工厂的原因。一个元类可以通过实现特殊的方法来定制它的实例,如下节所示。

元类如何自定义类

要使用元类,了解 __new__ 如何在类上工作至关重要。这在 “Flexible Object Creation with __new__”中进行了讨论。

当元类即将创建一个新实例(即类)时,相同的机制会发生在“元”级别。考虑这个声明:

class Klass(SuperKlass, metaclass=MetaKlass):
    x = 42
    def __init__(self, y):
        self.y = y

要处理这个class语句,Python 使用以下参数调用 MetaKlass.__new__:

meta_cls:

        元类本身(MetaKlass),因为 __new__ 用作类方法;

cls_name:

        字符串Klass

bases:

        单元素元组 (SuperKlass,)——在多重继承的情况下具有更多元素。

cls_dict:

        像 {x: 42, `__init__: <function init at 0x1009c4040>} 这样的映射

当您实现 MetaKlass.__new__ 时,您可以在将这些参数传递给 super().__new__ 之前检查并更改它们,super().__new__ 最终将调用 type.__new__ 来创建新的类对象。

在 super().__new__ 返回之后,您还可以在将新创建的类返回给 Python 之前对其进行进一步处理。然后 Python 调用 SuperKlass.__init_subclass__,传递您创建的类,然后将类装饰器应用到它(如果存在)。最后,Python 将类对象绑定到其在周围命名空间中的名称——如果class语句是顶级语句的话,通常是模块的全局命名空间。

在元类 __new__ 中进行的最常见处理是添加或替换 cls_dict 中的元素 - 表示正在构建的类的命名空间的映射。例如,在调用 super().__new__ 之前,您可以通过向 cls_dict 添加函数来在正在构建的类中注入方法。但是,请注意,添加方法也可以在类构建后完成,这就是我们能够使用 __init_subclass__ 或类装饰器来完成它的原因。

在 type.__new__ 运行之前必须添加到 cls_dict 的一个属性是 __slots__,如“Why __init_subclass__ cannot configure __slots__”中所述。元类的 __new__ 方法是配置 __slots__ 的理想位置。下一节将展示如何做到这一点。

一个优雅的元类示例

这里介绍的 MetaBunch 元类是 Python in a Nutshell 第三版第 4 章最后一个示例的变体,作者为 Alex Martelli、Anna Ravenscroft 和 Steve Holden,为在 Python 2.7 和 3.5 上运行而编写。假设 Python 3.6 或更高版本,我能够进一步简化代码。

首先,让我们看看 Bunch 基类提供了什么:

    >>> class Point(Bunch):
    ...     x = 0.0
    ...     y = 0.0
    ...     color = 'gray'
    ...
    >>> Point(x=1.2, y=3, color='green')
    Point(x=1.2, y=3, color='green')
    >>> p = Point()
    >>> p.x, p.y, p.color
    (0.0, 0.0, 'gray')
    >>> p
    Point()

请记住,Checked 根据类变量类型提示为子类中的字段描述符分配名称,这些提示实际上并没有成为类的属性,因为它们没有值。

另一方面,Bunch 子类使用带有值的实际类属性,然后这些值成为实例属性的默认值。生成的 __repr__ 省略了等于默认值的属性的参数。

MetaBunch——Bunch 的元类——根据用户类中声明的类属性为新类生成 __slots__。这会阻塞实例化和未声明属性的后续赋值:

    >>> Point(x=1, y=2, z=3)
    Traceback (most recent call last):
      ...
    AttributeError: No slots left for: 'z'
    >>> p = Point(x=21)
    >>> p.y = 42
    >>> p
    Point(x=21, y=42)
    >>> p.flavor = 'banana'
    Traceback (most recent call last):
      ...
    AttributeError: 'Point' object has no attribute 'flavor'

现在让我们深入研究 Metabunch 的优雅代码:

示例 24-15。 metabunch/from3.6/bunch.py​​:MetaBunch 元类和 Bunch 类。

class MetaBunch(type):  1
    def __new__(meta_cls, cls_name, bases, cls_dict):  2

        defaults = {}  3

        def __init__(self, **kwargs):  4
            for name, default in defaults.items():  5
                setattr(self, name, kwargs.pop(name, default))
            if kwargs:  6
                extra = ', '.join(kwargs)
                raise AttributeError(f'No slots left for: {extra!r}')

        def __repr__(self):  7
            rep = ', '.join(f'{name}={value!r}'
                            for name, default in defaults.items()
                            if (value := getattr(self, name)) != default)
            return f'{cls_name}({rep})'

        new_dict = dict(__slots__=[], __init__=__init__, __repr__=__repr__)  8

        for name, value in cls_dict.items():  9
            if name.startswith('__') and name.endswith('__'):  10
                if name in new_dict:
                    raise AttributeError(f"Can't set {name!r} in {cls_name!r}")
                new_dict[name] = value
            else:  11
                new_dict['__slots__'].append(name)
                defaults[name] = value
        return super().__new__(meta_cls, cls_name, bases, new_dict)  12


class Bunch(metaclass=MetaBunch):  13
    pass
  1.  要创建新的元类, 需要继承type。
  2. __new__ 作为一个类方法工作,但是这个类是一个元类,所以我喜欢将第一个参数命名为 meta_cls(mcs 是一个常见的替代方案)。其余三个参数与直接调用 type() 以创建类的三个参数签名相同。
  3. defaults 将保存属性名称及其默认值的映射。
  4. 这个方法将被注入到新类中。
  5. 读取defaults并使用从 kwargs 弹出的值或default设置相应的实例属性。
  6. 如果 kwargs 中仍然有元素,则意味着没有空位可以放置它们。我们相信快速失败是最佳实践,因此这里没有隐式忽略额外元素。一个快速有效的解决方案是从 kwargs 中弹出一个元素并尝试在实例上设置它,从而故意触发 AttributeError。
  7. __repr__ 返回一个看起来像构造函数调用的字符串——例如Point(x=3),省略带有默认值的关键字参数。
  8. 为新类初始化命名空间。
  9. 遍历用户类的命名空间...
  10. 如果找到一个 dunder 的name,则将该元素复制到新的类名称空间,除非它已经在new_dict中存在。这可以防止用户覆盖 __init__、__repr__ 和 Python 设置的其他属性,例如 __qualname__ 和 __module__。
  11. 如果不是 dunder 的name,则添加到 __slots__ 并将value保存在defaults中。
  12. 构建并返回新类。
  13. 提供一个基类,这样用户就不需要看到 MetaBunch。

MetaBunch 之所以有效,是因为它能够在调用 super().__new__ 之前配置 __slots__ 以构建最终类。通常进行元编程时,理解动作的顺序是关键。让我们做另一个评估时间实验,现在使用元类。

元类评估时间实验

这是 “Evaluation Time Experiments”的变体,添加了一个元类。builderlib.py 模块与之前相同,但主脚本现在是 evaldemo_meta.py,如示例 24-16 中所示。

示例 24-16。 evaldemo_meta.py:用元类做实验。

#!/usr/bin/env python3

from builderlib import Builder, deco, Descriptor
from metalib import MetaKlass  1

print('# evaldemo_meta module start')

@deco
class Klass(Builder, metaclass=MetaKlass):  2
    print('# Klass body')

    attr = Descriptor()

    def __init__(self):
        super().__init__()
        print(f'# Klass.__init__({self!r})')

    def __repr__(self):
        return '<Klass instance>'


def main():
    obj = Klass()
    obj.method_a()
    obj.method_b()
    obj.method_c()  3
    obj.attr = 999


if __name__ == '__main__':
    main()

print('# evaldemo_meta module end')
  1. 从 metalib.py 导入 MetaKlass,我们将在示例 24-18 中看到。
  2.  将 Klass 声明为 Builder 的子类和 MetaKlass 的实例。
  3. 正如我们将看到的,此方法由 MetaKlass.__new__ 注入。

WARNING

出于对科学的兴趣,示例 24-16 不顾一切地在 Klass 上应用了三种不同的元编程技术:一个装饰器、一个使用 __init_subclass__ 的基类和一个自定义元类。如果你在生产代码中这样做,请不要怪我。同样,目标是观察这三种技术在类构建过程中的干扰顺序。

与之前的评估时间实验一样,此示例仅打印显示执行流程的消息。接下来是 metalib.py 顶部的代码——其余部分在示例 24-18 中:

示例 24-17. metalib.py: the NosyDict class

print('% metalib module start')

import collections

class NosyDict(collections.UserDict):
    def __setitem__(self, key, value):
        args = (self, key, value)
        print(f'% NosyDict.__setitem__{args!r}')
        super().__setitem__(key, value)

    def __repr__(self):
        return '<NosyDict instance>'

我编写了 NosyDict 类来覆盖 __setitem__ 以在设置时显示每个key和value。元类将使用 NosyDict 实例来保存正在构建的类的命名空间,从而揭示更多 Python 的内部工作原理。

metalib.py 的主要吸引力在于示例 24-18 中的元类。它实现了 __prepare__ 特殊方法,这是 Python 仅在元类上调用的类方法。__prepare__ 方法提供了影响创建新类过程的最早机会。

TIP:

在编写元类时,我发现对特殊方法参数采用这种命名约定很有用:

在实例方法中使用 cls 而不是 self ,因为实例是一个类。

对类方法使用 meta_cls 而不是 cls,因为该类是一个元类。回想一下,即使没有 @classmethod 装饰器,__new__ 也表现得像一个类方法。

示例 24-18. metalib.py: the MetaKlass

class MetaKlass(type):
    print('% MetaKlass body')

    @classmethod  1
    def __prepare__(meta_cls, cls_name, bases):  2
        args = (meta_cls, cls_name, bases)
        print(f'% MetaKlass.__prepare__{args!r}')
        return NosyDict()  3

    def __new__(meta_cls, cls_name, bases, cls_dict):  4
        args = (meta_cls, cls_name, bases, cls_dict)
        print(f'% MetaKlass.__new__{args!r}')
        def inner_2(self):
            print(f'% MetaKlass.__new__:inner_2({self!r})')

        cls = super().__new__(meta_cls, cls_name, bases, cls_dict.data)  5

        cls.method_c = inner_2  6

        return cls  7

    def __repr__(cls):  8
        cls_name = cls.__name__
        return f"<class {cls_name!r} built by MetaKlass>"

print('% metalib module end')
  1. __prepare__ 应声明为类方法。它不是实例方法,因为当 Python 调用 __prepare__ 时,正在构建的类还不存在。
  2. Python 在元类上调用 __prepare__ 以获取映射以保存正在构建的类的命名空间。
  3. 返回要用作命名空间的 NosyDict 实例。
  4. cls_dict 是 __prepare__ 返回的 NosyDict 实例。
  5. type.__new__ 需要一个真正的 dict 作为最后一个参数,所以我给它一个继承自 UserDict 的 NosyDict 的data属性。 
  6. 在新创建的类中注入一个方法。
  7. 像往常一样, __new__ 必须返回刚刚创建的对象——在本例中是新类。
  8. 在元类上定义 __repr__ 允许自定义类对象的 repr()。

Python 3.6 之前 __prepare__ 的主要用例是提供一个 OrderedDict 来保存正在构建的类的属性,以便元类 __new__ 可以按照它们在用户类定义的源代码中出现的顺序处理这些属性。现在 dict 保留了插入顺序,很少需要 __prepare__ 。您将在“A Metaclass Hack with __prepare__”中看到它的创造性用途。

在 Python 控制台中导入 metalib.py 并不是很令人兴奋。请注意使用 % 作为此模块输出的前缀:

>>> import metalib
% metalib module start
% MetaKlass body
% metalib module end

如果你导入 evaldemo_meta.py,会发生很多事情:

示例 24-19。使用 evaldemo_meta.py 进行控制台实验。

>>> import evaldemo_meta
@ builderlib module start
@ Builder body
@ Descriptor body
@ builderlib module end
% metalib module start
% MetaKlass body
% metalib module end
# evaldemo_meta module start  1
% MetaKlass.__prepare__(<class 'metalib.MetaKlass'>, 'Klass',  2
                        (<class 'builderlib.Builder'>,))
% NosyDict.__setitem__(<NosyDict instance>, '__module__', 'evaldemo_meta')  3
% NosyDict.__setitem__(<NosyDict instance>, '__qualname__', 'Klass')
# Klass body
@ Descriptor.__init__(<Descriptor instance>)  4
% NosyDict.__setitem__(<NosyDict instance>, 'attr', <Descriptor instance>)  5
% NosyDict.__setitem__(<NosyDict instance>, '__init__',
                       <function Klass.__init__ at …>)  6
% NosyDict.__setitem__(<NosyDict instance>, '__repr__',
                       <function Klass.__repr__ at …>)
% NosyDict.__setitem__(<NosyDict instance>, '__classcell__', <cell at …: empty>)
% MetaKlass.__new__(<class 'metalib.MetaKlass'>, 'Klass',
                    (<class 'builderlib.Builder'>,), <NosyDict instance>)  7
@ Descriptor.__set_name__(<Descriptor instance>,
                          <class 'Klass' built by MetaKlass>, 'attr')  8
@ Builder.__init_subclass__(<class 'Klass' built by MetaKlass>)
@ deco(<class 'Klass' built by MetaKlass>)
# evaldemo_meta module end
  1. 在此之前的行是导入 builderlib.py 和 metalib.py 的结果。
  2. Python 调用 __prepare__ 开始处理类语句。
  3. 在解析类主体之前,Python 将 __module__ 和 __qualname__ 元素添加到正在构建的类的命名空间中。 
  4. 描述符实例被创建...
  5. …并绑定到类命名空间中的 attr。
  6. __init__ 和 __repr__ 方法被定义并添加到命名空间。
  7. 一旦 Python 完成了类主体的处理,它就会调用 MetaKlass.__new__。
  8. 在元类的 __new__ 方法返回新构建的类之后,__set_name__、__init_subclass__ 和装饰器依次调用。

如果您将 evaldemo_meta.py 作为脚本运行,则会调用 main(),然后会发生更多事情:

示例 24-20。将 evaldemo_meta.py 作为程序运行。

$ ./evaldemo_meta.py
[... 20 lines omitted ...]
@ deco(<class 'Klass' built by MetaKlass>)  1
@ Builder.__init__(<Klass instance>)
# Klass.__init__(<Klass instance>)
@ SuperA.__init_subclass__:inner_0(<Klass instance>)
@ deco:inner_1(<Klass instance>)
% MetaKlass.__new__:inner_2(<Klass instance>)  2
@ Descriptor.__set__(<Descriptor instance>, <Klass instance>, 999)
# evaldemo_meta module end
  1. 前 21 行(包括这一行)与示例 24-19 中所示的相同。
  2. 由 main 中的 obj.method_c() 触发; method_c 由 MetaKlass.__new__ 注入。 

现在让我们回到 Checked 类的想法,使用字段描述符实现运行时类型验证,看看如何使用元类来完成。

Checked 的元类解决方案

TBD

 

现实世界中的元类

元类很强大但也很棘手。在决定实现元类之前,请考虑以下几点。

现代功能简化或替换元类

随着时间的推移,元类的几个常见用例因新的语言特性而变得多余:

类装饰器(Class decorators):

比元类更容易理解,并且不太可能与基类和元类发生冲突。

__set_name__:

避免需要自定义元类逻辑来自动设置描述符的名称。

__init_subclass__:

提供一种自定义类创建的方法,该方法对最终用户透明,甚至比装饰器更简单——但可能会在复杂的类层次结构中引入冲突。

内置 dict 保留键插入顺序:

消除了使用 __prepare__ 的第一个原因:提供一个 OrderedDict 来存储正在构建的类的命名空间。Python 只在元类上调用 __prepare__,所以如果你需要按照它在源代码中出现的顺序来处理类命名空间,你必须在 Python 3.6 之前使用元类。

截至 2021 年,每个积极维护的 CPython 版本都支持上述所有功能。

我一直提倡这些特性,因为我看到我们的专业中有太多不必要的复杂性,而元类是通向复杂性的门户。

元类是稳定的语言特性

元类于 2002 年在 Python 2.2 中引入,以及所谓的“新型类”、描述符和属性。

值得注意的是,由 Alex Martelli 于 2002 年 7 月首次发布的 MetaBunch 示例,在python3.9中一样适用-----唯一的变化是指定要使用的元类的方式,在 Python 3 中是通过语法 class Bunch(metaclass=MetaBunch): 完成的。

我在 “Modern Features Simplify or Replace Metaclasses”中提到的任何添加都没有破坏使用元类的现有代码。但是使用元类的遗留代码通常可以通过利用这些特性来简化,特别是如果您可以放弃对 3.6 之前的 Python 版本的支持——这些版本不再维护。

一个类只能有一个元类

如果您的类声明涉及两个或更多元类,您将看到以下令人费解的错误消息:

TypeError: metaclass conflict: the metaclass of a derived class
must be a (non-strict) subclass of the metaclasses of all its bases

即使没有多重继承,这也可能发生。例如,这样的声明可能会触发 TypeError:

class Record(abc.ABC, metaclass=PersistentMeta):
    pass

 我们看到 abc.ABC 是 abc.ABCMeta 元类的一个实例。如果 Persistent 元类本身不是 abc.ABCMeta 的子类,则会发生元类冲突。

有两种方法可以处理该错误:

  • 找到一些其他的方法来做你需要做的事情,同时避免至少多个元类。
  • 使用多重继承将您自己的 PersistentABCMeta 元类编写为 abc.ABCMeta 和 PersistentMeta 的子类,并将其用作 Record 的唯一元类。

TIP:

我可以想象元类的解决方案,其中实现了两个基本元类以满足最后期限。以我的经验,元类编程总是比预期花费更长的时间,这使得这种方法在最后期限之前存在风险。如果您这样做并在最后期限之前完成,代码可能包含细微的错误。即使没有已知错误,您也应该将这种方法视为技术债务,因为它难以理解和维护。

元类应该是实现细节

除了 type 之外,整个 Python 3.9 标准库中只有六个元类。更为人所知的可能是 abc.ABCMeta、typing.NamedTupleMeta 和 enum.EnumMeta。它们都不打算显式地出现在用户代码中。我们可以考虑它们的实现细节。

虽然你可以用元类做一些非常古怪的元编程,但最好注意Principle of least astonishment,以便大多数用户确实可以将元类视为实现细节。

近年来,Python 标准库中的一些元类被其他机制取代,而没有破坏其包的公共 API。使此类 API 面向未来的最简单方法是提供一个常规类,用户子类化该类以访问元类提供的功能,正如我们在示例中所做的那样。

为了结束我们对类元编程的介绍,我将与您分享我在研究本章时发现的最酷的元类小例子。

使用 __prepare__ 的元类技巧

当我为第二版更新本章时,我需要找到简单但有启发性的示例来替换自 Python 3.6 以来不再需要元类的 bulkfood LineItem 代码。

João S. O. Bueno 给了我最简单和最有趣的元类想法——在巴西 Python 社区中被称为 JS。他的想法的一个应用是创建一个自动生成数字常量的类。

    >>> class Flavor(AutoConst):
    ...     banana
    ...     coconut
    ...     vanilla
    ...
    >>> Flavor.vanilla
    2
    >>> Flavor.banana, Flavor.coconut
    (0, 1)

是的,该代码如图所示工作!这实际上是 autoconst_demo.py 中的一个 doctest。

这是用户友好的 AutoConst 基类及其背后的元类,在 autoconst.py 中实现:

class AutoConstMeta(type):
    def __prepare__(name, bases, **kwargs):
        return WilyDict()

class AutoConst(metaclass=AutoConstMeta):
    pass

 仅此而已。

显然,秘密在 WilyDict。

当 Python 处理用户类的命名空间并读取banana时,它会在 __prepare__ 提供的映射中查找该名称:WilyDict 的一个实例.WilyDict 实现了 __missing__——在“__missing__ 方法”中有介绍。WilyDict 实例最初没有“banana"键,因此触发了 __missing__ 方法。它使用键“banana”和值 0 即时创建一个项,并返回该值。Python 对此感到满意,然后尝试检索“coconut”。 WilyDict 立即将该元素添加为 1,并将其返回。 'vanilla' 也会发生同样的情况,然后将其映射到 2。

我们以前见过 __prepare__ 和 __missing__。真正的创新在于 JS 如何将它们组合在一起。

这是 WilyDict 的源代码,同样来自 autoconst.py:

class WilyDict(dict):
    def __init__(self, *args, **kwargs):
        super().__init__(*args, **kwargs)
        self.__next_value = 0

    def __missing__(self, key):
        if key.startswith('__') and key.endswith('__'):
            raise KeyError(key)
        self[key] = value = self.__next_value
        self.__next_value += 1
        return value

在实验中,我发现 Python 在正在构建的类的命名空间中查找 __name__,导致 WilyDict 添加了一个 __name__ 条目,并增加了 __next_value。所以我在 __missing__ 中添加了 if 语句来为看起来像 dunder 属性的键抛出 KeyError。

autoconst.py 包既需要也说明了对 Python 的动态类构建机制的掌握。

我很高兴为 AutoConstMeta 和 AutoConst 添加更多功能,但我不会分享我的实验,而是让你玩 JS 的巧妙 hack。

这里有一些建议: 

  • 如果您有值,则可以检索常量名称。例如,Flavor[2] 可以返回 'vanilla'。您可以通过在 AutoConstMeta 中实现 __getitem__ 来实现这一点。从 Python 3.9 开始,您可以在 AutoConst 本身中实现 ​​__class_getitem__。
  • 通过在元类上实现 __iter__ 来支持对类的迭代。我会让 __iter__ 将常量作为 (name, value) 对产生。
  • 实现一个新的 Enum 变体。这将是一项艰巨的任务,因为 enum 包充满了技巧,包括具有数百行代码的 EnumMeta 元类和一个不平凡的 __prepare__ 方法。

NOTE

The __class_getitem__ special method was added in Python 3.9 to support generic types, as part of PEP 585—Type Hinting Generics In Standard Collections. Thanks to __class_getitem__, Python’s core developers did not have to write a new metaclass for the built-in types to implement __getitem__ so that we could write generic type hints like list[int]. This is a narrow feature, but representative of a wider use case for metaclasses: implementing operators and other special methods to work at the class level, such as making the class itself iterable, just like Enum subclasses.

总结

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

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值