Descriptors
Descriptors are objects that implement one or more of __get__, __set__, or __delete__, and serve as Python's core mechanism for intercepting attribute access when used as class attributes. @property, classmethod, and staticmethod are all descriptors.
The Descriptor Protocol
class Descriptor:
"""The three methods of the descriptor protocol"""
def __get__(self, obj, objtype=None):
"""Attribute read: called on obj.attr or Class.attr"""
if obj is None:
# Direct access from the class (Class.attr)
return self
return getattr(obj, "_value", None)
def __set__(self, obj, value):
"""Attribute write: obj.attr = value"""
obj._value = value
def __delete__(self, obj):
"""Attribute delete: del obj.attr"""
try:
del obj._value
except AttributeError:
raise AttributeError("Cannot delete attribute.")
class MyClass:
attr = Descriptor() # Registered as a class attribute
obj = MyClass()
obj.attr = 42 # Calls __set__
print(obj.attr) # Calls __get__ → 42
print(MyClass.attr) # __get__ with obj=None → returns Descriptor instance
del obj.attr # Calls __delete__
Data Descriptors vs Non-Data Descriptors
# Data descriptor: implements __set__ or __delete__ → takes priority over __dict__
class DataDescriptor:
def __get__(self, obj, objtype=None):
if obj is None:
return self
return obj.__dict__.get(f"__{id(self)}__")
def __set__(self, obj, value):
obj.__dict__[f"__{id(self)}__"] = value
# Non-data descriptor: implements only __get__ → loses to __dict__
class NonDataDescriptor:
def __get__(self, obj, objtype=None):
if obj is None:
return self
return "non-data descriptor value"
class Demo:
data = DataDescriptor()
non_data = NonDataDescriptor()
d = Demo()
d.data = "data descriptor"
d.__dict__["data"] = "instance __dict__" # data descriptor wins → ignored
d.__dict__["non_data"] = "instance __dict__" # __dict__ wins over non-data descriptor
print(d.data) # data descriptor (descriptor takes priority)
print(d.non_data) # instance __dict__ (__dict__ takes priority)
Type-Validating Descriptor
class TypedAttribute:
"""Data descriptor that performs type validation"""
def __init__(self, name: str, expected_type: type):
self.name = name
self.expected_type = expected_type
self.private_name = f"_{name}"
def __get__(self, obj, objtype=None):
if obj is None:
return self
return getattr(obj, self.private_name, None)
def __set__(self, obj, value):
if not isinstance(value, self.expected_type):
raise TypeError(
f"{self.name}: expected {self.expected_type.__name__}, "
f"got {type(value).__name__} ({value!r})"
)
setattr(obj, self.private_name, value)
def __delete__(self, obj):
try:
delattr(obj, self.private_name)
except AttributeError:
pass
class RangedFloat:
"""Range-validating descriptor"""
def __init__(self, name: str, min_val: float, max_val: float):
self.name = name
self.min_val = min_val
self.max_val = max_val
self.private_name = f"_{name}"
def __get__(self, obj, objtype=None):
if obj is None:
return self
return getattr(obj, self.private_name, None)
def __set__(self, obj, value):
if not isinstance(value, (int, float)):
raise TypeError(f"{self.name} must be a number.")
if not (self.min_val <= value <= self.max_val):
raise ValueError(
f"{self.name} must be in range {self.min_val}~{self.max_val}: {value}"
)
setattr(obj, self.private_name, float(value))
def __delete__(self, obj):
setattr(obj, self.private_name, None)
class Person:
name = TypedAttribute("name", str)
age = TypedAttribute("age", int)
height = RangedFloat("height", 0.0, 300.0)
weight = RangedFloat("weight", 0.0, 500.0)
def __init__(self, name: str, age: int, height: float, weight: float):
self.name = name
self.age = age
self.height = height
self.weight = weight
def __repr__(self) -> str:
return f"Person({self.name!r}, {self.age}, {self.height}cm, {self.weight}kg)"
@property
def bmi(self) -> float:
h_m = self.height / 100
return self.weight / (h_m ** 2) if h_m > 0 else 0
p = Person("Alice", 30, 165.0, 60.0)
print(p)
print(f"BMI: {p.bmi:.1f}")
# Type error
try:
p.age = "thirty"
except TypeError as e:
print(f"Type error: {e}")
# Range error
try:
p.height = 400.0
except ValueError as e:
print(f"Range error: {e}")
Using __set_name__ (Python 3.6+)
class Validated:
"""Descriptor that automatically obtains the attribute name via __set_name__"""
def __set_name__(self, owner, name: str):
"""Called automatically at class definition — gives access to attribute name"""
self.name = name
self.private_name = f"_{name}"
def __get__(self, obj, objtype=None):
if obj is None:
return self
return getattr(obj, self.private_name, None)
def __set__(self, obj, value):
value = self.validate(value)
setattr(obj, self.private_name, value)
def validate(self, value):
return value # Override in subclass
class PositiveInt(Validated):
def validate(self, value) -> int:
if not isinstance(value, int):
raise TypeError(f"{self.name}: expected int, got {type(value).__name__}")
if value <= 0:
raise ValueError(f"{self.name}: expected positive, got {value}")
return value
class NonEmptyStr(Validated):
def validate(self, value) -> str:
if not isinstance(value, str):
raise TypeError(f"{self.name}: expected str, got {type(value).__name__}")
stripped = value.strip()
if not stripped:
raise ValueError(f"{self.name}: empty string is not allowed.")
return stripped
class Product:
name = NonEmptyStr() # __set_name__ automatically passes "name"
price = PositiveInt() # __set_name__ automatically passes "price"
stock = PositiveInt() # __set_name__ automatically passes "stock"
def __init__(self, name: str, price: int, stock: int):
self.name = name
self.price = price
self.stock = stock
def __repr__(self) -> str:
return f"Product({self.name!r}, ${self.price}, {self.stock} in stock)"
p = Product("Python Book", 35, 100)
print(p)
try:
Product("", 35, 100)
except ValueError as e:
print(f"Error: {e}")
try:
Product("Book", -100, 50)
except ValueError as e:
print(f"Error: {e}")
Caching Descriptor
import functools
class cached_property:
"""Non-data descriptor that caches expensive-to-compute properties"""
def __init__(self, func):
self.func = func
self.attrname = None
self.__doc__ = func.__doc__
def __set_name__(self, owner, name: str):
self.attrname = name
def __get__(self, obj, objtype=None):
if obj is None:
return self
# Non-data descriptor: __dict__ takes priority → computed only once
val = self.func(obj)
obj.__dict__[self.attrname] = val # Store directly in instance __dict__
return val
class Circle:
def __init__(self, radius: float):
self.radius = radius
@cached_property
def area(self) -> float:
"""Assume this is computationally expensive"""
import math
print(" [Computing area...]")
return math.pi * self.radius ** 2
@cached_property
def perimeter(self) -> float:
print(" [Computing perimeter...]")
import math
return 2 * math.pi * self.radius
c = Circle(5.0)
print("First access:")
print(f" Area: {c.area:.2f}") # Computation runs
print("Second access:")
print(f" Area: {c.area:.2f}") # Returned from cache
print(f" Perimeter: {c.perimeter:.2f}") # Computation runs
print(f" Perimeter: {c.perimeter:.2f}") # Returned from cache
# functools.cached_property from the standard library works on the same principle
from functools import cached_property as std_cached_property
class Polygon:
def __init__(self, vertices: list[tuple[float, float]]):
self.vertices = vertices
@std_cached_property
def perimeter(self) -> float:
import math
total = 0.0
n = len(self.vertices)
for i in range(n):
x1, y1 = self.vertices[i]
x2, y2 = self.vertices[(i + 1) % n]
total += math.sqrt((x2 - x1) ** 2 + (y2 - y1) ** 2)
return total
Understanding Descriptor Internals
# Attribute lookup order (MRO + descriptor priority)
# 1. Data descriptors (have __set__ or __delete__ in type(obj).__mro__)
# 2. Instance __dict__
# 3. Non-data descriptors (have only __get__ in type(obj).__mro__)
class Spy:
"""Descriptor that traces the attribute access flow"""
def __set_name__(self, owner, name: str):
self.name = name
def __get__(self, obj, objtype=None):
print(f" __get__({obj!r}, {objtype.__name__ if objtype else None})")
if obj is None:
return self
return obj.__dict__.get(f"_{self.name}")
def __set__(self, obj, value):
print(f" __set__({obj!r}, {value!r})")
obj.__dict__[f"_{self.name}"] = value
def __delete__(self, obj):
print(f" __delete__({obj!r})")
obj.__dict__.pop(f"_{self.name}", None)
class Container:
value = Spy()
def __repr__(self) -> str:
return f"Container()"
print("=== Write ===")
c = Container()
c.value = 100
print("\n=== Read ===")
print(c.value)
print("\n=== Read from class ===")
print(Container.value) # obj=None → returns Spy instance
print("\n=== Delete ===")
del c.value
Pro Tips
1. Descriptor vs Property
# @property: independent per class, not reusable
class Circle:
def __init__(self, radius: float):
self._radius = radius
@property
def radius(self) -> float:
return self._radius
@radius.setter
def radius(self, value: float) -> None:
if value <= 0:
raise ValueError("Radius must be positive.")
self._radius = value
# Descriptor: reusable across multiple classes
class PositiveFloat:
def __set_name__(self, owner, name: str):
self.name = name
def __get__(self, obj, objtype=None):
if obj is None:
return self
return obj.__dict__.get(f"_{self.name}", 0.0)
def __set__(self, obj, value: float) -> None:
if not isinstance(value, (int, float)) or value <= 0:
raise ValueError(f"{self.name} must be positive: {value}")
obj.__dict__[f"_{self.name}"] = float(value)
class Rectangle:
width = PositiveFloat() # Reused
height = PositiveFloat() # Reused
def __init__(self, width: float, height: float):
self.width = width
self.height = height
class Cylinder:
radius = PositiveFloat() # Same descriptor reused
height = PositiveFloat()
def __init__(self, radius: float, height: float):
self.radius = radius
self.height = height
2. __slots__ and Descriptors
class SlottedDescriptor:
"""Descriptor for use with classes that have __slots__"""
def __set_name__(self, owner, name: str):
self.name = name
# Automatically add a storage slot to __slots__
slot_name = f"_{name}_slot"
if not hasattr(owner, "__slots__"):
raise TypeError("Only use with classes that have __slots__.")
def __get__(self, obj, objtype=None):
if obj is None:
return self
try:
return object.__getattribute__(obj, f"_{self.name}_slot")
except AttributeError:
return None
def __set__(self, obj, value):
object.__setattr__(obj, f"_{self.name}_slot", value)
Summary
| Method | When Called | Return Value |
|---|---|---|
__get__(self, obj, type) | Reading obj.attr | Attribute value (returns descriptor itself if obj=None) |
__set__(self, obj, value) | obj.attr = value | None |
__delete__(self, obj) | del obj.attr | None |
__set_name__(self, owner, name) | At class definition | None |
Priority Rules:
- Data descriptors (have
__set__or__delete__) - Instance
__dict__ - Non-data descriptors (have only
__get__)
@property is a data descriptor, so it always takes priority over instance __dict__.