Replace cached_property on slotted classes with a new descriptor instead of writing `__getattr__`
Summary
This PR reimplements cached_property in slotted classes as a slot wrapping descriptor replacing the current generated __getattr__ implementation.
Accessing the attributes will probably be slightly slower due to going through this descriptor but if the performance is reasonable this will close #1288 #1325 and #1333.
This idea came up again as part of discussion on https://discuss.python.org/t/extensible-cached-property/105188/25. I hadn't realised it would work, but it looks like there was a partial attempt to implement it for attrs before in #1357
I've implemented it for my own attrs/dataclasses-like here (the _SlottedCachedProperty code is mostly identical).
As I saw there were outstanding issues for it and the code is fairly similar I thought you might also find it useful. (Passing all of your existing tests also gave me some confidence that it does work correctly).
Changes:
- Adds a
_SlottedCachedPropertydescriptor class that replaces the slot descriptors after attrs has rebuilt the class.- This is very similar to
cached_propertyitself, other than reading/writing/deleting a wrapped slot rather than dict entry.
- This is very similar to
- Removes the customised
__getattr__function you were making previously.- There are probably some redundant tests that were checking the
__getattr__behaviour that are now just checking__getattr__works in Python.
- There are probably some redundant tests that were checking the
Outstanding issues:
This was already the case for the __getattr__ implementation but I'm now aware of one difference in behaviour this hasn't yet resolved as it would require changing the logic of when you create a new cached property. There's currently a test marked as xfail for this.
import attrs
import functools
for slotted in [False, True]:
@attrs.define(slots=slotted)
class Parent:
name: str = "Alice"
@attrs.define(slots=slotted)
class Child(Parent):
@functools.cached_property
def name(self):
return "Bob"
# This isn't to imply that this is good
# just that it's consistent
p = Parent()
c = Child()
assert p.name == "Alice"
assert c.name == "Alice"
del c.name
assert c.name == "Bob" # True without slots, errors under slots.
This is because the cached_property replacement check attrs does skips field names that already exist, so name is skipped in the check and doesn't get replaced. The descriptor would behave like this, but it's never placed.
Pull Request Check List
- [x] Do not open pull requests from your
mainbranch – use a separate branch!- There's a ton of footguns waiting if you don't heed this warning. You can still go back to your project, create a branch from your main branch, push it, and open the pull request from the new branch.
- This is not a pre-requisite for your pull request to be accepted, but you have been warned.
- [x] Added tests for changed code. Our CI fails if coverage is not 100%.
- [x] Changes or additions to public APIs are reflected in our type stubs (files ending in
.pyi).- [x] ...and used in the stub test file
typing-examples/baseline.pyor, if necessary,typing-examples/mypy.py. - [x] If they've been added to
attr/__init__.pyi, they've also been re-imported inattrs/__init__.pyi.
- [x] ...and used in the stub test file
- [x] Updated documentation for changed code.
- [x] New functions/classes have to be added to
docs/api.rstby hand. - [x] Changes to the signatures of
@attr.s()and@attrs.define()have to be added by hand too. - [ ] Changed/added classes/methods/functions have appropriate
versionadded,versionchanged, ordeprecateddirectives. The next version is the second number in the current release + 1. The first number represents the current year. So if the current version on PyPI is 22.2.0, the next version is gonna be 22.3.0. If the next version is the first in the new year, it'll be 23.1.0.- [ ] If something changed that affects both
attrs.define()andattr.s(), you have to add version directives to both.
- [ ] If something changed that affects both
- [x] New functions/classes have to be added to
- [ ] Documentation in
.rstand.mdfiles is written using semantic newlines. - [ ] Changes (and possible deprecations) have news fragments in
changelog.d. - [ ] Consider granting push permissions to the PR branch, so maintainers can fix minor issues themselves without pestering you.
CodSpeed Performance Report
Merging #1488 will degrade performances by 71.99%
Comparing DavidCEllis:descriptor-cached-property (cc06080) with main (87f0587)
Summary
⚡ 1 improvement
❌ 1 regression
✅ 13 untouched
:warning: Please fix the performance issues or acknowledge them on CodSpeed.
Benchmarks breakdown
| Benchmark | BASE |
HEAD |
Change | |
|---|---|---|---|---|
| ❌ | test_repeated_access |
254.5 µs | 908.5 µs | -71.99% |
| ⚡ | test_create_cached_property_class |
1.8 s | 1.1 s | +53.77% |
Oof, that's worse than I thought. I have one idea that should make it slightly better, but probably still significantly worse than the direct access it has on main.
Ah, it would have needed to be on main to see the difference. I haven't used codspeed before, sorry for the noise there. Locally it seemed to be significantly faster to construct classes at least. Probably less important than attribute access, but worth noting it's not uniformly worse performance-wise.
would you like me to transplant the benchmark to main? i'm a bit busy (just came back from vacation with the obvious results), but i'm open to mechanical work ;)
Sure, that would be great thanks. No rush if you're already swamped.
I tried a few other things to see if they affected performance but it seems it's entirely linked with wrapping the slot in another descriptor.
The only other thing I've tried so far that improved performance at all was replacing the new descriptor with a regular property that handles the caching. I don't like that as much as it makes recovering the slot/function more awkward (as you can't add attributes to a property) and it was only a little faster than the custom descriptor.
done!