流畅的 Python 第二版(GPT 重译)(十三)(1)

简介: 流畅的 Python 第二版(GPT 重译)(十三)

第二十四章:类元编程

每个人都知道调试比一开始编写程序要困难两倍。所以如果你在编写时尽可能聪明,那么你将如何调试呢?

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一起使用。这包括通常用于字段内容的简单类型,如strintfloatbool,以及这些类型的列表。

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

示例 24-3. initsub/checkedlib.py:创建CheckedMovie子类的 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__

用作属性类型提示的构造函数可以是任何可调用的函数,接受零个或一个参数并返回适合预期字段类型的值,或者通过引发TypeErrorValueError拒绝参数。

在示例 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__中,我们需要捕获TypeErrorValueError,因为内置构造函数可能会引发其中之一,具体取决于参数。例如,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__()的其他类友好相处。请参阅“多重继承和方法解析顺序”。

遍历每个字段的nameconstructor

…在subclass上创建一个属性,该属性的name绑定到一个使用nameconstructor参数化的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

相关文章
|
10天前
|
存储 缓存 算法
Python性能优化:让你的代码更快更流畅
本文介绍了优化 Python 代码性能的十二个技巧,包括使用内置数据类型和函数、避免不必要的循环和递归、使用局部变量、利用生成器节省内存、选择合适的数据结构、并行和并发处理、使用第三方库、缓存减少重复计算、代码剖析和性能分析、优化算法和数据结构以及减少 I/O 操作。通过这些方法,开发者可以编写出运行更快、效率更高的 Python 程序。
|
19天前
|
数据采集 存储 人工智能
【Python+微信】【企业微信开发入坑指北】4. 企业微信接入GPT,只需一个URL,自动获取文章总结
【Python+微信】【企业微信开发入坑指北】4. 企业微信接入GPT,只需一个URL,自动获取文章总结
48 0
|
19天前
|
Python
过年了,让GPT用Python给你写个放烟花的程序吧!
过年了,让GPT用Python给你写个放烟花的程序吧!
27 0
|
19天前
|
人工智能 JSON 机器人
【Chat GPT】用 ChatGPT 运行 Python
【Chat GPT】用 ChatGPT 运行 Python
|
19天前
|
机器学习/深度学习 人工智能 自然语言处理
总结几个GPT的超实用之处【附带Python案例】
总结几个GPT的超实用之处【附带Python案例】
|
19天前
|
前端开发 JavaScript 安全
JavaScript 权威指南第七版(GPT 重译)(七)(4)
JavaScript 权威指南第七版(GPT 重译)(七)
30 0
|
19天前
|
前端开发 JavaScript 算法
JavaScript 权威指南第七版(GPT 重译)(七)(3)
JavaScript 权威指南第七版(GPT 重译)(七)
43 0
|
19天前
|
前端开发 JavaScript Unix
JavaScript 权威指南第七版(GPT 重译)(七)(2)
JavaScript 权威指南第七版(GPT 重译)(七)
44 0
|
19天前
|
前端开发 JavaScript 算法
JavaScript 权威指南第七版(GPT 重译)(七)(1)
JavaScript 权威指南第七版(GPT 重译)(七)
71 0
|
19天前
|
存储 前端开发 JavaScript
JavaScript 权威指南第七版(GPT 重译)(六)(4)
JavaScript 权威指南第七版(GPT 重译)(六)
144 3
JavaScript 权威指南第七版(GPT 重译)(六)(4)