Properties as Descriptors¶
The Connection¶
1. Property IS a Descriptor¶
The @property decorator creates a descriptor object:
class Example:
@property
def value(self):
return self._value
# Property is a descriptor!
print(type(Example.value)) # <class 'property'>
print(hasattr(Example.value, '__get__')) # True
print(hasattr(Example.value, '__set__')) # True (if setter defined)
2. How Property Works¶
# When you write:
@property
def value(self):
return self._value
# Python creates:
value = property(fget=value_getter_function)
# Which is a descriptor with __get__, __set__, __delete__
3. Accessing Property¶
obj.value
↓
type(obj).__dict__['value'].__get__(obj, type(obj))
↓
Calls the getter function
Property Implementation¶
1. Simplified Property¶
class Property:
"""Simplified property implementation"""
def __init__(self, fget=None, fset=None, fdel=None):
self.fget = fget
self.fset = fset
self.fdel = fdel
def __get__(self, instance, owner):
if instance is None:
return self
if self.fget is None:
raise AttributeError("unreadable attribute")
return self.fget(instance)
def __set__(self, instance, value):
if self.fset is None:
raise AttributeError("can't set attribute")
self.fset(instance, value)
def __delete__(self, instance):
if self.fdel is None:
raise AttributeError("can't delete attribute")
self.fdel(instance)
def getter(self, fget):
return type(self)(fget, self.fset, self.fdel)
def setter(self, fset):
return type(self)(self.fget, fset, self.fdel)
def deleter(self, fdel):
return type(self)(self.fget, self.fset, fdel)
class Circle:
def __init__(self, radius):
self._radius = radius
@Property
def radius(self):
return self._radius
@radius.setter
def radius(self, value):
if value < 0:
raise ValueError("Radius must be positive")
self._radius = value
c = Circle(5)
print(c.radius) # 5
c.radius = 10
print(c.radius) # 10
2. Property Data Descriptor¶
class Example:
@property
def value(self):
return self._value
@value.setter
def value(self, val):
self._value = val
# Has both __get__ and __set__ → data descriptor
desc = type(Example.value)
print(hasattr(Example.value, '__get__')) # True
print(hasattr(Example.value, '__set__')) # True
# Data descriptor wins over instance dict
obj = Example()
obj.__dict__['value'] = 999
obj.value = 42
print(obj.value) # 42 (not 999)
3. Read-Only Property¶
class Example:
@property
def value(self):
return self._value
# Only has __get__ → non-data descriptor
print(hasattr(Example.value, '__get__')) # True
print(hasattr(Example.value, '__set__')) # False
# Can be overridden by instance dict
obj = Example()
obj._value = 42
obj.__dict__['value'] = 999
print(obj.value) # 999 (instance dict wins)
Custom Property-Like Descriptors¶
1. Validated Property¶
class ValidatedProperty:
def __init__(self, validator):
self.validator = validator
self.name = None
def __set_name__(self, owner, name):
self.name = f'_{name}'
def __get__(self, instance, owner):
if instance is None:
return self
return getattr(instance, self.name, None)
def __set__(self, instance, value):
if not self.validator(value):
raise ValueError(f"Validation failed for {self.name}")
setattr(instance, self.name, value)
class Person:
age = ValidatedProperty(lambda x: 0 <= x <= 150)
name = ValidatedProperty(lambda x: len(x) > 0)
p = Person()
p.age = 30 # ✅ OK
# p.age = 200 # ❌ ValueError
2. Computed Property with Caching¶
class CachedProperty:
def __init__(self, func):
self.func = func
self.name = func.__name__
def __get__(self, instance, owner):
if instance is None:
return self
# Check cache in instance dict
value = instance.__dict__.get(self.name)
if value is None:
# Compute and cache
value = self.func(instance)
instance.__dict__[self.name] = value
return value
def __set__(self, instance, value):
# Allow manual override
instance.__dict__[self.name] = value
def __delete__(self, instance):
# Clear cache
instance.__dict__.pop(self.name, None)
class DataLoader:
@CachedProperty
def data(self):
print("Loading data...")
return [1, 2, 3, 4, 5]
loader = DataLoader()
print(loader.data) # Loading data... [1, 2, 3, 4, 5]
print(loader.data) # [1, 2, 3, 4, 5] (cached)
del loader.data
print(loader.data) # Loading data... [1, 2, 3, 4, 5]
3. Type-Enforced Property¶
class TypedProperty:
def __init__(self, expected_type):
self.expected_type = expected_type
self.name = None
def __set_name__(self, owner, name):
self.name = f'_{name}'
def __get__(self, instance, owner):
if instance is None:
return self
return getattr(instance, self.name, None)
def __set__(self, instance, value):
if not isinstance(value, self.expected_type):
raise TypeError(
f"{self.name[1:]} must be {self.expected_type.__name__}"
)
setattr(instance, self.name, value)
class Product:
name = TypedProperty(str)
price = TypedProperty((int, float))
quantity = TypedProperty(int)
p = Product()
p.name = "Widget" # ✅ OK
p.price = 19.99 # ✅ OK
# p.name = 123 # ❌ TypeError
Property vs Custom Descriptor¶
1. When to Use Property¶
Use @property when:
- Simple getter/setter/deleter logic
- One-off attribute management
- Quick prototyping
- Standard property behavior is sufficient
class Circle:
@property
def area(self):
return 3.14 * self.radius ** 2
2. When to Use Custom Descriptor¶
Use custom descriptor when: - Reusable validation logic - Complex attribute behavior - Need to share logic across multiple classes - Building frameworks or libraries
class PositiveNumber:
def __set__(self, instance, value):
if value <= 0:
raise ValueError("Must be positive")
instance.__dict__[self.name] = value
class Rectangle:
width = PositiveNumber()
height = PositiveNumber()
class Circle:
radius = PositiveNumber()
3. Comparison Table¶
| Aspect | @property |
Custom Descriptor |
|---|---|---|
| Syntax | Decorator | Class definition |
| Reusability | Per class | Across classes |
| Complexity | Simple | Can be complex |
| Use case | Specific attributes | Generic patterns |
| Examples | Computed values | Validation, ORM fields |
Advanced Patterns¶
1. Chained Properties¶
class ChainedProperty:
def __init__(self, func):
self.func = func
def __get__(self, instance, owner):
if instance is None:
return self
return self.func(instance)
def __set__(self, instance, value):
# Allow chaining
self.func(instance).__set__(value)
return instance
class Builder:
@ChainedProperty
def name(self):
return self
b = Builder().name("Alice").name("Bob")
2. Property with Dependencies¶
class DependentProperty:
def __init__(self, func, *dependencies):
self.func = func
self.dependencies = dependencies
self.name = None
def __set_name__(self, owner, name):
self.name = f'_cached_{name}'
def __get__(self, instance, owner):
if instance is None:
return self
# Check if dependencies changed
cache_key = tuple(getattr(instance, d) for d in self.dependencies)
cached = getattr(instance, self.name, None)
if cached is None or cached[0] != cache_key:
value = self.func(instance)
setattr(instance, self.name, (cache_key, value))
return cached[1] if cached else self.func(instance)
class Rectangle:
def __init__(self, width, height):
self.width = width
self.height = height
@DependentProperty
def area(self):
print("Computing area")
return self.width * self.height
area.dependencies = ('width', 'height')
r = Rectangle(5, 10)
print(r.area) # Computing area... 50
r.width = 6
print(r.area) # Computing area... 60 (recomputed)
3. Lazy Property¶
class LazyProperty:
def __init__(self, func):
self.func = func
self.name = func.__name__
def __get__(self, instance, owner):
if instance is None:
return self
value = self.func(instance)
# Replace descriptor with computed value
setattr(instance, self.name, value)
return value
class DataLoader:
@LazyProperty
def data(self):
print("Loading...")
return [1, 2, 3, 4, 5]
loader = DataLoader()
print(type(loader.__class__.data)) # <class 'LazyProperty'>
print(loader.data) # Loading... [1, 2, 3, 4, 5]
print(type(loader.data)) # <class 'list'>
Internals¶
1. Property Object Structure¶
class Example:
@property
def value(self):
"""Get value"""
return self._value
prop = Example.value
print(prop.fget) # <function value at ...>
print(prop.fset) # None
print(prop.fdel) # None
print(prop.__doc__) # "Get value"
2. Decorator Chain¶
class Example:
@property
def value(self):
return self._value
@value.setter
def value(self, val):
self._value = val
# Is equivalent to:
def get_value(self):
return self._value
def set_value(self, val):
self._value = val
value = property(get_value)
value = value.setter(set_value)
3. Manual Property Creation¶
class Example:
def _get_value(self):
return self._value
def _set_value(self, val):
if val < 0:
raise ValueError("Must be non-negative")
self._value = val
def _del_value(self):
del self._value
value = property(_get_value, _set_value, _del_value, "Value property")
obj = Example()
obj.value = 42
print(obj.value) # 42
Summary¶
1. Key Takeaways¶
- Properties ARE descriptors (specifically, data descriptors when they have a setter)
- Properties use
__get__,__set__, and__delete__under the hood - Custom descriptors allow reusable property-like behavior
- Choose properties for simplicity, descriptors for reusability
2. Mental Model¶
@property Custom Descriptor
↓ ↓
Creates property object Creates descriptor class
↓ ↓
Descriptor protocol Descriptor protocol
↓ ↓
__get__, __set__, __delete__
3. Best Practices¶
- Use
@propertyas default choice - Create custom descriptors when reusing logic
- Document descriptor behavior clearly
- Test edge cases (class access, None checks)
- Consider using
__set_name__for automatic naming