Classes and Objects II: Descriptors

Descriptors are an esoteric but integral part of the python programming language. They are used widely in the core of the python language and a good grasp of descriptors provides a python programmer with an extra trick in his or her toolbox. To set the stage for the discussion of descriptors I describe some scenarios that a programmer may encounter in his or her daily programming activities; I then go ahead to explain what descriptors are and how they provide elegant solutions to these scenarios. For this writeup, I refer to python version using new style classes.

  1. Consider a program in which we need to enforce strict type checking for object attributes. Python is a dynamic languages and thus does not support type checking but this does not prevent us from implementing our own version of type checking regardless of how rudimentary it may be. The conventional way to go about type checking object attributes may take the form shown below:

    def __init__(self, name, age):
        if isinstance(str, name):
            self.name = name
        else:
            raise TypeError("Must be a string")
        if isinstance(int, age):
            self.age = age
        else:
            raise TypeError("Must be an int")
    

    The above method is one method of enforcing such type checking but as the arguments increase in number it gets cumbersome. Alternatively, we could create a type_check(type, val) function that is called in the __init__ method before assignment but then how would we easily implement such checking when we want to set the attribute value somewhere else. A quick solution that comes to mind is the getters and setters present in Java but that is un-pythonic and cumbersome.

  2. Consider a program in which we want to create attributes that are initialized once at run-time and then become read-only. One could also think of ways of implementing this using python special methods but once again such implementation would be unwieldy and cumbersome.

  3. Finally, imagine a program in which we wanted to somehow customize object attribute access. This maybe to log such attribute access for example. Once again, it is not too difficult to come up with a solution to this although such solution maybe unwieldy and not re-useable.

All the above mentioned issues are all linked together by the fact that they are all related to attribute references; we are trying to customize attribute access.

 Python Descriptors

Descriptors provides solutions to the above listed issues that are elegant, simple, robust and re-useable. Simply put, a descriptor is an object that represents the value of an attribute. This means that if an account object has an attribute name, a descriptor is another object that can be used to represent the value held by that attribute, name. A descriptor is any object that implements any of the __get__, __set__ or __delete__ special methods of the descriptor protocol. The signature for each of these methods is shown below:
“`python
descr.get(self, obj, type=None) –> value

descr.__set__(self, obj, value) --> None

descr.__delete__(self, obj) --> None
```

Objects implementing the __get__ method are non-data descriptors meaning they can only be read from after initialization while objects implementing the __get__ and __set__ are data descriptors which means such attribute are writable.

To get a better understanding of descriptors we provide descriptor based solution to the issues mentioned. Implementing type checking on an object attribute using python descriptors is then a very simple task. A decorator implementing this type checking is shown below:

class TypedProperty(object):

    def __init__(self, name, type, default=None):
        self.name = "_" + name
        self.type = type
        self.default = default if default else type()

    def __get__(self, instance, cls):
        return getattr(instance, self.name, self.default)

    def __set__(self,instance,value):
        if not isinstance(value,self.type):
            raise TypeError("Must be a %s" % self.type) 
        setattr(instance,self.name,value)

    def __delete__(self,instance):
        raise AttributeError("Can't delete attribute")


class Foo(object):
    name = TypedProperty("name",str) 
    num = TypedProperty("num",int,42)

>> acct = Foo()
>> acct.name = "obi"
>> acct.num = 1234
>> print acct.num
1234
>> print acct.name 
obi
# trying to assign a string to number fails
>> acct.num = '1234'
TypeError: Must be a <type 'int'>

In the example, we implement a descriptor, TypedProperty and this descriptor class enforces type checking for any attribute of a class which it is used to represent. It is important to note that descriptors can only be legally defined at the class level rather than instance level i.e. in __init__ method as shown in the example above.

When the any attribute of a Foo class instance is accessed, the descriptor calls its __get__ method. Notice that the first argument to the __get__ method is the object from which the attribute the descriptor represents is being referenced. When the attribute is assigned to, the descriptor calls its __set__ method. To understand why descriptors can be used to represent object attributes, we need to understand the way attribute reference resolution is carried out in python. For objects, the mechanics for attribute resolution is in object.__getattribute__(). This method transforms b.x into type(b).__dict__['x'].__get__(b, type(b)). The resolutions then searches for the attribute using a precedence chain that gives data descriptors found in class dict priority over instance variables, instance variables priority over non-data descriptors, and assigns lowest priority to getattr() if provided. This precedence chain can be overridden by defining custom __getattribute__ methods for a given object class.

With a firm understanding of the mechanics of descriptors, it is easy to imagine elegant solutions to the second and third issues raised in the previous section. Implementing a read only attribute with descriptors becomes a simple case of implementing a data descriptor i.e descriptor with no __set__ method`. The issue of customizing access, though trivial in this instance, would just involve adding the required functionality in the __get__ and __set__ methods.

 Class Properties

Having to define descriptor classes each time we want to use them is cumbersome. Python properties provide a concise way of adding data descriptors to attributes in python. A property signature is given below:

property(fget=None, fset=None, fdel=None, doc=None) -> property attribute

fget, fset and fdel are the getter, setter and deleter methods for such class. We illustrate creating properties with an example below:


class Accout(object):
    def __init__(self):
        self._acct_num = None

    def get_acct_num(self):
        return self._acct_num

    def set_acct_num(self, value):
        self._acct_num = value

    def del_acct_num(self):
        del self._acct_num

    acct_num = property(get_acct_num, set_acct_num, del_acct_num, "Account number property.")

If acct is an instance of Account, acct.acct_num will invoke the getter, acct.acct_num = value will invoke the setter and del acct_num.acct_num will invoke the deleter.

The property object and functionality can be implemented in python as illustrated in Descriptor How-To Guide using the descriptor protocol as shown below :


class Property(object):
    "Emulate PyProperty_Type() in Objects/descrobject.c"

    def __init__(self, fget=None, fset=None, fdel=None, doc=None):
        self.fget = fget
        self.fset = fset
        self.fdel = fdel
        if doc is None and fget is not None:
            doc = fget.__doc__
        self.__doc__ = doc

    def __get__(self, obj, objtype=None):
        if obj is None:
            return self
        if self.fget is None:
            raise AttributeError("unreadable attribute")
        return self.fget(obj)

    def __set__(self, obj, value):
        if self.fset is None:
            raise AttributeError("can't set attribute")
        self.fset(obj, value)

    def __delete__(self, obj):
        if self.fdel is None:
            raise AttributeError("can't delete attribute")
        self.fdel(obj)

    def getter(self, fget):
        return type(self)(fget, self.fset, self.fdel, self.__doc__)

    def setter(self, fset):
        return type(self)(self.fget, fset, self.fdel, self.__doc__)

    def deleter(self, fdel):
        return type(self)(self.fget, self.fset, fdel, self.__doc__)

Python also provides the @property decorator that can be used to create read only attributes. A property object has getter, setter, and deleter decorator methods that can be used to create a copy of the property with the corresponding accessor function set to the decorated function. This is best explained with an example:

class C(object):
    def __init__(self):
        self._x = None

    @property
     # the x property. the decorator creates a read-only property
    def x(self):
        return self._x

    @x.setter
    # the x property setter makes the property writeable
    def x(self, value):
        self._x = value

    @x.deleter
    def x(self):
        del self._x

If we wanted to make the property read-only then we would leave out the setter method.

Descriptors see wide application in the python language itself. Python functions, class methods, static methods are all examples of non-data descriptors. Descriptor How-To Guide provides a basic description of how the listed python objects are implemented using descriptors.

Further Reading

  1. Descriptor How-To Guide

  2. Python Essential Reference 4th Edition; David Beazley

  3. Caching in python with a descriptor and a decorator

  4. Inside story on new style classes

 
694
Kudos
 
694
Kudos

Now read this

The Function II: Python Function Decorators

Function decorators enable the addition of new functionality to a function without altering the function’s original functionality. Prior to reading this post, it is important that you have read and understood the first installment on... Continue →