Descriptors: On your mark, *get*, *set*, *delete*!

Introduction

Great design makes the complex appear simple. By this standard, Python is certainly an elegantly designed langauge. Among Python’s distinguishing characteristics as a programming language is its wide array of “special methods”. These special methods are hooks for built-in function and operators, and can be overriden to create the appearance of language-native support for user-defined classes. While you may already be familiar with some of the most common special methods — __init__(), __str__(), __iter__(),to name a few — there are many others that are not as well known.

In this article, we’ll take a closer look at three special methods in particular: __get__(), __set__(), __delete__(), and __set_name__(). Together these special methods define the descriptor protocol, an interface for customizing attribute access behavior (n.d.).

Overview

Descriptors are objects that mediate attribute access on behalf of other objects. Normally, in Python, the default behavior for attribute access is to read/modify the corresponding entry in an object’s instance dictionary. Descriptors replace this default behavior, with the one specified in a corresponding method: __get__() is invoked in response to an attribute read request, __set__() is invoked in response to an attribute write request, and __delete__() is invoked in response to an attribute delete request (n.d.).

You can think of descriptors as playing a role similar to that of a lawyer, agent, or secretary; Descriptors function as an intermediary that handles requests to a client on the client’s behalf. When an attribute is bound to a descriptor, all requests to the attribute are routed to the associated descriptor. Then, depending on the type of request (read, write, or delete), the corresponding special method (on the descriptor) is invoked, and its output is returned as the result of the original request.

Let’s take a look at each of the special methods that make up the descriptor protocol and examine when they’re called.

 
 

The Descriptor Protocol

As mentioned before, the descriptor protocol consists of the __get__(), __set__(), __delete__(), and __set_name__() special methods. In constrast to many other protocols, an object doesn’t have to implement or inherit all of these methods in order to be descriptor. We’ll return to this point later. In the meantime, the table below shows the function signature for each method in the descriptor protocol.

Special Method Description
__get__(self, instance, class) Returns the value of an attribute on an instance. If instance is None, returns self (the descriptor instance itself).
__set__(self, instance, value) Sets the attribute to value.
__delete__(self, instance) Deletes the attribute.
__set_name__(self, instance, name) Specifies the identifier that the descriptor is bound to on the object instance.
(Beazley 2009)

 
 

A Lesson on Descriptors

To showcase descriptors in action, let’s say we have a Classroom class, instances of which have a capacity atribute. A logical constraint for instances of this class could be that capacity must be a non-negative value (greater than or equal to zero) – it doesn’t make logical sense to have a classroom with a negative capacity. These requirements can be easily implemented using the property decorator.  

class Classroom:

    def __init__(self, capacity):
        self.capacity = capacity
    

    @property
    def capacity(self):
        return self._capacity
    
    @capacity.setter
    def _(self, value):
        if not isinstance(value, int):
            raise TypeError("Value must be a non-negative integer.")
        if value < 0:
            raise ValueError("Value must be a non-negative integer.")
        self._capacity = value

 

Now, suppose we want to add an attribute num_doors that describes the number of doors in a Classroom. Similar to the capacity attribute, num_doors should only accept non-negative values. Once again, this can be accomplished easily enough, using code that closely mirrors our previous solution for capacity.
 

class Classroom:

    def __init__(self, capacity, num_doors):
        self.capacity = capacity
        self.num_doors = num_doors
    

    @property
    def capacity(self):
        return self._capacity
    
    @capacity.setter
    def _(self, value):
        if not isinstance(value, int):
            raise TypeError("Value must be a non-negative integer.")
        if value < 0:
            raise ValueError("Value must be a non-negative integer.")
        self._capacity = value

    @property
    def num_doors(self):
        return self._num_doors
    
    @num_doors.setter
    def _(self, value):
        if not isinstance(value, int):
            raise TypeError("Value must be a non-negative integer.")
        if value < 0:
            raise ValueError("Value must be a non-negative integer.")
        self._num_doors = value

 

While, for this example, the addition was easy enough to make, we can see that there is a lot of duplication in the code. The presence of duplicated code is a signal that our current approach is not very flexible. For example, what if there are other attributes that we want to add, that are also constrained to non-negative values? If we continued using our current approach, then we would need separate getter and setter property functions for each attribute. Again, this choice would lead to a lot of duplicate code, which in turn would make future changes prone to error. The crux of our problem is that we have common logic shared among a group of attributes. These are ideal conditions for using a descriptor. By employing descriptors, we can abstract out the common logic into a reusable class.

Below, we’ve done exactly that. We’ve created a class NonNegative that contains all of the common logic for attributes that should accept only non-negative integers as values. In the Classroom class definition, we create two instances of the NonNegative descriptor and bind one instance to the capacity attribute, and the other to the num_doors attribute. Later, when a Classroom instance is being initialized, the attribute assignments use each descriptor’s __set__() method. It’s important to note, that descriptors must be declared as class attributes.

 

class NonNegative:

    def __set_name__(self, name):
        self.name = "_" + name
    
    def __get__(self, instance, cls):
        if instance is None:
            return self
        return getattr(instance, self.name)
    
    def __set__(self, instance, value):
        if not isinstance(value, int):
            raise TypeError("Value must be a non-negative integer.")
        if value < 0:
            raise ValueError("Value must be a non-negative integer.")
        setattr(instance, self.name, value)


class Classroom:

    capacity = NonNegative()
    num_doors = NonNegative()

    def __init__(self, capacity, num_doors)
        self.capacity = capacity
        self.num_doors = num_doors

   

We’ll stop here for now. In the next article, we’ll learn about the two different kinds of descriptors, how we can design parameterized descriptors, and more. I hope you we’re able to learn something new today and now have a better understanding of how to use descriptors in your own work.


Additional Resources

“3. Data Model — Python 3.8.5 Documentation.” n.d. Accessed September 2, 2020. https://docs.python.org/3/reference/datamodel.html#implementing-descriptors.
———. n.d. Accessed September 2, 2020. https://docs.python.org/3/reference/datamodel.html#invoking-descriptors.
Beazley, David M. 2009. Python Essential Reference. 4th ed. Developer’s Library. Upper Saddle River, NJ: Addison-Wesley.
Ramalho, Luciano. 2015. Fluent Python. First edition. Sebastopol, CA: O’Reilly.
Avatar
Akindele Davies
Associate Data Scientist

My research interests include statistics and solving complex problems.