Programming for fun and profit

A blog about software engineering, programming languages and technical tinkering

Thu 05 April 2018

Properties as Pythonic getters

Posted by Simon Larsén in Programming   

If you come from either Java or C++, you've probably written your fair share of getter and setter (also called accessor and mutator) methods. It is common for programmers that transition from such a language to Python to carry over this practice. In many cases in Python, we simply forego the abstraction and access the attributes directly. Sometimes, however, getters and setters are useful for providing write-protection and input validation. In this two-part series, we are going to explore how to make Pythonic setters and getters using one of my favorite Python features: properties.

Part 1 (this part): Properties as Pythonic getters

In this first part, we take a look at how to use a property to implement a read-only data attribute that can be accessed just like any other data attribute (e.g. like obj.attr). Writing to it will, however, result in an AttributeError. This is useful for preventing users from accidentally changing the internal state of an object in an unintended way, while still providing a uniform API. For example, we might want a way to access the root element of a binary tree, but without risking to alter its container.

Part 2: Properties as Pythonic setters

In the second part, we'll have a look at how we can use properties to also implement a setter method, with input validation, that can be utulized just like any plain ol' data attribute (e.g. like obj.attr = 42). This is useful when the attribute has some legal set of values.

The Ticker class

For the purpose of learning properties, we will develop a fairly useless class called Ticker. All it does is tick from 0 to some boundary, and then restart from 0. Two Ticker instances could, for example, represent a rudimentary clock with hour and minute counts. The first version of Ticker is outlined below.

class Ticker:
    """A Ticker ticks from 0 to an upper limit, and then starts over."""

    def __init__(self, end: int): # ': int' is an optional type hint
        """Create a Ticker that starts over at end"""
        if end <= 0:
            raise ValueError("end must be greater than 0!")
        self._end = end
        self.count = 0

    def tick(self):
        """Increment the internal count by 1."""
        self.count = (self.count + 1) % self._end    

We can use this class something like this:

>>> t = Ticker(5)
>>> t.count
0
>>> t.tick()
>>> t.tick()
>>> t.count
2
>>> for _ in range(3):
...     t.tick()
>>> t.count
0
>>> t.count = 42    # uh oh...
>>> t.count
42                  # this is an illegal state
>>> t.tick()        # back to a legal state in the next tick
>>> t.count
3

As long as the count variable is only read from, there are no issues with this design. Unfortunately, directly assigning to count may put the Ticker in an illegal state, i.e. such that count is outside of its expected range of [0, _end). This isn't so much an issue for the Ticker itself, as it is returned to a legal state on the next tick. Other functionality depending on the Ticker to keep within the [0, _end) range could however be in for a nasty surprise, meaning that there is a serious usability issue here.

Thus to the crux:

How do we protect the count variable from being put in an illegal state, while still allowing access to it?

Solving the problem

First of all, we should make the count variable private (which in Python equates to prepending an underscore). The issue that remains to be resolved is how to expose _count in the public API of the class.

Solution 1: A Java-style getter

A Java or C++ programmer might instinctevly think of a traditional getter method.

def get_count(self):
    """Return the current count."""
    return self._count

This solution has two issues: it breaks the api, and it makes us think about count as something more complicated than the mere data attribute that it is. It would be much preferable if we could access _count just like we accessed it before it was made private (i.e. with t.count), but at the same time provide write protection (such that t.count = 42 raises an error). Enter the property.

Solution 2: Using a property as a read-only data attribute

Implementing the same functionality as get_count() with a property is dead simple.

@property
def count(self):
    """Return the current count."""
    return self._count

We use the @property decorator to say that the count method is a property. This will let us invoke the count method without providing the parens, so it will look like we are just accessing a data attribute named count. Usage now looks like below:

>>> t = Ticker(5)
>>> t.count
0
>>> t.tick()
>>> t.tick()
>>> t.count
2
>>> for _ in range(3):
...     t.tick()
>>> t.count
0
>>> t.count = 42
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
AttributeError: can't set attribute
can't set attribute

Excellent! We have the exact same API as when count was a public attribute, but without the risk of accidental overwriting. This is precisely what we wanted, and a Pythonic way of dealing with the issue of providing read access to fragile state variables.

Ticker full listing

It always annoys me when I get to the conclusion of some tutorial, and the end result is just assumed to be obvious. Therefore, here is the full listing of Ticker with a property as a getter.

class Ticker:
    """A Ticker ticks from 0 to an upper limit, and then starts over."""

    def __init__(self, end: int):
        """Create a Ticker that starts over at end"""
        if end <= 0:
            raise ValueError("end must be greater than 0!")
        self._end = end
        self._count = 0

    def tick(self):
        """Increment the internal count by 1."""
        self._count = (self._count + 1) % self._end    

    @property
    def count(self):
        """Return the current count."""
        return self._count

Now is about the time to move on to Part 2, in which we expand on the count property to allow us to set the internal count, but only within the range [0, _end)!