第二十四章:类元编程
每个人都知道调试比一开始编写程序要困难两倍。所以如果你在编写时尽可能聪明,那么你将如何调试呢?
Brian W. Kernighan 和 P. J. Plauger,《编程风格的要素》¹
类元编程是在运行时创建或自定义类的艺术。在 Python 中,类是一等对象,因此可以使用函数在任何时候创建一个新类,而无需使用 class
关键字。类装饰器也是函数,但设计用于检查、更改甚至替换装饰的类为另一个类。最后,元类是类元编程的最高级工具:它们让你创建具有特殊特性的全新类别的类,例如我们已经看到的抽象基类。
元类很强大,但很难证明其合理性,甚至更难正确使用。类装饰器解决了许多相同的问题,并且更容易理解。此外,Python 3.6 实现了 PEP 487—更简单的类创建自定义,提供了支持以前需要元类或类装饰器完成的任务的特殊方法。²
本章按复杂性递增的顺序介绍了类元编程技术。
警告
这是一个令人兴奋的话题,很容易让人着迷。因此,我必须提供这些建议。
为了可读性和可维护性,你可能应该避免在应用代码中使用本章描述的技术。
另一方面,如果你想编写下一个伟大的 Python 框架,这些就是你的工具。
本章新内容
第一版《流畅的 Python》“类元编程”章节中的所有代码仍然可以正确运行。然而,由于自 Python 3.6 以来添加了新功能,一些先前的示例不再代表最简单的解决方案。
我用不同的示例替换了那些示例,突出了 Python 的新元编程特性或添加了进一步的要求,以证明使用更高级技术的合理性。一些新示例利用类型提示提供了类构建器,类似于 @dataclass
装饰器和 typing.NamedTuple
。
“现实世界中的元类” 是一个关于元类适用性的高层考虑的新部分。
提示
一些最好的重构是通过删除由更新和更简单的解决相同问题的方法所导致的冗余代码来实现的。这适用于生产代码以及书籍。
我们将从审查 Python 数据模型中为所有类定义的属性和方法开始。
类作为对象
像 Python 中的大多数程序实体一样,类也是对象。每个类在 Python 数据模型中都有一些属性,这些属性在《Python 标准库》的“内置类型”章节中的 “4.13. 特殊属性” 中有文档记录。这本书中已经多次出现了其中的三个属性:__class__
、__name__
和 __mro__
。其他类的标准属性包括:
cls.__bases__
类的基类元组。
cls.__qualname__
类或函数的限定名称,这是从模块的全局范围到类定义的点路径。当类在另一个类内部定义时,这是相关的。例如,在 Django 模型类中,比如 Ox
,有一个名为 Meta
的内部类。Meta
的 __qualname__
是 Ox.Meta
,但它的 __name__
只是 Meta
。此属性的规范是 PEP 3155—类和函数的限定名称。
cls.__subclasses__()
此方法返回类的直接子类列表。该实现使用弱引用以避免超类和其子类之间的循环引用——后者在其__bases__
属性中保留对超类的强引用。该方法列出当前内存中的子类。尚未导入的模块中的子类不会出现在结果中。
cls.mro()
解释器在构建类时调用此方法,以获取存储在类的__mro__
属性中的超类元组。元类可以重写此方法以自定义正在构建的类的方法解析顺序。
提示
此部分提到的属性都不会被dir(…)
函数列出。
现在,如果一个类是一个对象,那么一个类的类是什么?
类型:内置类工厂
我们通常认为type
是一个返回对象类的函数,因为type(my_object)
的作用是返回my_object.__class__
。
然而,type
是一个在用三个参数调用时创建新类的类。
考虑这个简单的类:
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
属性名称到值的映射。可调用对象变成方法,就像我们在“方法是描述符”中看到的那样。其他值变成类属性。
注意
type
构造函数接受可选的关键字参数,这些参数会被type
本身忽略,但会原封不动地传递到__init_subclass__
中,后者必须消耗这些参数。我们将在“介绍 init_subclass”中学习这个特殊方法,但我不会涉及关键字参数的使用。更多信息,请阅读PEP 487—更简单的类创建自定义。
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
。在第五章中,我们还看到了typing.NamedTuple
和@dataclass
。所有这些类构建器都利用了本章介绍的技术。
我们将从一个用于可变对象类的超级简单工厂开始——这是@dataclass
的最简单替代品。
假设我正在编写一个宠物店应用程序,我想将狗的数据存储为简单记录。但我不想写这样的样板代码:
class Dog: def __init__(self, name, weight, owner): self.name = name self.weight = weight self.owner = owner
无聊…每个字段名称出现三次,而且那些样板代码甚至不能为我们提供一个漂亮的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') # ① >>> rex = Dog('Rex', 30, 'Bob') >>> rex # ② Dog(name='Rex', weight=30, owner='Bob') >>> name, weight, _ = rex # ③ >>> name, weight ('Rex', 30) >>> "{2}'s dog weighs {1}kg".format(*rex) # ④ "Bob's dog weighs 30kg" >>> rex.weight = 32 # ⑤ >>> rex Dog(name='Rex', weight=32, owner='Bob') >>> Dog.__mro__ # ⑥ (<class 'factories.Dog'>, <class 'object'>)
①
工厂可以像namedtuple
一样调用:类名,后跟用单个字符串中的空格分隔的属性名称。
②
漂亮的repr
。
③
实例是可迭代的,因此它们可以在赋值时方便地解包…
④
…或者当传递给format
等函数时。
⑤
记录实例是可变的。
⑥
新创建的类继承自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]] # ① def record_factory(cls_name: str, field_names: FieldNames) -> type[tuple]: # ② slots = parse_identifiers(field_names) # ③ def __init__(self, *args, **kwargs) -> None: # ④ attrs = dict(zip(self.__slots__, args)) attrs.update(kwargs) for name, value in attrs.items(): setattr(self, name, value) def __iter__(self) -> Iterator[Any]: # ⑤ for name in self.__slots__: yield getattr(self, name) def __repr__(self): # ⑥ 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( # ⑦ __slots__=slots, __init__=__init__, __iter__=__iter__, __repr__=__repr__, ) return type(cls_name, (object,), cls_attrs) # ⑧ def parse_identifiers(names: FieldNames) -> tuple[str, ...]: if isinstance(names, str): names = names.replace(',', ' ').split() # ⑨ if not all(s.isidentifier() for s in names): raise ValueError('names must all be valid identifiers') return tuple(names)
①
用户可以将字段名称提供为单个字符串或字符串的可迭代对象。
②
接受类似于collections.namedtuple
的前两个参数;返回一个type
,即一个类,其行为类似于tuple
。
③
构建属性名称的元组;这将是新类的__slots__
属性。
④
此函数将成为新类中的__init__
方法。它接受位置参数和/或关键字参数。⁴
⑤
按照__slots__
给定的顺序产生字段值。
⑥
生成漂亮的repr
,遍历__slots__
和self
。
⑦
组装类属性的字典。
⑧
构建并返回新类,调用type
构造函数。
⑨
将由空格或逗号分隔的names
转换为str
列表。
示例 24-2 是我们第一次在类型提示中看到type
。如果注释只是-> type
,那意味着record_factory
返回一个类,这是正确的。但是注释-> type[tuple]
更精确:它表示返回的类将是tuple
的子类。
record_factory
在示例 24-2 的最后一行构建了一个由cls_name
的值命名的类,以object
作为其唯一的直接基类,并且具有一个加载了__slots__
、__init__
、__iter__
和__repr__
的命名空间,其中最后三个是实例方法。
我们可以将__slots__
类属性命名为其他任何名称,但然后我们必须实现__setattr__
来验证被分配的属性名称,因为对于类似记录的类,我们希望属性集始终相同且顺序相同。但是,请记住,__slots__
的主要特点是在处理数百万个实例时节省内存,并且使用__slots__
有一些缺点,讨论在“使用 slots 节省内存”中。
警告
由record_factory
创建的类的实例不可序列化,也就是说,它们无法使用pickle
模块的dump
函数导出。解决这个问题超出了本示例的范围,本示例旨在展示type
类在简单用例中的应用。要获取完整解决方案,请查看collections.namedtuple
的源代码;搜索“pickling”一词。
现在让我们看看如何模拟更现代的类构建器,比如typing.NamedTuple
,它接受一个用户定义的class
语句编写的类,并自动增强其功能。
引入__init_subclass__
。
__init_subclass__
和__set_name__
都在PEP 487—更简单的类创建自定义中提出。我们第一次在“LineItem Take #4: Automatic Naming of Storage Attributes”中看到描述符的__set_name__
特殊方法。现在让我们研究__init_subclass__
。
在第五章中,我们看到typing.NamedTuple
和@dataclass
允许程序员使用class
语句为新类指定属性,然后通过类构建器增强该类,自动添加必要的方法如__init__
,__repr__
,__eq__
等。
这两个类构建器都会读取用户class
语句中的类型提示以增强类。这些类型提示还允许静态类型检查器验证设置或获取这些属性的代码。然而,NamedTuple
和@dataclass
在运行时不利用类型提示进行属性验证。下一个示例中的Checked
类会这样做。
注意
不可能支持每种可能的静态类型提示进行运行时类型检查,这可能是为什么typing.NamedTuple
和@dataclass
甚至不尝试的原因。然而,一些也是具体类的类型可以与Checked
一起使用。这包括通常用于字段内容的简单类型,如str
,int
,float
和bool
,以及这些类型的列表。
示例 24-3 展示了如何使用Checked
构建Movie
类。
示例 24-3. initsub/checkedlib.py:创建Checked
的Movie
子类的 doctest
>>> class Movie(Checked): # ① ... 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)
①
Movie
继承自Checked
—我们稍后将在示例 24-5 中定义。
②
每个属性都用构造函数进行了注释。这里我使用了内置类型。
③
必须使用关键字参数创建Movie
实例。
④
作为回报,您会得到一个漂亮的__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 子类和静态类型检查
在一个带有Movie
实例movie
的*.py*源文件中,如示例 24-3 中定义的,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 所示。
示例 24-4. initsub/checkedlib.py:Field
描述符类
from collections.abc import Callable # ① from typing import Any, NoReturn, get_type_hints class Field: def __init__(self, name: str, constructor: Callable) -> None: # ② if not callable(constructor) or constructor is type(None): # ③ 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 ...: # ④ value = self.constructor() else: try: value = self.constructor(value) # ⑤ except (TypeError, ValueError) as e: # ⑥ 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 # ⑦
①
请注意,自 Python 3.9 以来,用于注释的Callable
类型是collections.abc
中的 ABC,而不是已弃用的typing.Callable
。
②
这是一个最小的Callable
类型提示;constructor
的参数类型和返回类型都隐含为Any
。
③
对于运行时检查,我们使用callable
内置函数。⁷ 对type(None)
的测试是必要的,因为 Python 将类型中的None
解读为NoneType
,即None
的类(因此可调用),但是一个无用的构造函数,只返回None
。
④
如果Checked.__init__
将value
设置为...
(内置对象Ellipsis
),我们将不带参数调用constructor
。
⑤
否则,使用给定的value
调用constructor
。
⑥
如果constructor
引发这些异常中的任何一个,我们将引发TypeError
,并提供一个包含字段和构造函数名称的有用消息;例如,'MMIX'与 year:int 不兼容
。
⑦
如果没有引发异常,则将value
存储在instance.__dict__
中。
在__set__
中,我们需要捕获TypeError
和ValueError
,因为内置构造函数可能会引发其中之一,具体取决于参数。例如,float(None)
引发TypeError
,但float('A')
引发ValueError
。另一方面,float('8')
不会引发错误,并返回8.0
。我在此声明,这是这个玩具示例的一个特性,而不是一个 bug。
提示
在“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]: # ① return get_type_hints(cls) def __init_subclass__(subclass) -> None: # ② super().__init_subclass__() # ③ for name, constructor in subclass._fields().items(): # ④ setattr(subclass, name, Field(name, constructor)) # ⑤ def __init__(self, **kwargs: Any) -> None: for name in self._fields(): # ⑥ value = kwargs.pop(name, ...) # ⑦ setattr(self, name, value) # ⑧ if kwargs: # ⑨ self.__flag_unknown_attrs(*kwargs) # ⑩
①
我编写了这个类方法,以隐藏对typing.get_type_hints
的调用,使其不被类的其他部分所知晓。如果我需要支持 Python ≥ 3.10,我会调用inspect.get_annotations
。请查看“运行时注解的问题”以了解这些函数的问题。
②
当定义当前类的子类时,会调用__init_subclass__
。它将新的子类作为第一个参数传递进来,这就是为什么我将参数命名为subclass
而不是通常的cls
。有关更多信息,请参阅“init_subclass 不是典型的类方法”。
③
super().__init_subclass__()
并非绝对必要,但应该被调用,以便与可能在相同继承图中实现.__init_subclass__()
的其他类友好相处。请参阅“多重继承和方法解析顺序”。
④
遍历每个字段的name
和constructor
…
⑤
…在subclass
上创建一个属性,该属性的name
绑定到一个使用name
和constructor
参数化的Field
描述符。
⑥
对于类字段中的每个name
…
⑦
…从kwargs
中获取相应的value
并将其从kwargs
中删除。使用...
(Ellipsis
对象)作为默认值允许我们区分给定值为None
的参数和未给定的参数。⁸
⑧
这个setattr
调用触发了Checked.__setattr__
,如示例 24-6 所示。
⑨
如果kwargs
中还有剩余项,它们的名称与声明的字段不匹配,__init__
将失败。
⑩
错误由__flag_unknown_attrs
报告,列在示例 24-6 中。它使用*names
参数来传递未知属性名称。我在*kwargs
中使用单个星号将其键作为参数序列传递。
现在让我们看看Checked
类的剩余方法,从示例 24-5 继续。请注意,我在_fields
和_asdict
方法名称前加上_
的原因与collections.namedtuple
API 相同:为了减少与用户定义的字段名称发生冲突的机会。
流畅的 Python 第二版(GPT 重译)(十三)(2)https://developer.aliyun.com/article/1485190