@property, @staticmethod, @classmethod -- and the descriptor protocol underneath
All three are descriptors: objects with __get__/__set__ that live on the class, and intercept
attribute access on instances. property is just a built-in descriptor class -- writing your own descriptor
from scratch shows exactly what @property does for you, and explains why descriptors are looked up on the
class (type(obj).__dict__), not the instance.
class LoggedAttr:
"""A minimal hand-written descriptor, doing by hand what @property automates."""
def __set_name__(self, owner, name):
self.name = "_" + name # called automatically when the class body executes
def __get__(self, obj, objtype=None):
if obj is None:
return self
print(f" (getting {self.name})")
return getattr(obj, self.name, None)
def __set__(self, obj, value):
print(f" (setting {self.name} = {value})")
setattr(obj, self.name, value)
class Widget:
color = LoggedAttr() # descriptor instance, lives on the CLASS
@property
def size(self):
return getattr(self, "_size", 0)
@size.setter
def size(self, v):
self._size = v
w = Widget()
w.color = "red" # triggers LoggedAttr.__set__
print(w.color) # triggers LoggedAttr.__get__
w.size = 42 # triggers the property's setter
print(w.size)
print("property is a class attribute, an instance of the built-in descriptor type:", type(Widget.__dict__["size"]))
staticmethod and classmethod are descriptors too -- their __get__ controls what gets bound to the
call. A staticmethod binds nothing (no implicit first argument); a classmethod binds the class, not an
instance, which is exactly what makes alternative-constructor patterns like dict.fromkeys possible.
class Temperature:
def __init__(self, celsius):
self.celsius = celsius
@classmethod
def from_fahrenheit(cls, f):
# `cls` is bound to the class -- works correctly for subclasses too, unlike a hardcoded Temperature(...)
return cls((f - 32) * 5 / 9)
@staticmethod
def describe():
# no implicit first argument at all -- just a plain function namespaced under the class
return "Temperature is stored in Celsius internally"
class Kelvin(Temperature):
@classmethod
def from_fahrenheit(cls, f):
base = super().from_fahrenheit(f)
base.celsius += 273.15
return base
t = Temperature.from_fahrenheit(98.6)
print(f"{t.celsius:.1f}C")
print(Temperature.describe())
k = Kelvin.from_fahrenheit(98.6) # `cls` inside from_fahrenheit is Kelvin here, not Temperature
print(type(k).__name__, f"{k.celsius:.1f}")