When you evaluate obj.name in CPython, the interpreter does not simply look in one place. It follows a strict hierarchical chain:
- Data Descriptors: Does the class (or its superclasses) have an attribute called
namethat implements__get__and__set__? (This is what@propertycreates!). If so, use it. - Instance Dictionary: Does
obj.__dict__['name']exist? If so, return it. - Non-Data Descriptors: Does the class have an attribute called
namethat only implements__get__? (Like standard methods or@cached_property). If so, use it. - Class Dictionary: Does
type(obj).__dict__['name']exist? If so, return it. __getattr__: If all the above fail, and the class defines__getattr__, call it.- AttributeError: If
__getattr__doesn't exist or raisesAttributeError, crash.
Because Data Descriptors (Step 1) are checked before the Instance Dictionary (Step 2), a @property completely overrides any actual instance variable of the same name.
class Test:
@property
def data(self):
return "Protected"
t = Test()
t.__dict__["data"] = "Hacked!"
print(t.data) # Still prints "Protected"!Even if you manually inject "data" into the instance dictionary, the @property catches the lookup at Step 1 and intercepts it.
Introduced in Python 3.8, @cached_property is a Non-Data Descriptor. It only implements __get__.
Because it does not implement __set__, it falls to Step 3 in the lookup chain.
When obj.data is accessed the first time:
- Step 1 fails.
- Step 2 fails (it's not in
__dict__yet). - Step 3 hits the
cached_property. It runs the calculation. It saves the answer viaobj.__dict__['data'] = answerand returns it.
When obj.data is accessed the second time:
- Step 1 fails.
- Step 2 SUCCEEDS. It finds the cached answer directly in the
__dict__and returns it in O(1) time. Thecached_propertymethod is never called again!