Descriptor Introduction¶
What Is a Descriptor?¶
1. Definition¶
A descriptor is any object that defines at least one of these three special methods:
__get__(self, instance, owner)- controls getting an attribute__set__(self, instance, value)- controls setting an attribute__delete__(self, instance)- controls deleting an attribute
2. Key Concept¶
Descriptors are objects that live on classes but manage attribute access for instances.
class MyDescriptor:
def __get__(self, instance, owner):
return "descriptor value"
class MyClass:
attr = MyDescriptor() # Descriptor lives here (class level)
obj = MyClass()
print(obj.attr) # "descriptor value" (accessed on instance)
3. You've Used Them¶
You've already used descriptors, even if you didn't know:
@property- is a descriptor- Methods - become bound methods via descriptors
classmethodandstaticmethod- are descriptors- ORM fields (Django, SQLAlchemy) - use descriptors
How Python Uses Descriptors¶
1. Attribute Access Protocol¶
When you access obj.attr, Python:
- Checks if
attris a data descriptor in the class - Checks
obj.__dict__for instance attribute - Checks if
attris a non-data descriptor in the class - Checks class attributes
- Calls
__getattr__if defined - Raises
AttributeError
2. Descriptor Lives on Class¶
class Descriptor:
def __get__(self, instance, owner):
return "value"
class MyClass:
attr = Descriptor() # Lives in class namespace
# Accessing from class
print(MyClass.attr) # Descriptor.__get__(None, MyClass)
# Accessing from instance
obj = MyClass()
print(obj.attr) # Descriptor.__get__(obj, MyClass)
3. Manages Instance Access¶
class Descriptor:
def __get__(self, instance, owner):
if instance is None:
return self # Accessed from class
return f"Value for {instance}" # Accessed from instance
class MyClass:
attr = Descriptor()
print(MyClass.attr) # <Descriptor object>
obj = MyClass()
print(obj.attr) # "Value for <MyClass object>"
Simple Example¶
1. Basic Descriptor¶
class SimpleDescriptor:
def __init__(self, name):
self.name = name
def __get__(self, instance, owner):
if instance is None:
return self
print(f"Getting {self.name}")
return instance.__dict__.get(self.name, None)
def __set__(self, instance, value):
print(f"Setting {self.name} = {value}")
instance.__dict__[self.name] = value
class MyClass:
x = SimpleDescriptor('x')
y = SimpleDescriptor('y')
obj = MyClass()
obj.x = 10 # Setting x = 10
print(obj.x) # Getting x → 10
obj.y = 20 # Setting y = 20
print(obj.y) # Getting y → 20
2. Why Use This?¶
Instead of:
class MyClass:
def get_x(self):
return self._x
def set_x(self, value):
self._x = value
You can:
class MyClass:
x = ManagedAttribute('x')
3. Benefits¶
- ✅ Cleaner syntax
- ✅ Reusable attribute logic
- ✅ Attribute-style access with method-level control
- ✅ DRY (Don't Repeat Yourself)
Where Descriptors Shine¶
1. Validation¶
class TypedDescriptor:
def __init__(self, name, expected_type):
self.name = name
self.expected_type = expected_type
def __set__(self, instance, value):
if not isinstance(value, self.expected_type):
raise TypeError(
f"{self.name} must be {self.expected_type.__name__}"
)
instance.__dict__[self.name] = value
def __get__(self, instance, owner):
if instance is None:
return self
return instance.__dict__.get(self.name)
class Person:
name = TypedDescriptor('name', str)
age = TypedDescriptor('age', int)
p = Person()
p.name = "Alice" # ✅ OK
p.age = 30 # ✅ OK
# p.age = "30" # ❌ TypeError
2. Lazy Loading¶
class LazyProperty:
def __init__(self, func):
self.func = func
def __get__(self, instance, owner):
if instance is None:
return self
value = self.func(instance)
# Cache by replacing descriptor with value
setattr(instance, self.func.__name__, value)
return value
class DataLoader:
@LazyProperty
def data(self):
print("Loading data...")
return [1, 2, 3, 4, 5]
obj = DataLoader()
print(obj.data) # Loading data... [1, 2, 3, 4, 5]
print(obj.data) # [1, 2, 3, 4, 5] (no loading)
3. Computed Properties¶
class Quantity:
def __init__(self, name):
self.name = name
def __get__(self, instance, owner):
if instance is None:
return self
return instance.__dict__.get(self.name, 0)
def __set__(self, instance, value):
if value < 0:
raise ValueError("Quantity cannot be negative")
instance.__dict__[self.name] = value
class Product:
price = Quantity('price')
quantity = Quantity('quantity')
@property
def total(self):
return self.price * self.quantity
prod = Product()
prod.price = 10
prod.quantity = 5
print(prod.total) # 50
Descriptor vs Property¶
1. Property Is a Descriptor¶
# Using property
class Example:
@property
def x(self):
return self._x
@x.setter
def x(self, value):
self._x = value
# Property is actually a descriptor!
print(type(Example.__dict__['x'])) # <class 'property'>
2. When to Use Each¶
Use @property when:
- One-off attribute with custom logic
- Simple getter/setter/deleter
- Specific to one class
Use descriptor when: - Reusable across multiple classes - Complex attribute management - Need to share logic
3. Comparison¶
# Property - specific to one attribute
class Circle:
@property
def area(self):
return 3.14 * self.radius ** 2
# Descriptor - reusable pattern
class PositiveNumber:
def __init__(self, name):
self.name = name
def __set__(self, instance, value):
if value <= 0:
raise ValueError("Must be positive")
instance.__dict__[self.name] = value
def __get__(self, instance, owner):
if instance is None:
return self
return instance.__dict__.get(self.name, 1)
class Rectangle:
width = PositiveNumber('width')
height = PositiveNumber('height')
class Circle:
radius = PositiveNumber('radius')
Common Use Cases¶
1. Type Enforcement¶
class Integer:
def __init__(self, name):
self.name = name
def __set__(self, instance, value):
if not isinstance(value, int):
raise TypeError(f"{self.name} must be int")
instance.__dict__[self.name] = value
2. Value Validation¶
class Bounded:
def __init__(self, name, min_val, max_val):
self.name = name
self.min_val = min_val
self.max_val = max_val
def __set__(self, instance, value):
if not self.min_val <= value <= self.max_val:
raise ValueError(f"{self.name} must be in [{self.min_val}, {self.max_val}]")
instance.__dict__[self.name] = value
3. Read-Only Attributes¶
class ReadOnly:
def __init__(self, name, value):
self.name = name
self.value = value
def __get__(self, instance, owner):
return self.value
def __set__(self, instance, value):
raise AttributeError("Read-only attribute")
class Config:
MAX_CONNECTIONS = ReadOnly('MAX_CONNECTIONS', 100)
4. Logging Access¶
class LoggedAccess:
def __init__(self, name):
self.name = name
def __get__(self, instance, owner):
if instance is None:
return self
value = instance.__dict__.get(self.name)
print(f"[GET] {self.name} = {value}")
return value
def __set__(self, instance, value):
print(f"[SET] {self.name} = {value}")
instance.__dict__[self.name] = value
Why Descriptors Matter¶
1. Framework Building¶
ORMs use descriptors extensively:
# Django-style models
class User(Model):
name = CharField(max_length=100)
age = IntegerField()
email = EmailField()
Each field is a descriptor that handles database storage/retrieval.
2. Attribute Management¶
Descriptors centralize attribute logic:
class ManagedAttribute:
"""Handles validation, logging, caching"""
def __init__(self, validator, logger):
self.validator = validator
self.logger = logger
3. Code Reuse¶
Write once, use everywhere:
# Define once
class NonNegative:
def __set__(self, instance, value):
if value < 0:
raise ValueError("Must be non-negative")
instance.__dict__[self.name] = value
# Use everywhere
class BankAccount:
balance = NonNegative()
class ShoppingCart:
total = NonNegative()
class Inventory:
quantity = NonNegative()