Knowledge for the World

Using Python descriptors

Descriptors are used to manage access to an attribute. They can be used to protect an attribute from changes or automatically update the value of a dependent attribute, as we'll see in this guide.

1

A descriptor is simply a way for the developer to manage access to an attribute. Descriptors determine what happens when an attribute is referenced on a model. Descriptors give the developer the ability to manage access to three actions: get, set, and delete.

get

print(user.name)

set

user.name = "Adam Smith"

delete

del(user.name)

2

No, Python doesn't offer a way to privatize class variables. Why not?

"Python makes a lot of assumptions. One being that you're not an idiot." Internet

But there are some use cases for private variables - one being to avoid name clashes with subclasses. But also, there are occasions where a variable really shouldn't be made accessible to the user because it needs to be validated either upon being set or retrieved.

Keep in mind, these variables are in no way private. It is only a convention. When you prefix a variable with an underscore, it let's other developers know that this variable is "private" or "for internal use". So if you're using a class, and you're trying to set a "private" variable directly you might find that something breaks.

A single underscore prefix specifies "weak internal use". This is contrasted with a double underscore prefix which, when used, becomes mangled. This means that it becomes prefixed with the class name to avoid clashing with subclass variables.

Like this:

In [1]: class foo: __bar = None

In [2]: print(dir(foo))
['__class__',  ...,  '_foo__bar']

But for the sake of this guide, we're going to use single underscores for our private variables.

3

As an example we'll create a temperature class. The point of this class is to keep track of the user-specified temperature and display the output in either celsius or fahrenheit. So the user should be able to set either fahrenheit or celsius and retrieve the temperature in either fahrenheit or celsius.

The wrongest way.

class Temperature(object):
    def __init__(self, fahrenheit=32):
        self.fahrenheit = fahrenheit
        self.celsius = self.convert_to_celsius(fahrenheit)

    def convert_to_celsius(self, f):
        return (f - 32) * (5 / 9)

    def convert_to_fahrenheit(self, c):
        return (c * (9 / 5)) + 32

So we're setting the initial temperature to 32 degrees F upon instantiation. Then you can access the temperature in either F or C. But after that, if the user sets the temperature, it will not change the value of the other measurement.

Let's see this in action:

In [1]: from temperature import Temperature

In [2]: t = Temperature()

In [3]: t.celsius
Out[3]: 0.0

In [4]: t.fahrenheit
Out[4]: 32

In [5]: t.celsius = 100

In [6]: t.fahrenheit
Out[6]: 32

You can see that setting celsius to 100 has no impact on the fahrenheit value.

A way that works.

If you just want to make this work you can create a set method for the user.

class Temperature(object):
    def __init__(self, fahrenheit=32):
        self.fahrenheit = fahrenheit
        self.celsius = self.convert_to_celsius(fahrenheit)

    def convert_to_celsius(self, f):
        return (f - 32) * (5 / 9)

    def convert_to_fahrenheit(self, c):
        return (c * (9 / 5)) + 32

    def set_fahrenheit(self, value):
        self.fahrenheit = value
        self.celsius = self.convert_to_celsius(value)

    def set_celsius(self, value):
        self.fahrenheit = self.convert_to_fahrenheit(value)
        self.celsius = value

Now we have a set method that we want our user to use. Assuming that the user knows exactly what we expect of them, this can work.

In [1]: from temperature import Temperature

In [2]: t = Temperature()

In [3]: t.set_fahrenheit(212)

In [4]: t.fahrenheit
Out[4]: 212

In [5]: t.celsius
Out[5]: 100.0

So using our setter actually does what we'd like. But Python provides a way to keep the setters and getters hidden by way of descriptors with properties. I believe this is cleaner and more intuitive for the user. This method also introduces Python's conventional way of creating "private" variables.

class Temperature(object):
    def __init__(self, fahrenheit=32):
        self._fahrenheit = fahrenheit
        self._celsius = self.convert_to_celsius(fahrenheit)

    def convert_to_celsius(self, f):
        return (f - 32) * (5 / 9)

    def convert_to_fahrenheit(self, c):
        return (c * (9 / 5)) + 32

    def set_fahrenheit(self, value):
        self._fahrenheit = value
        self._celsius = self.convert_to_celsius(value)

    def set_celsius(self, value):
        self._fahrenheit = self.convert_to_fahrenheit(value)
        self._celsius = value

    def get_fahrenheit(self):
        return int(self._fahrenheit)

    def get_celsius(self):
        return int(self._celsius)

    fahrenheit = property(get_fahrenheit, set_fahrenheit)
    celsius = property(get_celsius, set_celsius)

Notice we have created two private variables on init: _fahrenheit and _celsius. And we have a getter and setter for each of these variables. Then at the bottom of the class we create the actual properties themselves passing in the getter and setter.

This gives us the functionality that makes the most sense.

In [1]: from temperature import Temperature

In [2]: t = Temperature()

In [3]: t.fahrenheit = 212

In [4]: t.celsius
Out[4]: 100

In [5]: t.celsius = 0

In [6]: t.fahrenheit
Out[6]: 32

In [7]: t.celsius = 200

In [8]: t.fahrenheit
Out[8]: 392

Of course, the user still has the ability to access your "private" variables, but this is where the "don't be an idiot" rule comes in to play.