Creating a New Sequence Type in Python - Part 3

Welcome to part 3 of our series on Python's special methods. In this post we'll be covering a feature called operator overloading, and how we can achieve this using Python's special methods. To do so we'll be continuing our journey to create our new sequence type (the LockableList), so if you haven't been keeping up with this series, be sure to check out part 1 and part 2.

A Quick Recap

While I recommend taking a look at our previous posts for a full breakdown of the code, we've so far created a class to describe our new LockableList sequence type. In the class we've defined a number of special or "dunder" methods, starting with __init__.

__init__ is called automatically when an instance of our class is created, and it's here that we take in some initial values, as well as some configuration regarding the lock state of our list.

We next have the __str__ and __repr__ methods, which return string representations of a given LockableList object, with __str__ providing user friendly output, while __repr__ is more aimed at developers using our code.

We also defined __getitem__ and __setitem__, which are responsible for retrieving, updating, and adding values in our sequence using either indexes or slice objects.

Finally we have defined two normal methods called lock and unlock which allow us to update the lock state of a given LockableList object. While locked, a LockableList cannot be mutated, and acts very much like a tuple.

Here is the complete class definition so far:

class LockableList:
    def __init__(self, *values, locked=False):
        self.values = list(values)
        self._locked = locked

    def __str__(self):
        return f"{self.values}"

    def __repr__(self):
        values = ", ".join([value.__repr__() for value in self.values])
        return f"LockableList({values})"

    def __len__(self):
        return len(self.values)

    def __getitem__(self, i):
        if isinstance(i, int):
            # Perform conversion to positive index if necessary
            if i < 0:
                i = len(self.values) + i

            # Check index lies within the valid range and return value if possible
            if i < 0 or i >= len(self.values):
                raise IndexError("LockableList index out of range")
            else:
                return self.values[i]
        elif isinstance(i, slice):
            start, stop, step = i.indices(len(self.values))
            rng = range(start, stop, step)
            return LockableList(*[self.values[index] for index in rng])
        else:
            invalid_type = type(i)
            raise TypeError(
                "LockableList indices must be integers or slices, not {}"
                .format(invalid_type.__name__)
            )
    
    def __setitem__(self, i, values):
        if self._locked:
            raise RuntimeError(
                "LockedList object does not support item assignment while locked"
            )
        
        if isinstance(i, int):
            # Perform conversion to positive index if necessary
            if i < 0:
                i = len(self.values) + i
            
            # Check index lies within the valid range and assign value if possible
            if i < 0 or i >= len(self.values):
                raise IndexError("LockableList index out of range")
            else:
                self.values[i] = values
        elif isinstance(i, slice):
            start, stop, step = i.indices(len(self.values))
            rng = range(start, stop, step)
            if step != 1:
                if len(rng) != len(values):
                    raise ValueError(
                        "attempt to assign a sequence of size {} to extended slice of size {}"
                        .format(len(values), len(rng))
                    )
                else:
                    for index, value in zip(rng, values):
                        self.values[index] = value
            else:
                self.values = self.values[:start] + values + self.values[stop:]
        else:
            invalid_type = type(i)
            raise TypeError(
                "LockableList indices must be integers or slices, not {}"
                .format(invalid_type.__name__)
            )

    def lock(self):
        self._locked = True

    def unlock(self):
        self._locked = False

What is Operator Overloading?

Operator overloading is a feature of many languages which allows us to define new behaviour for existing operators with regards to certain operand types. In Python, we accomplish this behaviour using special methods, and we've already touched on this behaviour previously. Note that we can access LockableList items by index using subscript notation.

l = LockableList(1, 2, 3)
print(l[2])  # 3

This is an example of operator overloading, as we we have redefined the behaviour of these square brackets when used in conjunction with a LockableList object.

We can define behaviour for just about any operator we can think of, with each corresponding to a generally sensibly named special method. The * operator corresponds to __mul__, for example, while + corresponds to __add__.

This isn't the end of the story, however. If you were to peruse the documentation, you'd also find __radd__ and __iadd__, so what's going on here?

When Python encounters a binary operator like +, Python first checks the left-hand operand for information on how to perform the operation for the relevant types. If no such information exists, Python tries the right hand operand instead, but this time looks for an r version of the relevant special method. We therefore have special methods like __radd__, __rmul__, and __rsub__.

i versions of the special methods represent an in-place operation. This is an operation that doesn't require an assignment.

a = 5
a = a + 1  # __add__

b = 5
b += 1  # __iadd__

These i versions of the special methods are very important for us, because while we want to implement in place modifications to our LockableList objects, we want to prevent such operations while a LockableList is locked.

__add__, __radd__, and __iadd__

Let's start off with __add__. First we have to think about what we want to happen when we try to concatenate LockableList objects, and indeed objects of other types. One important question is, which types will we exclude?

In my implementation, I'm going to follow the example of the list type, except I will accept lists and LockableList objects. The resulting type will always be a new LockableList object, and the new object will use the default lock state.

Ultimately these kinds of decisions are entirely up to you, so you can write your own classes to function differently to mine if you don't like my choices in this regard. You might want to only allow concatenation with other LockableList objects, or you might want to set the new object to locked if either of the operands were locked. You really can do whatever you want.

Implementing __add__

def __add__(self, other):
    if isinstance(other, (list, LockableList)):
        return LockableList(*(self.values + other))
    
    invalid_type = type(other)
    raise TypeError(
        'can only concatenate list or LockableList (not "{}") to LockableList'
        .format(invalid_type.__name__)
    )

Here we define __add__ with two parameters: self and other. These names are entirely dictated by convention, and you can actually name them whatever you like.

In our case, self as usual refers to the current LockableList object, while other refers to the right hand operand of the + operator. We have no idea what wacky things our users are going to try to concatenate to our LockableList objects, so we need to carry out some checks.

A simple if statement is enough here in conjunction with the isinstance built in function. If you don't know about isinstance, you can read more about it in the official documentation.

Essentially, isinstance will allow us to verify that a given object is of a certain type. We can specify a number of types using a tuple, and as you can see above, we specify list and LockableList. If other is an instance of list or LockableList, isinstance will return True.

In the event that we find a valid type, we can simply concatenate the values of other to the internal list we use for storage, and unpack the new list into a new LockableList object. There is a problem with this which we'll look at in a moment.

In the event that the user tries to concatenate an invalid type, we raise a TypeError. This is very similar to the errors we've used previously in this project.

With that, we can do some cool stuff:

numbers_one = LockableList(1, 2, 3)
numbers_two = [4, 5, 6]

print(numbers_one + numbers_two)  # [1, 2, 3, 4, 5, 6]

But as I mentioned, there is a problem:

numbers_one = LockableList(1, 2, 3)
numbers_two = LockableList(4, 5, 6)

print(numbers_one + numbers_two)  # TypeError

Remember that internally we use a list for storing values in our LockableList. Our return value for __add__ is self.values + other.

self.values is a list; other is a LockableList, so when we use +, we end up calling the list's __add__ method, and lists only allow concatenation with other lists. We therefore get a TypeError.

The solution is to implement __radd__, so that Python can fall back on the methods defined LockableList when the list's method raises an error.

Implementing __radd__

Our implementation of __radd__ is going to be near identical to __add__, but with the order of other and self.values reversed. This is to preserve the order of the values based on the order of the operands.

def __radd__(self, other):
    if isinstance(other, (list, LockableList)):
        return LockableList(*(other + self.values))
    
    invalid_type = type(other)
    raise TypeError(
        'can only concatenate list or LockableList (not "{}") to LockableList'
        .format(invalid_type.__name__)
    )

Now we can avoid the error we were getting before:

numbers_one = LockableList(1, 2, 3)
numbers_two = LockableList(4, 5, 6)

print(numbers_one + numbers_two)  # [1, 2, 3, 4, 5, 6]

Implementing __iadd__

Finally, let's take care of __iadd__.

__iadd__ is a little different, since we have to take note of our lock state, but the rest of our implementation will be fairly similar.

def __iadd__(self, other):
    if self._locked:
        raise RuntimeError(
            "LockedList object does not support in-place concatenation while locked"
        )

    if isinstance(other, (list, LockableList)):
        self.values = self.values + list(other)
        return self

    invalid_type = type(other)
    raise TypeError(
        'can only concatenate list or LockableList (not "{}") to LockableList'
        .format(invalid_type.__name__)
    )

In the case of __iadd__, we update the internal list directly, and then return a reference to the current object. This allows us to preserve the same object, while updating the values contained within the given LockableList.

numbers_one = LockableList(1, 2, 3)
numbers_two = LockableList(4, 5, 6)
numbers_one += numbers_two

print(numbers_one)  # [1, 2, 3, 4, 5, 6]

A Note about __iadd__

An interesting thing to note is that our class was already capable of handling the += augmented arithmetic operator before we implemented __iadd__. Try it out for yourself.

If this is the case, why did we bother implementing __iadd__ at all? __iadd__ is really an optimisation feature. In the absence of __iadd__ Python will fall back to the __add__ method, but our __add__ method creates a new LockableList object, and creating an object isn't free. By implementing __iadd__ we can bypass the creation of this new object, saving us a little bit of computing time.

The Completed Class

With that, our class definition is getting very large, but we've accomplished an awful lot:

class LockableList:
    def __init__(self, *values, locked=False):
        self.values = list(values)
        self._locked = locked

    def __str__(self):
        return f"{self.values}"

    def __repr__(self):
        values = ", ".join([value.__repr__() for value in self.values])
        return f"LockableList({values})"

    def __len__(self):
        return len(self.values)

    def __getitem__(self, i):
        if isinstance(i, int):
            # Perform conversion to positive index if necessary
            if i < 0:
                i = len(self.values) + i

            # Check index lies within the valid range and return value if possible
            if i < 0 or i >= len(self.values):
                raise IndexError("LockableList index out of range")
            else:
                return self.values[i]
        elif isinstance(i, slice):
            start, stop, step = i.indices(len(self.values))
            rng = range(start, stop, step)
            return LockableList(*[self.values[index] for index in rng])
        else:
            invalid_type = type(i)
            raise TypeError(
                "LockableList indices must be integers or slices, not {}"
                .format(invalid_type.__name__)
            )
    
    def __setitem__(self, i, values):
        if self._locked:
            raise RuntimeError(
                "LockedList object does not support item assignment while locked"
            )
        
        if isinstance(i, int):
            # Perform conversion to positive index if necessary
            if i < 0:
                i = len(self.values) + i
            
            # Check index lies within the valid range and assign value if possible
            if i < 0 or i >= len(self.values):
                raise IndexError("LockableList index out of range")
            else:
                self.values[i] = values
        elif isinstance(i, slice):
            start, stop, step = i.indices(len(self.values))
            rng = range(start, stop, step)
            if step != 1:
                if len(rng) != len(values):
                    raise ValueError(
                        "attempt to assign a sequence of size {} to extended slice of size {}"
                        .format(len(values), len(rng))
                    )
                else:
                    for index, value in zip(rng, values):
                        self.values[index] = value
            else:
                self.values = self.values[:start] + values + self.values[stop:]
        else:
            invalid_type = type(i)
            raise TypeError(
                "LockableList indices must be integers or slices, not {}"
                .format(invalid_type.__name__)
            )

    def __add__(self, other):
        if isinstance(other, (list, LockableList)):
            return LockableList(*(self.values + other))
        
        invalid_type = type(other)
        raise TypeError(
            'can only concatenate list or LockableList (not "{}") to LockableList'
            .format(invalid_type.__name__)
        )

    def __radd__(self, other):
        if isinstance(other, (list, LockableList)):
            return LockableList(*(other + self.values)) 
        
        invalid_type = type(other)
        raise TypeError(
            'can only concatenate list or LockableList (not "{}") to LockableList'
            .format(invalid_type.__name__)
        )

    def __iadd__(self, other):
        if self._locked:
            raise RuntimeError(
                "LockedList object does not support in-place concatenation while locked"
            )

        if isinstance(other, (list, LockableList)):
            self.values = self.values + other
            return self

        invalid_type = type(other)
        raise TypeError(
            'can only concatenate list or LockableList (not "{}") to LockableList'
            .format(invalid_type.__name__)
        )

    def lock(self):
        self._locked = True

    def unlock(self):
        self._locked = False

Where to Go From Here

If you've found this project interesting, I'd recommend trying to implement __mul__ for yourself, along with its variant methods. Of course you can go much further than this if you like: it's entirely up to you! You can find plenty of information about Python's special methods in the official documentation.

I hope you've learnt something about how Python's special methods work over the course of this short series, and I hope you find new and interesting ways to use them in your future projects.

If you're interested a more comprehensive look at object oriented programming in Python, you might want to check out our Complete Python Course! There's over 35 hours of material, so there's plenty to sink your teeth into.