🚀AdvancedMetaprogramming
Descriptors
The descriptor protocol: __get__, __set__, __delete__ how property, classmethod, and staticmethod are implemented under the hood.
Overview
A **descriptor** is any object that defines at least one of `__get__`, `__set__`, or `__delete__`. When a descriptor is stored as a **class attribute**, Python routes attribute access through it instead of the default `__dict__` lookup. This is the mechanism behind `property`, `classmethod`, `staticmethod`, `functools.cached_property`, and ORM fields.
**Data descriptor**: defines `__set__` (and/or `__delete__`) has higher priority than instance `__dict__`. Used for validation, type-coercion.
**Non-data descriptor**: defines only `__get__` instance `__dict__` can shadow it. Functions are non-data descriptors (that's why methods work).
Descriptors receive the *owner class* and the *instance* (or `None` for class access) so they can behave differently depending on context. They're the most powerful low-level customisation point in Python.
Key Points
- •`__get__(self, obj, objtype=None)` called on attribute read; `obj` is None when accessed on the class
- •`__set__(self, obj, value)` called on attribute write; making this a data descriptor
- •`__delete__(self, obj)` called on `del obj.attr`
- •Data descriptors (`__set__` defined) override instance `__dict__`; non-data descriptors do not
- •Descriptors must be stored as **class attributes** instance attributes don't trigger the protocol
- •`property` is a built-in data descriptor; `classmethod`/`staticmethod` are non-data descriptors
- •`__set_name__(self, owner, name)` (Python 3.6+) called when the descriptor is assigned to a class; provides the attribute name
Examples
Typed descriptor with validation
class TypedAttr:
def __set_name__(self, owner, name):
self.name = name
self.storage = f"_{name}"
def __init__(self, expected_type):
self.expected_type = expected_type
def __get__(self, obj, objtype=None):
if obj is None:
return self
return getattr(obj, self.storage, None)
def __set__(self, obj, value):
if not isinstance(value, self.expected_type):
raise TypeError(f"{self.name} must be {self.expected_type.__name__}")
setattr(obj, self.storage, value)
class Person:
name = TypedAttr(str)
age = TypedAttr(int)
def __init__(self, name, age):
self.name = name
self.age = age
p = Person("Alice", 30)
print(p.name, p.age) # Alice 30
try:
p.age = "old"
except TypeError as e:
print(e) # age must be intImplementing property from scratch
class MyProperty:
def __init__(self, fget=None, fset=None):
self.fget = fget
self.fset = fset
def __get__(self, obj, objtype=None):
if obj is None:
return self
if self.fget is None:
raise AttributeError("unreadable attribute")
return self.fget(obj)
def __set__(self, obj, value):
if self.fset is None:
raise AttributeError("can't set attribute")
self.fset(obj, value)
def setter(self, fset):
return type(self)(self.fget, fset)
class Circle:
def __init__(self, radius):
self._radius = radius
@MyProperty
def radius(self):
return self._radius
@radius.setter
def radius(self, value):
if value < 0:
raise ValueError("Radius must be non-negative")
self._radius = value
c = Circle(5)
print(c.radius) # 5
c.radius = 10
print(c.radius) # 10cached_property compute once, store in instance dict
import time
class CachedProperty:
"""Non-data descriptor instance __dict__ shadows it after first access."""
def __init__(self, func):
self.func = func
self.attrname = None
def __set_name__(self, owner, name):
self.attrname = name
def __get__(self, obj, objtype=None):
if obj is None:
return self
if self.attrname not in obj.__dict__:
obj.__dict__[self.attrname] = self.func(obj)
return obj.__dict__[self.attrname]
class DataSet:
def __init__(self, data):
self.data = data
@CachedProperty
def average(self):
print("computing...")
return sum(self.data) / len(self.data)
ds = DataSet([1, 2, 3, 4, 5])
print(ds.average) # computing... 3.0
print(ds.average) # 3.0 (no recompute)Common Mistakes
- Storing the descriptor as an instance attribute it won't trigger `__get__`/`__set__`; must be a class attribute
- Storing per-instance data on the descriptor itself (`self.value = ...`) all instances share the same descriptor object, so they'd all get the same value; use `obj.__dict__` or `setattr(obj, self.storage, ...)`
- Forgetting `__set_name__` without it, the descriptor doesn't know its own attribute name
Note
Use `functools.cached_property` from the standard library (Python 3.8+) instead of rolling your own. It's the same non-data descriptor pattern but battle-tested and thread-aware.
Exercises
Write Python code to solve each exercise. Run it in your browser and check your output.
Loading editor…
Completed: 0 / 5 exercises
Knowledge Check
Question 1 of 4