Skip to Content
Course content

195: Descriptors Explained

Click on the "Edit" button in the top corner of the screen to edit your slide content.

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:

  1. The value must be a string.
  2. The value must be between 3 and 15 characters long.
Apply this descriptor to a 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.
Rating
0 0

There are no comments for now.

to be the first to leave a comment.