Python
Completed
-
Section 1: Getting Started
-
Section 2: Core Syntax and Data Types
-
Section 3: Collections
-
39: Set Operations: Union, Intersection, Difference
-
Section 4: Control Flow
-
Section 5: Functions
-
Section 6: Turtle Graphics and Early Practice Projects
-
Section 7: Working with Files and I/O
-
Section 8: Regular Expressions
-
Section 9: Object-Oriented Python
-
Section 10: Error Handling
-
Section 11: Modules and Packages
-
Section 12: Iterators, Generators, and Functional Tools
-
Section 13: Decorators and Metaprogramming
-
Section 14: Concurrency and Parallelism
-
Section 15: Working with Dates, Times, and Numbers
-
Section 16: Standard Library Deep Dive I: Data Structures
-
Section 17: Standard Library Deep Dive II: System and Introspection
-
Section 18: Standard Library Deep Dive III: Security and Encoding
-
Section 19: Standard Library Deep Dive IV: Text and Data Utilities
-
Section 20: Networking and Web Basics
-
Section 21: Working with Databases
-
Section 22: Testing and Quality
-
Section 23: Advanced Typing
-
Section 24: Context Managers and Resource Handling
-
Section 25: Text, Unicode, and Binary Data
-
Section 26: More Functional and Iteration Tools
-
Section 27: Data Validation and Configuration
-
Section 28: Working with Images and Media
-
Section 29: Property-Based and Documentation Testing
-
Section 30: Packaging and Deployment
-
Section 31: Performance and Internals
-
Section 32: Design Patterns in Python
-
Section 33: GUI Programming
-
Section 34: Security Basics
-
Section 35: Data Structures and Algorithms
-
Section 36: Practical Projects
-
Section 37: Capstone Projects
-
Section 38: Interview and Algorithm Practice
-
Section 39: Writing Idiomatic Python
195: Descriptors Explained
You've likely spent a lot of time using the @property decorator to handle validation or computed attributes. It's a great tool, but once you start building a larger system, you'll notice a frustrating pattern: you find yourself writing the exact same validation logic over and over again across different classes. If you have a Product class, a Service class, and a Subscription class, and all of them need a price attribute that cannot be negative, you end up with three identical sets of getters and setters.
The Boilerplate Trap
Let's look at the naive way we usually handle this. We create a private variable and wrap it in properties. It works, but it's tedious.
class Product:
def __init__(self, price):
self.price = price
@property
def price(self):
return self._price
@price.setter
def price(self, value):
if value < 0:
raise ValueError("Price cannot be negative")
self._price = value
class Service:
def __init__(self, hourly_rate):
self.hourly_rate = hourly_rate
@property
def hourly_rate(self):
return self._hourly_rate
@hourly_rate.setter
def hourly_rate(self, value):
if value < 0:
raise ValueError("Rate cannot be negative")
self._hourly_rate = value
I call this the "Boilerplate Trap." The logic is the same, but because the property is tied to the specific class, we're duplicating code. If we decide later that prices must be at least 0.01 instead of 0, we have to hunt down every single setter across the entire codebase. It's a maintenance nightmare waiting to happen.
Moving Logic Into the Attribute Itself
This is where descriptors come in. A descriptor is essentially a class that tells Python: "Whenever someone tries to get or set an attribute on another object, let me handle it first." Instead of writing the logic inside the Product class, we write it in a separate class and then use an instance of that class as our attribute.
To make a descriptor, we implement the __set__ and __get__ methods. Here is how I would refactor the previous example to be actually maintainable:
class PositiveNumber:
def __set_name__(self, owner, name):
# This helper tells us the name of the variable in the owner class
self.private_name = '_' + name
def __get__(self, instance, owner):
# If accessed from the class level, return the descriptor itself
if instance is None:
return self
return getattr(instance, self.private_name)
def __set__(self, instance, value):
if value < 0:
raise ValueError(f"Value cannot be negative")
setattr(instance, self.private_name, value)
class Product:
price = PositiveNumber() # This is the descriptor
def __init__(self, price):
self.price = price
class Service:
hourly_rate = PositiveNumber()
def __init__(self, hourly_rate):
self.hourly_rate = hourly_rate
Notice what happened here. We've decoupled the validation logic from the data model. The Product class doesn't actually know how price is validated; it just knows that PositiveNumber is handling it. If you need to add a TaxRate attribute or a ShippingFee, you just assign them to PositiveNumber() and you're done.
The Trade-off: Complexity vs. Reuse
Now, you might be thinking, "Why not just use a function?" The beauty of the descriptor is that it maintains the clean obj.attribute = value syntax. Your API remains simple for the user of the class, while the implementation is robust under the hood.
The cost is a slight increase in conceptual overhead. You have to remember that the descriptor instance is shared across all instances of the Product class. This is why we can't store the value directly inside the descriptor (e.g., self.value = value). If we did, every single Product would share the same price. Instead, we store the data on the instance object itself using setattr or by calculating a private name like _price.
I generally advise using descriptors only when you find yourself repeating the same property logic in three or more places. For a single attribute in a single class, a simple @property is cleaner. But once you're building a framework or a complex domain model, descriptors are the secret sauce that keeps your classes lean.
📋 Practical Task
Build a Validated Username Descriptor
Create a descriptor class called UsernameValidator that enforces two rules for any attribute it is assigned to:
- The value must be a string.
- The value must be between 3 and 15 characters long.
User class for the username attribute and a Profile class for the display_name attribute. Test your implementation by attempting to set a username that is too short (e.g., "ed") and ensuring it raises a ValueError with a descriptive message.There are no comments for now.