它是如何工作的?(How Does It Work?)

样板(Boilerplate)

attrs 不是第一个旨在简化 Python 类定义的库。 但其 声明式 方法结合 无运行时开销 的特点使其独树一帜。

一旦你将 @attrs.define(或 @attr.s)装饰器应用于一个类,attrs 会在类对象中搜索 attr.ib 的实例。 它们在内部是传递给 attr.ib 的数据的表示,带有一个计数器以保持属性的顺序。 另外,也可以使用 类型注解 来定义它们。

为了确保子类化按预期工作,attrs 还会遍历类层次结构,收集所有基类的属性。 请注意,attrs 不会 调用 super()。 它会编写 双下划线方法 来处理 所有 这些属性,这也带来了由于减少函数调用而提高的性能。

一旦 attrs 知道它需要处理哪些属性,它就会写入请求的 双下划线方法,并根据你希望创建的是 dict 还是 slotted 类,创建一个新类(slots=True)或将它们附加到原始类(slots=False)。 虽然创建新类更优雅,但我们遇到了一些围绕元类的边缘情况,使得无法无条件地走这条路。

明确一点:如果你定义一个只有一个没有默认值的属性的类,生成的 __init__ 将看起来 完全 如你所预期:

>>> import inspect
>>> from attrs import define
>>> @define
... class C:
...     x: int
>>> print(inspect.getsource(C.__init__))
def __init__(self, x):
    self.x = x

没有魔法,没有元编程,没有昂贵的运行时反射。


直到这一点为止,所有的操作都是在类定义时 一次性 完成的。 一旦一个类定义完成,它就完成了。 它只是一个常规的 Python 类,除了有一个 __attrs_attrs__ 属性,attrs 在内部使用。 很多信息可以通过 attrs.fields() 和其他函数访问,这些函数可用于反射或为 attrs 编写你自己的工具和装饰器(如 attrs.asdict())。

一旦你开始实例化你的类,attrs 就完全不再干预。

这种 静态 方法是 attrs 的一个设计目标,我坚信这使其与众不同。

不可变性(Immutability)

为了实现不可变性,attrs 将在你的类上附加一个 __setattr__ 方法,当任何人尝试设置属性时,将抛出 attrs.exceptions.FrozenInstanceError

如果你选择使用 attrs.setters.frozen on_setattr 钩子来冻结单个属性,则异常将变为 attrs.exceptions.FrozenAttributeError

这两个异常都继承自 attrs.exceptions.FrozenError


根据类是字典类还是插槽类,attrs 使用不同的技术来绕过 __init__ 方法中的限制。

一旦构造完成,冻结的实例与常规实例没有任何不同,除了你无法更改其属性。

字典类(Dict Classes)

字典类——即:常规类——直接将值分配到类的同名 __dict__ 中(我们无法阻止用户这样做)。

性能影响可以忽略不计。

插槽类(Slotted Classes)

插槽类则更复杂。 它使用(一个激进缓存的)object.__setattr__() 来设置你的属性。 这(仍然)比普通赋值慢:

$ pyperf timeit --rigorous \
      -s "import attr; C = attr.make_class('C', ['x', 'y', 'z'], slots=True)" \
      "C(1, 2, 3)"
.........................................
Mean +- std dev: 228 ns +- 18 ns

$ pyperf timeit --rigorous \
      -s "import attr; C = attr.make_class('C', ['x', 'y', 'z'], slots=True, frozen=True)" \
      "C(1, 2, 3)"
.........................................
Mean +- std dev: 425 ns +- 16 ns

因此,在一台笔记本电脑上,差异约为 200 纳秒(1 秒为 1,000,000,000 纳秒)。 在热循环中你肯定会感受到这个差异,但在正常代码中不应该有太大问题。 根据你的需求选择更重要的方面。

总结(Summary)

在性能关键代码中,你应避免实例化大量冻结的插槽类(即:@frozen)。

冻结的字典类几乎没有性能影响,未冻结的插槽类甚至比未冻结的字典类(即:常规类)还要快。

插槽类中被缓存的属性(Cached Properties on Slotted Classes)

默认情况下,标准库的 functools.cached_property() 装饰器无法在插槽类上使用,因为它需要一个 __dict__ 来存储缓存值。 这可能会让使用 attrs 的用户感到意外,因为插槽类是默认设置。 因此,attrs 在构造插槽类时会转换 cached_property 装饰的方法。

实现这一功能的方式包括:

  • 为被包装的方法添加名称到 __slots__ 中。

  • 添加一个 __getattr__ 方法以设置被包装方法的值。

对于大多数用户来说,这意味着它可以透明地工作。

备注

该实现并不保证在多线程使用中被包装的方法仅调用一次。这与 Python 3.12 中 cached_property 的实现相匹配。