File: how-does-it-work.md

package info (click to toggle)
python-attrs 25.3.0-1
  • links: PTS, VCS
  • area: main
  • in suites: forky, sid, trixie
  • size: 2,004 kB
  • sloc: python: 10,495; makefile: 153
file content (121 lines) | stat: -rw-r--r-- 5,480 bytes parent folder | download
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
(how)=

# How Does It Work?

## Boilerplate

*attrs* isn't the first library that aims to simplify class definition in Python.
But its **declarative** approach combined with **no runtime overhead** lets it stand out.

Once you apply the `@attrs.define` (or `@attr.s`) decorator to a class, *attrs* searches the class object for instances of `attr.ib`s.
Internally they're a representation of the data passed into `attr.ib` along with a counter to preserve the order of the attributes.
Alternatively, it's possible to define them using {doc}`types`.

In order to ensure that subclassing works as you'd expect it to work, *attrs* also walks the class hierarchy and collects the attributes of all base classes.
Please note that *attrs* does *not* call `super()` *ever*.
It will write {term}`dunder methods` to work on *all* of those attributes which also has performance benefits due to fewer function calls.

Once *attrs* knows what attributes it has to work on, it writes the requested {term}`dunder methods` and -- depending on whether you wish to have a {term}`dict <dict classes>` or {term}`slotted <slotted classes>` class -- creates a new class for you (`slots=True`) or attaches them to the original class (`slots=False`).
While creating new classes is more elegant, we've run into several edge cases surrounding metaclasses that make it impossible to go this route unconditionally.

To be very clear: if you define a class with a single attribute without a default value, the generated `__init__` will look *exactly* how you'd expect:

```{doctest}
>>> import inspect
>>> from attrs import define
>>> @define
... class C:
...     x: int
>>> print(inspect.getsource(C.__init__))
def __init__(self, x):
    self.x = x
<BLANKLINE>
```

No magic, no meta programming, no expensive introspection at runtime.

---

Everything until this point happens exactly *once* when the class is defined.
As soon as a class is done, it's done.
And it's just a regular Python class like any other, except for a single `__attrs_attrs__` attribute that *attrs* uses internally.
Much of the information is accessible via {func}`attrs.fields` and other functions which can be used for introspection or for writing your own tools and decorators on top of *attrs* (like {func}`attrs.asdict`).

And once you start instantiating your classes, *attrs* is out of your way completely.

This **static** approach was very much a design goal of *attrs* and what I strongly believe makes it distinct.

(how-frozen)=

## Immutability

In order to give you immutability, *attrs* will attach a `__setattr__` method to your class that raises an {class}`attrs.exceptions.FrozenInstanceError` whenever anyone tries to set an attribute.

The same is true if you choose to freeze individual attributes using the {obj}`attrs.setters.frozen` *on_setattr* hook -- except that the exception becomes {class}`attrs.exceptions.FrozenAttributeError`.

Both exceptions subclass {class}`attrs.exceptions.FrozenError`.

---

Depending on whether a class is a dict class or a slotted class, *attrs* uses a different technique to circumvent that limitation in the `__init__` method.

Once constructed, frozen instances don't differ in any way from regular ones except that you cannot change its attributes.


### Dict Classes

Dict classes -- that is: regular classes -- simply assign the value directly into the class' eponymous `__dict__` (and there's nothing we can do to stop the user to do the same).

The performance impact is negligible.


### Slotted Classes

Slotted classes are more complicated.
Here it uses (an aggressively cached) {meth}`object.__setattr__` to set your attributes.
This is (still) slower than a plain assignment:

```none
$ 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
```

So on a laptop computer the difference is about 200 nanoseconds (1 second is 1,000,000,000 nanoseconds).
It's certainly something you'll feel in a hot loop but shouldn't matter in normal code.
Pick what's more important to you.

### Summary

You should avoid instantiating lots of frozen slotted classes (meaning: `@frozen`) in performance-critical code.

Frozen dict classes have barely a performance impact, unfrozen slotted classes are even *faster* than unfrozen dict classes (meaning: regular classes).


(how-slotted-cached_property)=

## Cached Properties on Slotted Classes

By default, the standard library {func}`functools.cached_property` decorator does not work on slotted classes, because it requires a `__dict__` to store the cached value.
This could be surprising when using *attrs*, as slotted classes are the default.
Therefore, *attrs* converts `cached_property`-decorated methods when constructing slotted classes.

Getting this working is achieved by:

* Adding names to `__slots__` for the wrapped methods.
* Adding a `__getattr__` method to set values on the wrapped methods.

For most users, this should mean that it works transparently.

:::{note}
The implementation does not guarantee that the wrapped method is called only once in multi-threaded usage.
This matches the implementation of `cached_property` in Python 3.12.
:::