Tutorials
python

Python Descriptors

Learn what Python Descriptors are, when you should use them, and why you should use them.

Python descriptors or, more generally, descriptors provide you a powerful technique to write reusable code that can be shared between classes. They might seem similar to the concept of inheritance, but technically they are not. They are a general-purpose way of intercepting attribute access. Descriptors are the mechanism behind properties' static methods, class methods, super methods, etc.

Descriptors were added in Python version 2.2, and since then, they are considered as magical things which have given traditional classes a new style. They are classes that allow you to do managed properties in another class. Specifically, they implement interface for __get__(), __set__() and __delete__() method which makes them interesting for many reasons. For example, a class decorator and property decorator that you would have seen in Python before.

To put it simply, a class that implements __get__(), __set()__, or __delete()__ method for an object is known as a "Descriptor". Quoting directly from Python's official documentation, a descriptor is an object attribute with binding behavior, one whose attribute access has been overridden by methods in the descriptor protocol. Those methods are __get__(), __set__(), and __delete__() (Source).

Binding behavior with respect to descriptors means that binding the way value can be set, queried (get), or deleted for a given variable or object or a data set. This complete interaction is bound to this piece of data as it only applies to what data you've set it on, hence, binding it to only that particular part of data.

The descriptors can be further categorized into data and non-data descriptors. If the descriptor that you write has only __get__() method, then it is a non-data descriptor, on the other hand, implementation involving __set__() and __delete__() methods is called a data descriptor. The non-data descriptors are only readable while the data descriptors are both readable and writable.

It is important to note that descriptors are assigned to a class, not to the instance of a class. Modifying the class overwrites or deletes the descriptor itself, rather than triggering its code (IBM Developer).

Finally, the descriptor class is not only confined to have these three methods, which means it can also contain any other attribute apart from the get, set, and delete method.

Let's understand the get, set, and delete methods in more detail inspired by this IBM Developer page:

  • self is the instance of the descriptor you create (Real Python).
  • object is the instance of the object your descriptor is attached (Real Python).
  • type is the type of the object the descriptor is attached to (Real Python).
  • value is the value that is assigned to the attribute of the descriptor. get(self, object, type) set(self, object, value) delete(self, object)
  • __get__() accesses the attribute or when you want to extract some information. It returns the value of the attribute or raises the AttributeError exception if a requested attribute is not present.

  • __set__() is called in an attribute assignment operation that sets the value of an attribute. Returns nothing. But can raise the AttributeError exception.

  • __delete__() controls a delete operation, i.e., when you would want to delete the attribute from an object. Returns nothing.

Now let's understand the purpose of Descriptors with the help of some examples!

Purpose of Descriptors

Let's define a class car that has three attributes, namely make, model, and fuel_cap. You will use the __init__() method to initialize the attributes of the class. Then, you will use the magic function __str__(), which will simply return the output of the three attributes that you will pass to the class while creating the object.

Note that the __str__() method returns the string representation of the object. It is called when print()or str() function is invoked on an object of the class.

class Car:
    def __init__(self,make,model,fuel_cap):
        self.make = make
        self.model = model
        self.fuel_cap = fuel_cap

    def __str__(self):
        return "{0} model {1} with a fuel capacity of {2} ltr.".format(self.make,self.model,self.fuel_cap)
car1 = Car("BMW","X7",40)
print(car1)
BMW model X7 with a fuel capacity of 40 ltr.

So as you can see from the above output, everything looks great!

Now let's change the fuel capacity of the car to negative 40.

car2 = Car("BMW","X7",-40)
print(car2)
BMW model X7 with a fuel capacity of -40 ltr.

Hold on, there is something wrong, isn't it? The fuel capacity of the car can never be negative. However, Python accepts it as an input without any error. That's because Python is a dynamic programming language that does not support type-checking explicitly.

To avoid this issue, let's add an if condition in the __init__() method and check whether the inputted fuel capacity is valid or invalid. If the fuel capacity entered is invalid, then raise a ValueError exception.

class Car:
    def __init__(self,make,model,fuel_cap):
        self.make = make
        self.model = model
        self.fuel_cap = fuel_cap
        if self.fuel_cap < 0:
            raise ValueError("Fuel Capacity can never be less than zero")

    def __str__(self):
        return "{0} model {1} with a fuel capacity of {2} ltr.".format(self.make,self.model,self.fuel_cap)
car1 = Car("BMW","X7",40)
print(car1)
BMW model X7 with a fuel capacity of 40 ltr.
car2 = Car("BMW","X7",-40)
print(car2)
----------------------------------------

ValueErrorTraceback (most recent call last)

<ipython-input-22-1c3d23be72f7> in <module>
----> 1 car2 = Car("BMW","X7",-40)
      2 print(car2)


<ipython-input-20-1e154783588d> in __init__(self, make, model, fuel_cap)
      5         self.fuel_cap = fuel_cap
      6         if self.fuel_cap < 0:
----> 7             raise ValueError("Fuel Capacity can never be less than zero")
      8
      9     def __str__(self):


ValueError: Fuel Capacity can never be less than zero

From the above output, you can observe that everything works fine for now since the program raises a ValueError if the fuel capacity is below zero.

However, there can be another problem, i.e., what if the fuel capacity entered is a float value or a string. Not just the fuel capacity but also the make and model of the car can be an integer value. In all of these cases, the program will fail to raise an exception.

class Car:
    def __init__(self,make,model,fuel_cap):
        self.make = make
        self.model = model
        self.fuel_cap = fuel_cap
        if self.fuel_cap < 0:
            raise ValueError("Fuel Capacity can never be less than zero")

    def __str__(self):
        return "{0} model {1} with a fuel capacity of {2} ltr.".format(self.make,self.model,self.fuel_cap)
car2 = Car(-40,"X7",40)
print(car2)
-40 model X7 with a fuel capacity of 40 ltr.

To handle the above case as well, you might think of adding another if condition or maybe make use of the isinstance method for type-checking.

This time let's use the isinstance built-in method to handle the error.

class Car:
    def __init__(self,make,model,fuel_cap):
        self.make = make
        self.model = model
        self.fuel_cap = fuel_cap
        if isinstance(self.make, str):
            print(self.make)
        else:
            raise ValueError("Make of the car can never be an integer")

        if self.fuel_cap < 0:
            raise ValueError("Fuel Capacity can never be less than zero")

    def __str__(self):
        return "{0} model {1} with a fuel capacity of {2} ltr.".format(self.make,self.model,self.fuel_cap)
car2 = Car("BMW","X7",40)
print(car2)
BMW
BMW model X7 with a fuel capacity of 40 ltr.
car2 = Car(-40,"X7",40)
print(car2)
----------------------------------------

ValueErrorTraceback (most recent call last)

<ipython-input-34-75b08cba454f> in <module>
----> 1 car2 = Car(-40,"X7",40)
      2 print(car2)


<ipython-input-31-175690bf3b98> in __init__(self, make, model, fuel_cap)
      7             print(self.make)
      8         else:
----> 9             raise ValueError("Make of the car can never be an integer")
     10
     11         if self.fuel_cap < 0:


ValueError: Make of the car can never be an integer

Great! So you were able to handle this error as well.

However, what if you would like to change the fuel capacity attribute to negative 40 explicitly later on. In this case, it will not work, since the type-checking will be done only in the __init__() method once. As you would know, the __init__() method is a constructor, and it is called only once when you create an object of the class. Hence, the custom type-checking will fail later on.

Let's understand it with an example.

class Car:
    def __init__(self,make,model,fuel_cap):
        self.make = make
        self.model = model
        self.fuel_cap = fuel_cap
        if isinstance(self.make, str):
            print(self.make)
        else:
            raise ValueError("Make of the car can never be an integer")

        if self.fuel_cap < 0:
            raise ValueError("Fuel Capacity can never be less than zero")

    def __str__(self):
        return "{0} model {1} with a fuel capacity of {2} ltr.".format(self.make,self.model,self.fuel_cap)
car2 = Car("BMW","X7",40)
print(car2)
BMW
BMW model X7 with a fuel capacity of 40 ltr.

car2.make = -40
print(car2)
-40 model X7 with a fuel capacity of 40 ltr.

And there you go! You were able to break out of type-checking.

Now think it this way, what if you have many other attributes of the car like mileage, price, accessories, etc. which requires type-checking as well and you also would like functionality in which some of these attributes have only read access. Wouldn't that be so annoying?

Well, to address all of the above problems, Python has Descriptors that come to the rescue!

As you learned above that any class that implements __get__(), __set()__, or __delete()__ magic methods for an object of descriptor protocol are called Descriptors. They also give you additional control over how an attribute should work like whether it should have a read or write access.

Now let's extend the above example by adding the Python Descriptor methods.

class Descriptor:
    def __init__(self):
        self.__fuel_cap = 0
    def __get__(self, instance, owner):    
        return self.__fuel_cap
    def __set__(self, instance, value):
        if isinstance(value, int):
            print(value)
        else:
            raise TypeError("Fuel Capacity can only be an integer")

        if value < 0:
            raise ValueError("Fuel Capacity can never be less than zero")

        self.__fuel_cap = value

    def __delete__(self, instance):
        del self.__fuel_cap
class Car:
    fuel_cap = Descriptor()
    def __init__(self,make,model,fuel_cap):
        self.make = make
        self.model = model
        self.fuel_cap = fuel_cap

    def __str__(self):
        return "{0} model {1} with a fuel capacity of {2} ltr.".format(self.make,self.model,self.fuel_cap)
car2 = Car("BMW","X7",40)
print(car2)
40
BMW model X7 with a fuel capacity of 40 ltr.

Well, don't worry if the class Descriptor seems obscure. Let's break it down into small pieces and understand what each method is essentially doing.

  • The __init__() method of the Descriptor class has a local variable __fuel_cap to zero. The dunder or a double underscore at the beginning of it means that the variable is private. Having a dunder, in the beginning, is only to distinguish the fuel capacity attribute of Descriptor class with the Car class.
  • As you know by now, the __get__() method is used to retrieve the attributes, and it returns the variable fuel capacity. It takes three arguments the descriptor object, instance of the class that contains the descriptor object instance, i.e., car2 and finally the owner, which is the class to which the instance belongs, i.e., the Car class. In this method, you simply return the value attribute, i.e., the fuel_cap whose value is set in the __set__() method.
  • The __set__() method is invoked when the value is set to the attribute, and unlike the __get__() method, it returns nothing. It has two arguments apart from the descriptor object itself, i.e., the instance which is the same as the __get__() method and the value argument, which is the value you assign to the attribute. In this method, you check whether the value you would like to assign to the fuel_cap attribute is an integer or not. If not, you raise a TypeError exception. Then, in the same method, you also check whether the value is less than zero if it is then you raise another exception but this time a ValueError exception. After checking for errors, you update the fuel_cap attribute equal to the value.
  • Finally, the __delete__() method, which is called when the attribute is deleted from an object and similar to the __set__() method, it returns nothing.

The Car class remains the same as before. However, the only change that you do is adding the instance fuel_cap, of class Descriptor(). Note that as mentioned before, the instance of descriptor class must be added to a class as a class attribute and not as an instance attribute.

As soon as you set the local variable fuel_cap in the __init__() method to instance fuel_cap it invokes the __set__() method of the Descriptor class.

Now let's change the fuel capacity to a negative value and see if the program raises a ValueError exception or not.

car2 = Car("BMW","X7",-40)
print(car2)
-40



----------------------------------------

ValueErrorTraceback (most recent call last)

<ipython-input-115-1c3d23be72f7> in <module>
----> 1 car2 = Car("BMW","X7",-40)
      2 print(car2)


<ipython-input-107-3e1f3e97d3c7> in __init__(self, make, model, fuel_cap)
      4         self.make = make
      5         self.model = model
----> 6         self.fuel_cap = fuel_cap
      7
      8     def __str__(self):


<ipython-input-106-0b252695aeed> in __set__(self, instance, value)
     11
     12         if value < 0:
---> 13             raise ValueError("Fuel Capacity can never be less than zero")
     14
     15         self.__fuel_cap = value


ValueError: Fuel Capacity can never be less than zero

If you remember here, the type-checking was unsuccessful when you later changed the attribute value to a negative number since the type-checking was applicable once as it was in the __init__() method. Let's update the fuel_cap value to a string value and find out if it results in an error.

car2.fuel_cap = -1
-1



----------------------------------------

ValueErrorTraceback (most recent call last)

<ipython-input-120-dea9dbe96ebe> in <module>
----> 1 car2.fuel_cap = -1


<ipython-input-106-0b252695aeed> in __set__(self, instance, value)
     11
     12         if value < 0:
---> 13             raise ValueError("Fuel Capacity can never be less than zero")
     14
     15         self.__fuel_cap = value


ValueError: Fuel Capacity can never be less than zero
car2.fuel_cap = "BMW"
----------------------------------------

TypeErrorTraceback (most recent call last)

<ipython-input-121-0b316a9872c6> in <module>
----> 1 car2.fuel_cap = "BMW"


<ipython-input-106-0b252695aeed> in __set__(self, instance, value)
      8             print(value)
      9         else:
---> 10             raise TypeError("Fuel Capacity can only be an integer")
     11
     12         if value < 0:


TypeError: Fuel Capacity can only be an integer

Perfect! So as you can see, it works when you update the fuel capacity attribute later on.

Well, there is a slight problem in Descriptors and which is that when you create a new instance or a second instance of the class, the previous instance value gets overridden. The reason is that Descriptors are linked to class and not the instance.

Let's understand this with the below example.

car3 = Car("BMW","X7",48) #created a new instance 'car3' with different values
48

When you print the instance car2, you will observe that the values will have been overridden by car3.

print(car2)
BMW model X7 with a fuel capacity of 48 ltr.

Conclusion

Congratulations on finishing the tutorial.

This tutorial was for those who are comfortable with Python and aspire to master the advanced level. As a good exercise, you might want to find out how you could possibly solve the instance overriding problem that was discussed in today's tutorial.

Please feel free to ask any questions related to this tutorial in the comments section below.

If you would like to learn more about Python, take DataCamp's complete Python Programming skill track.