Python Descriptors: Building Reusable Attribute Logic

Python Descriptors: Building Reusable Attribute Logic (Or, How I Learned to Stop Worrying and Love the __get__
)
Alright, settle in, folks. Today we're diving into Python descriptors. Not because some LinkedIn guru told me it's the "next big thing" (eye roll), but because they're genuinely useful for building reusable, elegant, and frankly, kinda badass attribute logic. If you're tired of copy-pasting validation code across your classes, or you're just curious about the magic behind @property
, then buckle up. We're going deep.
What in the Holy Hell is a Descriptor?
In essence, a descriptor is a class that manages attribute access. Woah, slow down! Think of it as a gatekeeper for your object's attributes. Instead of directly accessing the attribute, you go through the descriptor, which can then perform extra logic like validation, lazy loading, or even tracking changes.
Descriptors are defined by implementing one or more of the following special methods (the descriptor protocol):
__get__(self, instance, owner)
: Called when the descriptor's attribute value is accessed.__set__(self, instance, value)
: Called when the descriptor's attribute value is assigned.__delete__(self, instance)
: Called when the descriptor's attribute is deleted.__set_name__(self, owner, name)
: Called when the owning class is created, giving the descriptor a chance to know its attribute name. (Python 3.6+)
These methods let you intercept attribute access, assignment, and deletion, giving you fine-grained control over how your objects behave.
The @property
Decorator: Descriptors in Disguise
You've probably used @property
before, right? It's that handy decorator that lets you define getter, setter, and deleter methods for an attribute. Guess what? It's built on descriptors!
Let's break it down:
class Circle:
def __init__(self, radius):
self._radius = radius
@property
def radius(self):
"""I'm the 'radius' property."""
print("Getting radius")
return self._radius
@radius.setter
def radius(self, value):
if value <= 0:
raise ValueError("Radius must be positive")
print("Setting radius")
self._radius = value
@radius.deleter
def radius(self):
print("Deleting radius")
del self._radius
Under the hood, @property
creates a property
object, which is a descriptor. When you access circle.radius
, Python calls the __get__
method of the property
object, which in turn calls your getter method. The same goes for setting and deleting the attribute.
@radius.setter
and @radius.deleter
are just syntactic sugar to create new property
objects with the setter and deleter methods attached, respectively. They essentially replace the original property
object associated with radius
.
Roll Your Own: Custom Descriptor Examples
Okay, enough theory. Let's get our hands dirty with some custom descriptors.
Example 1: Validating Attributes
Tired of writing the same validation logic over and over? A descriptor can help.
class ValidatedString:
def __init__(self, min_length=0, max_length=None):
self.min_length = min_length
self.max_length = max_length
self.name = None # Assigned by __set_name__
def __set_name__(self, owner, name):
self.name = name
def __get__(self, instance, owner):
if instance is None:
return self
return instance.__dict__[self.name]
def __set__(self, instance, value):
if not isinstance(value, str):
raise TypeError(f"{self.name} must be a string")
if len(value) < self.min_length:
raise ValueError(f"{self.name} must be at least {self.min_length} characters long")
if self.max_length is not None and len(value) > self.max_length:
raise ValueError(f"{self.name} must be at most {self.max_length} characters long")
instance.__dict__[self.name] = value
class User:
name = ValidatedString(min_length=3, max_length=50)
email = ValidatedString()
def __init__(self, name, email):
self.name = name
self.email = email
Now, whenever you try to assign an invalid value to user.name
or user.email
, the ValidatedString
descriptor will raise an exception. Boom. Reusable validation.
Example 2: Lazy Loading
Want to defer loading an expensive attribute until it's actually needed? Descriptors to the rescue!
import time
class LazyLoad:
def __init__(self, func):
self.func = func
self.name = None # Assigned by __set_name__
def __set_name__(self, owner, name):
self.name = name
def __get__(self, instance, owner):
if instance is None:
return self
print(f"Loading {self.name}...")
value = self.func(instance)
instance.__dict__[self.name] = value # Cache the result
return value
class DataProcessor:
@LazyLoad
def large_dataset(self):
# Simulate loading a large dataset
print("Actually loading...")
time.sleep(2)
return list(range(1000000))
def __init__(self):
pass
The first time you access data_processor.large_dataset
, the LazyLoad
descriptor will call the large_dataset
method, load the data, and cache it in the instance's __dict__
. Subsequent accesses will retrieve the cached value directly, avoiding the expensive loading process. Efficiency!
Real-World Applications: Beyond the Hype
Descriptors aren't just academic exercises. They're used in real-world applications all the time.
- ORMs (Object-Relational Mappers): ORMs like SQLAlchemy use descriptors to map database columns to object attributes, handle data validation, and track changes.
- Validation Libraries: Libraries like
attrs
andmarshmallow
leverage descriptors for defining and validating object attributes. - Frameworks: Django uses descriptors in its model fields to handle data conversion and validation.
Basically, if you're working on a complex Python project, chances are you're already using descriptors, even if you don't realize it.
Common Pitfalls and Best Practices
Descriptors can be powerful, but they can also be tricky. Here are a few things to keep in mind:
- Descriptor Types: There are two main types of descriptors: data descriptors (with both
__get__
and__set__
) and non-data descriptors (with only__get__
). Data descriptors take precedence over instance attributes, while non-data descriptors are shadowed by instance attributes. Understand the difference! __set_name__
: Don't forget to use__set_name__
(Python 3.6+) to get the attribute name. It makes your descriptors more reusable and less prone to errors.- Avoid Infinite Recursion: Be careful when accessing or setting attributes within your descriptor methods. You can easily create infinite recursion if you're not careful. Use the instance's
__dict__
directly to avoid triggering the descriptor again. - Keep it Simple: Don't overcomplicate your descriptors. If you find yourself writing a ton of code, consider breaking it down into smaller, more manageable functions or classes.
Conclusion: Embrace the Descriptor
Python descriptors are a powerful tool for building reusable and maintainable code. They might seem a bit intimidating at first, but once you understand the basics, you'll be able to write more elegant and efficient Python code. So, go forth and embrace the descriptor! And remember, don't let the LinkedIn influencers tell you what to do. Think for yourself, write good code, and stay slightly unhinged. You'll go far.
Subscribe to my newsletter
Read articles from buddha gautam directly inside your inbox. Subscribe to the newsletter, and don't miss out.
Written by

buddha gautam
buddha gautam
Python, Django, DevOps(can use ec2 and docker lol).