Demystifying Python Descriptors
Have you ever wondered how @property
works in Python? Or how Django models automatically validate and manage data fields?
Python uses descriptors for this. Learning them well allows you to write better classes that are also more powerful.
What Are Descriptors?
Descriptors are objects that define the behavior of attribute access (getting, setting, or deleting) in a class. To create a descriptor, you implement one or more of these special methods into a class:
__get__(self, instance, owner)
: Defines behavior for attribute access.__set__(self, instance, value)
: Defines behavior for attribute assignment.__delete__(self, instance)
: Defines behavior for attribute deletion.
In simpler terms, descriptors allow you to intercept and customize what happens when attributes of a class are accessed or modified.
A Simple Read-Only Descriptor
Here’s an example of a descriptor that allows only reading a constant value:
class ReadOnly:
def __init__(self, value):
self.value = value
def __get__(self, instance, owner):
return self.value
def __set__(self, instance, value):
raise AttributeError
class Example:
constant = ReadOnly(42)
obj = Example()
print(obj.constant)
obj.constant = 100
print(obj.constant)
Output:
42
Traceback (most recent call last):
File "...", line 16, in <module>
obj.constant = 100 # Raises AttributeError
^^^^^^^^^^^^
File "...", line 9, in __set__
raise AttributeError
AttributeError
This descriptor prevents changes to the constant
attribute by raising an error in the __set__
method.
Why Use Descriptors?
Attribute validation
Ensure values meet specific criteria.
class PositiveNumber:
def __get__(self, instance, owner):
return instance.__dict__.get(self.name, 0)
def __set__(self, instance, value):
if value < 0:
raise ValueError("Value must be positive")
instance.__dict__[self.name] = value
def __set_name__(self, owner, name):
self.name = name
class Account:
balance = PositiveNumber()
account = Account()
account.balance = 100
print(f"{account.balance = }")
account.balance = -50
Output:
Traceback (most recent call last):
File "...", line 19, in <module>
account.balance = -50 # Raises ValueError
^^^^^^^^^^^^^^^
File "...", line 7, in __set__
raise ValueError("Value must be positive")
ValueError: Value must be positive
account.balance = 100
Lazy evaluation
Compute values only when accessed.
class LazyAttribute:
def __get__(self, instance, owner):
print("Computing value...")
return 42
class Example:
attribute = LazyAttribute()
obj = Example()
print(obj.attribute)
Output:
Computing value...
42
Reusability
Share logic across multiple classes.
class NonEmptyString:
def __get__(self, instance, owner):
return instance.__dict__.get(self.name, "")
def __set__(self, instance, value):
if not value:
raise ValueError("String cannot be empty")
instance.__dict__[self.name] = value
def __set_name__(self, owner, name):
self.name = name
class Person:
name = NonEmptyString()
class Animal:
name = NonEmptyString()
person = Person()
person.name = "Alice" # Works fine
person.name = "" # Raises ValueError
Encapsulation
Customize access while hiding implementation details.
Example - Numeric Range Validation
Here is an example of a descriptor that ensures values fall within a certain range when an attribute is set:
class RangeValidator:
def __init__(self, min_value, max_value):
self.min_value = min_value
self.max_value = max_value
def __get__(self, instance, owner):
return instance.__dict__.get(self.name)
def __set__(self, instance, value):
if not (self.min_value <= value <= self.max_value):
raise ValueError(f"Not between {self.min_value} and {self.max_value}")
instance.__dict__[self.name] = value
def __set_name__(self, owner, name):
self.name = name
class Product:
price = RangeValidator(0, 1000)
product = Product()
product.price = 500 # Works fine
product.price = -10 # Raises ValueError
Real World Applications
@property
and@staticmethod
: Both are implemented using descriptors.- ORMs like Django Models: Fields like
IntegerField
andCharField
use descriptors to validate and manage database records.
Example - Django Models
class CharField:
def __init__(self, max_length):
self.max_length = max_length
def __get__(self, instance, owner):
return instance.__dict__.get(self.name, "")
def __set__(self, instance, value):
if len(value) > self.max_length:
raise ValueError(f"Value exceeds {self.max_length} characters")
instance.__dict__[self.name] = value
def __set_name__(self, owner, name):
self.name = name
class User:
username = CharField(max_length=10)
user = User()
user.username = "short" # Works fine
user.username = "toolongname" # Raises ValueError
This is how Django's ORM works.
Example - Cached Property
Here’s an example of a descriptor that caches the result of an expensive computation:
class CachedProperty:
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)
instance.__dict__[self.name] = value
return value
class Data:
@CachedProperty
def expensive_computation(self):
print("Computing...")
return sum(i * i for i in range(1000000))
data = Data()
print(data.expensive_computation) # Computes and caches the result
print(data.expensive_computation) # Returns cached result
Example - Logging Attribute Access
Here’s how to implement a logger using descriptors:
class Logger:
def __get__(self, instance, owner):
print(f"Accessing attribute in {owner}")
return instance.__dict__.get(self.name)
def __set__(self, instance, value):
print(f"Setting {self.name} to {value}")
instance.__dict__[self.name] = value
def __set_name__(self, owner, name):
self.name = name
class Example:
attribute = Logger()
obj = Example()
obj.attribute = 10 # Logs: Setting attribute to 10
print(obj.attribute) # Logs: Accessing attribute
Conclusion
Descriptors are a powerful tool in Python that allow control over attribute behavior. Whether you’re validating data, caching expensive computations, or enhancing attribute access, descriptors provide a clean and reusable way to handle complex logic. By understanding and leveraging descriptors, you can write more Pythonic and maintainable code.