Programmer's Python - Custom Attributes
Written by Mike James   
Monday, 21 October 2019
Article Index
Programmer's Python - Custom Attributes
Custom Attribute Access

We  have already looked at the idea of a get/set property and often this is sufficient for more sophisticated attribute access. However, Python also provides you with the ability to take control of how attributes are accessed – the descriptor. 

Programmer's Python
Everything is an Object

Is now available as a print book: Amazon

pythoncover


Contents

  1. Hello Python World
  2. Variables, Objects and Attributes
  3. The Function Object
      Extract - Function Objects
  4. Scope, Lifetime and Closure
      Extract - Local and Global
  5. Advanced Functions
      Extract -  Decorators 
  6. Class Methods and Constructors
  7. Inside Class
  8. Metaclass
  9. Advanced Attributes
      Extract - Properties
  10. Custom Attribute Access
      Extract -  Custom Attributes
      Extract -  Default Methods ***NEW
  11. Single Inheritance
  12. Multiple Inheritance
  13. Class and Type
      Extract - Class
  14. Type Annotation
      Extract - Type Annotation 
  15. More Magic - Operator Overloading

 

Advanced Attributes

In this first extract we look at custom get.

The Descriptor Object

All attributes are objects and a descriptor object creates a special type of attribute.

A descriptor object has one of __get__(), __set__() or __delete__() defined. The idea is that these functions control how the object’s “value” is determined when it is accessed as an attribute of another object.

For example, suppose obj has an attribute x. When you write obj.x then what usually happens is that x is looked up in obj.__dict__[‘x’] and if it isn’t found then it is looked for in obj’s class and so on through all the inherited classes. Notice that the search doesn’t include the metaclass.

Now suppose that the object has been found, i.e. we know what x is. If the object that x references has any of __get__, __set__ or __delete__ defined then they will be invoked in place of the usual attribute access.

First consider what happens when an attribute is not a descriptor just an object:

class MyDescriptor:
    pass
class MyClass:
    myAttr=MyDescriptor()
myObj=MyClass()
print(myObj.myAttr)

In this case myAttr is a reference to an instance of MyDescriptor and myObj.myAttr is equally a reference to the instance.

Now consider what happens when the attribute is a descriptor:

class MyDescriptor:
    def __get__(self,obj,type):
        print(self)
        print(obj)
        print(type)
        return "Descriptor get"

The __get__ has three parameters with the final parameter defaulting to None. You can see that all it does is print the parameters and returns a string.

Now when you try:

class MyClass:
    myAttr=MyDescriptor()
myObj=MyClass()
print(myObj.myAttr)

You will see:

<__main__.MyDescriptor object at 0x054741B0>
<__main__.MyClass object at 0x054741F0>
<class '__main__.MyClass'>
Descriptor get

That is, self is set to the instance of the descriptor object – i.e. the object referenced by myAttr in this case, obj is set to the instance object of the class i.e. myObj in this case, and type is set to the class object that created the instance i.e. MyClass.

You can see that the overall effect of defining __get__ is to change the attribute access from a reference to a MyDescriptor instance object to the result of calling __get__.

If you followed this example you should now have a clear idea what the descriptor object does. When you define an attribute that is a reference to a descriptor object, access is overridden by __get__, __set__ or __delete__ as defined on the descriptor object.

The three descriptor functions are:

descr.__get__(self, obj, type=None) --> value
descr.__set__(self, obj, value) --> None
descr.__delete__(self, obj) --> None

The parameters are the same as described for __get__ and value is the value to set to the attribute i.e. the value involved in the assignment.

There is a distinction between data and non-data descriptors.

If a descriptor defines __get__ and __set__ then it is a data descriptor; if only __get__ is defined it is a non-data descriptor. Clearly a data descriptor corresponds to something you retrieve and assign to, and a non-data descriptor is something like a method that you only retrieve and use.

There is an important difference between the two – data descriptors cannot be overridden by a definition in the instance, whereas non-data descriptors can be. The reason should be fairly obvious. A data descriptor has __set__ defined and this is called if you try to assign to it. A non-data descriptor doesn’t have a __set__ defined so you can assign to it without interference.

For example, the descriptor MyDescriptor described earlier has only a __get__ method and so it is a non-data descriptor. So we can override it:

class MyClass:
    myAttr=MyDescriptor()
myObj=MyClass()
print(myObj.myAttr)
obj.myAttr=42
print(myObj.myAttr)

The first time myAttr is accessed the __get__ is used, but the second time the assignment has created an instance version of the attribute and so all you see is 42 printed.

On the other hand, if you define a set method as well:

class MyDescriptor:
    def __get__(self,obj,type):
        print(self)
        print(obj)
        print(type)
        return "Descriptor get"
    def __set__(self, obj, value):
        print("set:",value)

then assigning 42 to myAttr doesn’t override the descriptor by an instance attribute, instead it results in a call to __set__ and set:42 is printed.

This non-overridability of a data descriptor is a logical consequence of having a __set__ defined that is called when the attribute is assigned to.

How can we create a read-only data descriptor?

Easy; simply define __set__ so that it doesn’t change anything or, more usually, throw an exception when __set__ is called.

It is also worth pointing out that the descriptor __get__ mechanism works when you access the attribute via the class but you can override even a data descriptor by assigning to the class attribute.

That is, after:

MyClass.myAttr=42

the descriptor object is replaced by 42 in the class and in any instances created after this. The reason for this behavior will become clear in later in the chapter.

Descriptors are used within Python to implement properties a data descriptor – see Chapter 9 – and decorators to create methods, class methods and static methods – all non-data descriptors.



Last Updated ( Thursday, 23 April 2020 )