每个人都知道,一开始调试的难度是编写程序的两倍。因此,如果您在编写程序时尽可能地聪明,您将如何调试它?
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'>)
- 工厂可以像 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]] 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)
- 用户可以将字段名称提供为单个字符串或字符串组成的可迭代对象。
- 接受像 collections.namedtuple 的前两个这样的参数;返回一个type——即一个类——这个类表现为tuple。
- 构建一个属性名称元组,这将是新类的 __slots__ 属性。
- 这个函数将成为新类中的 __init__ 方法。它接受位置和/或关键字参数
- 按照 __slots__ 给出的顺序生成字段值。
- 友好的repr,迭代 __slots__ 和 self.
- 生成类属性字典。
- 构建并返回新类,调用type的构造函数。
- 将由空格或逗号分隔的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)
- Movie 继承自 Checked——我们将在后面的示例 24-5 中对其进行定义。
- 每个属性都使用构造函数进行注解。这里我使用了内置类型。
- Movie实例必须使用关键字参数创建。
- 作为回报,你会得到一个友好的 __repr__。
用作属性类型提示的构造函数可以是任何可调用的对象,它接受零个或一个参数并返回适合预期字段类型的值,或者通过抛出 TypeError 或 ValueError 来拒绝参数。
为示例 24-3 中的注解使用内置类型意味着该类型的构造函数必须可以接收这些值。对于 int&#x