The machinery behind descriptors

The way descriptors work is not all that complicated, but the problem with them is that there are a lot of caveats to take into consideration, so the implementation details are of the utmost importance here.

In order to implement descriptors, we need at least two classes. For the purposes of this generic example, we are going to call the client class to the one that is going to take advantage of the functionality we want to implement in the descriptor (this class is generally just a domain model one, a regular abstraction we create for our solution), and we are going to call the descriptor class to the one that implements the logic of the descriptor.

A descriptor is, therefore, just an object that is an instance of a class that implements the descriptor protocol. This means that this class must have its interface containing at least one of the following magic methods (part of the descriptor protocol as of Python 3.6+):

  • __get__
  • __set__
  • __delete__
  • __set_name__

For the purposes of this initial high-level introduction, the following naming convention will be used:

Name
Meaning
ClientClass

The domain-level abstraction that will take advantage of the functionality to be implemented by the descriptor. This class is said to be a client of the descriptor.

This class contains a class attribute (named descriptor by this convention), which is an instance of DescriptorClass.

DescriptorClass

The class that implements the descriptor itself. This class should implement some of the aforementioned magic methods that entail the descriptor protocol.

client

An instance of ClientClass.

client = ClientClass()

descriptor

An instance of DescriptorClass.

descriptor = DescriptorClass().

This object is a class attribute that is placed in ClientClass.

 

This relationship is illustrated in the following diagram:

 

A very important observation to keep in mind is that for this protocol to work, the descriptor object has to be defined as a class attribute. Creating this object as an instance attribute will not work, so it must be in the body of the class, and not in the init method.

Always place the descriptor object as a class attribute!

On a slightly critical note, readers can also note that it is possible to implement the descriptor protocol partially—not all methods must always be defined; instead, we can implement only those we need, as we will see shortly.

So, now we have the structure in place—we know what elements are set and how they interact. We need a class for the descriptor, another class that will consume the logic of the descriptor, which, in turn, will have a descriptor object (an instance of the DescriptorClass) as a class attribute, and instances of ClientClass that will follow the descriptor protocol when we call for the attribute named descriptor. But now what? How does all of this fit into place at runtime?

Normally, when we have a regular class and we access its attributes, we simply obtain the objects as we expect them, and even their properties, as in the following example:

>>> class Attribute:
... value = 42
...
>>> class Client:
... attribute = Attribute()
...
>>> Client().attribute
<__main__.Attribute object at 0x7ff37ea90940>
>>> Client().attribute.value
42

But, in the case of descriptors, something different happens. When an object is defined as a class attribute (and this one is a descriptor), when a client requests this attribute, instead of getting the object itself (as we would expect from the previous example), we get the result of having called the __get__ magic method.

Let's start with some simple code that only logs information about the context, and returns the same client object:

class DescriptorClass:
def __get__(self, instance, owner):
if instance is None:
return self
logger.info("Call: %s.__get__(%r, %r)",
self.__class__.__name__,instance, owner)
return instance


class ClientClass:
descriptor = DescriptorClass()

When running this code, and requesting the descriptor attribute of an instance of ClientClass, we will discover that we are, in fact, not getting an instance of DescriptorClass, but whatever its __get__() method returns instead:

>>> client = ClientClass()
>>> client.descriptor
INFO:Call: DescriptorClass.__get__(<ClientClass object at 0x...>, <class 'ClientClass'>)
<ClientClass object at 0x...>
>>> client.descriptor is client
INFO:Call: DescriptorClass.__get__(ClientClass object at 0x...>, <class 'ClientClass'>)
True

Notice how the logging line, placed under the __get__ method, was called instead of just returning the object we created. In this case, we made that method return the client itself, hence making true a comparison of the last statement. The parameters of this method are explained in more detail in the following subsections when we explore each method in more detail.

Starting from this simple, yet demonstrative example, we can start creating more complex abstractions and better decorators, because the important note here is that we have a new (powerful) tool to work with. Notice how this changes the control flow of the program in a completely different way. With this tool, we can abstract all sorts of logic behind the __get__ method, and make the descriptor transparently run all sorts of transformations without clients even noticing. This takes encapsulation to a new level.

..................Content has been hidden....................

You can't read the all page of ebook, please click here login for view all page.
Reset
3.17.175.21