- What Are Descriptors?
- Using Descriptors to Compute Attributes
- Using Descriptors to Store Data
- Combining Descriptors with Class Decorators for Validation
- Conclusion
Using Descriptors to Compute Attributes
We often create classes holding data items that we want to access in more than one form. For example, imagine that we have a Person class that holds a person's salutation, forename, and surname. Typically we want to display the complete name in some standard form; for example, the salutation, the first initial of the forename, a period, and then the surname. We could easily do this by creating an additional "display name" instance variable whenever a new Person object was created. But this approach has some disadvantages:
- We must store an extra string for every person, even if we rarely use it.
- If the salutation, forename, or surname is changed, we must update the display name.
It would be much better if we could just create the display name when it was needed—this technique would avoid the need to store or update an extra string for each person. Here's how the definition of such a Person class might look:
class Person: display_name = DisplayName() def __init__(self, salutation, forename, surname): self.salutation = salutation self.forename = forename self.surname = surname
Every instance of the Person class has four instance attributes:
- self.salutation
- self.forename
- self.surname
- self.display_name
The self.display_name attribute is represented by an instance of the DisplayName descriptor class (so it doesn't use any memory for each Person instance), yet it's accessed like any other attribute. (In this case, we've made it read-only; as you'll see shortly, the descriptor has a getter but no setter.)
For example:
fred = Person("", "Fred", "Bloggs") assert fred.display_name == "F. Bloggs" jane = Person("Ms", "Jane", "Doe") assert jane.display_name == "Ms J. Doe"
The implementation of the descriptor class is very simple:
class DisplayName: def __get__(self, instance, owner=None): parts = [] if instance.salutation: parts.append(instance.salutation) if instance.forename: parts.append(instance.forename[0] + ".") parts.append(instance.surname) return " ".join(parts)
When an attribute is read via a descriptor, the descriptor's __get__() method is called. The self argument is the descriptor instance, the instance argument is the instance of the object for whose class the descriptor is defined, and the owner argument is that class. So, when fred.display_name is used, the Person class' instance of the DisplayName descriptor's __get__() method is called with Person.displayName as the self argument, fred as the instance argument, and Person as the owner argument.
Of course, the same goal could be achieved by using a display name property. By using a descriptor, however, we can create as many display name attributes as we like, in as many different classes as we like—all getting their behavior from the descriptor with a single line of code for each one. This design eases maintenance; if we need to change how the display name attribute works (perhaps to change the format of the string it returns), we have to change the code in only one place—in the descriptor—rather than in individual property functions for each relevant attribute in every affected class.
Clearly, descriptors can be useful for reducing the memory footprint of classes in which attributes can be computed. This footprint can be reduced still further by using slots; for example, by adding this line to the Person class:
__slots__ = ("salutation", "forename", "surname")
If the computation is expensive, we could cache the results in the descriptor, using the technique shown in the next section.