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. |
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.