Advanced Applications and Practical Use of Descriptors in Python
Descriptors are a powerful feature in Python that allow objects to customize the behavior of attribute access. You already understand the basic concepts of descriptors; now let's delve into their advanced applications and practical usage scenarios.
1. Descriptor Protocol Review
A descriptor is a class that implements a specific protocol (__get__, __set__, __delete__). Depending on the protocol methods implemented, they are divided into:
- Data Descriptor: Implements
__set__or__delete__ - Non-data Descriptor: Only implements
__get__
2. Descriptor Priority Rules
When instance attributes, class attributes, and descriptors share the same name, the access priority is:
- Data Descriptor (highest priority)
- Instance Attribute
- Non-data Descriptor
- Class Attribute (lowest priority)
class DataDescriptor:
def __get__(self, instance, owner):
return "Data Descriptor"
def __set__(self, instance, value):
pass
class NonDataDescriptor:
def __get__(self, instance, owner):
return "Non-data Descriptor"
class Test:
data_desc = DataDescriptor()
non_data_desc = NonDataDescriptor()
t = Test()
t.data_desc = "Instance Attribute" # Data descriptor takes priority; this line actually calls the descriptor's __set__
t.non_data_desc = "Instance Attribute" # Non-data descriptor; creates an instance attribute
print(t.data_desc) # Output: Data Descriptor
print(t.non_data_desc) # Output: Instance Attribute
3. Lazy Property (Lazy Evaluation)
Use descriptors to implement lazy initialization, where computation only occurs on first access:
class LazyProperty:
def __init__(self, method):
self.method = method
self.method_name = method.__name__
def __get__(self, instance, owner):
if instance is None:
return self
# Compute result on first access and cache it
value = self.method(instance)
setattr(instance, self.method_name, value) # Replace descriptor with computed result
return value
class HeavyComputation:
@LazyProperty
def expensive_result(self):
print("Performing complex computation...")
return sum(i*i for i in range(10**6))
obj = HeavyComputation()
print("First access:")
print(obj.expensive_result) # Will perform computation
print("Second access:")
print(obj.expensive_result) # Directly returns cached result
4. Validation Descriptor
Ensure attribute values meet specific conditions:
class Validated:
def __init__(self, name=None, min_value=None, max_value=None):
self.name = name
self.min_value = min_value
self.max_value = max_value
def __set_name__(self, owner, name):
self.name = name
def __get__(self, instance, owner):
if instance is None:
return self
return instance.__dict__.get(self.name)
def __set__(self, instance, value):
if self.min_value is not None and value < self.min_value:
raise ValueError(f"{self.name} cannot be less than {self.min_value}")
if self.max_value is not None and value > self.max_value:
raise ValueError(f"{self.name} cannot be greater than {self.max_value}")
instance.__dict__[self.name] = value
class Person:
age = Validated(min_value=0, max_value=150)
height = Validated(min_value=0)
def __init__(self, age, height):
self.age = age
self.height = height
try:
p = Person(200, 180) # Will raise an exception
except ValueError as e:
print(f"Error: {e}")
5. Observer Pattern Descriptor
Automatically notify observers when an attribute changes:
class Observable:
def __init__(self):
self.observers = []
def __set_name__(self, owner, name):
self.name = name
def __get__(self, instance, owner):
if instance is None:
return self
return instance.__dict__.get(self.name)
def __set__(self, instance, value):
old_value = instance.__dict__.get(self.name)
instance.__dict__[self.name] = value
if old_value != value:
self.notify(instance, old_value, value)
def add_observer(self, observer):
self.observers.append(observer)
def notify(self, instance, old_value, new_value):
for observer in self.observers:
observer(instance, self.name, old_value, new_value)
class Stock:
price = Observable()
def __init__(self, symbol, price):
self.symbol = symbol
self.price = price
def price_change_handler(instance, attr_name, old_value, new_value):
print(f"{instance.symbol} price changed from {old_value} to {new_value}")
stock = Stock("AAPL", 100)
Stock.price.add_observer(price_change_handler)
stock.price = 105 # Automatically triggers notification
6. Descriptor Applications in Frameworks
Many popular frameworks use descriptors, such as Django's model fields:
# Simplified Django-style field descriptor
class CharField:
def __init__(self, max_length=255):
self.max_length = max_length
def __set_name__(self, owner, name):
self.name = name
def __get__(self, instance, owner):
return instance.__dict__.get(self.name)
def __set__(self, instance, value):
if not isinstance(value, str):
raise TypeError("Must be a string")
if len(value) > self.max_length:
raise ValueError(f"Length cannot exceed {self.max_length}")
instance.__dict__[self.name] = value
class User:
username = CharField(max_length=50)
email = CharField(max_length=100)
def __init__(self, username, email):
self.username = username
self.email = email
7. Best Practices for Descriptors
- Use
__set_name__to automatically get the attribute name (Python 3.6+) - Properly handle the
instance is Nonecase in__get__ - Store data in the instance's
__dict__to avoid recursion - Consider the inheritability of descriptors
Descriptors are one of the core tools of Python metaprogramming; using them appropriately can make your code more elegant and powerful.