Understanding Descriptors in Python

Definition

A class that defines any of __get__(), __set__(), or __delete__() is called a descriptor. If it only implements __get__() it is a non-data descriptor; if it implements both __get__() and __set__() it is a data descriptor.

Descriptors intercept attribute access on other classes.

Why use them?

Start with @property

@property is the go-to decorator when you need computed attributes:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
class Egg(object):
def __init__(self):
self.price = 10 # price in RMB

@property
def usd_price(self): # price in USD
return self.price / 6.8

@usd_price.setter
def usd_price(self, value):
self.price = value * 6.8

@property
def hkd_price(self): # price in HKD
return self.price / 0.87

@hkd_price.setter
def hkd_price(self, value):
self.price = value * 0.87

egg = Egg()

print("Current price: %s RMB, %s USD, %s HKD" % (egg.price, egg.usd_price, egg.hkd_price))
egg.usd_price = 2
print("Current price: %s RMB, %s USD, %s HKD" % (egg.price, egg.usd_price, egg.hkd_price))
egg.hkd_price = 15
print("Current price: %s RMB, %s USD, %s HKD" % (egg.price, egg.usd_price, egg.hkd_price))

Output:

1
2
3
Current price: 10 RMB, 1.4705882352941178 USD, 11.494252873563218 HKD
Current price: 13.6 RMB, 2.0 USD, 15.632183908045977 HKD
Current price: 13.05 RMB, 1.9191176470588236 USD, 15.000000000000002 HKD

The USD and HKD properties share almost identical logic. Adding more currencies would repeat the same pattern over and over.

Refactor with descriptors

Enter descriptors. First, define a reusable descriptor:

1
2
3
4
5
6
7
8
9
class Price(object):
def __init__(self, rate):
self.rate = rate

def __get__(self, instance, instype):
return instance.price / self.rate

def __set__(self, instance, value):
instance.price = value * self.rate

Then apply it to the Egg class:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
class Egg(object):
def __init__(self):
self.price = 10 # price in RMB

usd_price = Price(6.8)
hkd_price = Price(0.87)

egg = Egg()

print("Current price: %s RMB, %s USD, %s HKD" % (egg.price, egg.usd_price, egg.hkd_price))
egg.usd_price = 2
print("Current price: %s RMB, %s USD, %s HKD" % (egg.price, egg.usd_price, egg.hkd_price))
egg.hkd_price = 15
print("Current price: %s RMB, %s USD, %s HKD" % (egg.price, egg.usd_price, egg.hkd_price))

Instantiating the descriptor captures the logic in a single place and keeps the class definition clean. You can think of it as factoring the three @property methods into a reusable class.

Python features built on descriptors

@property

@property itself is implemented with a descriptor. A simplified pure-Python equivalent looks like this:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
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__)

Applying it:

1
2
3
4
5
6
7
8
9
10
11
class Egg(object):
def __init__(self):
self.price = 10 # price in RMB

@property
def usd_price(self):
return self.price / 6.8

@usd_price.setter
def usd_price(self, value):
self.price = value * 6.8

__getattribute__, __get__, and __getattr__

__getattribute__ runs whenever you access an attribute on a new-style class. It controls the lookup order.

__get__ is the descriptor protocol shown above.

__getattr__ fires only if the attribute was not found elsewhere.

By default the lookup order is: instance dictionary → data descriptors → class dictionary → non-data descriptors → __getattr__.