alpaca0984.log

Composition in Python

alpaca0984

I'm a big fan of Composition over inheritance. I mainly use Kotlin for my job and it has a built-in delegation mechanism which I like very much. Recently I have worked on a Python project with my friend. Python is very different from Kotlin.

Let's suppose that we create a simple program that counts down and prints a message when it's finished. I will look:

5
4
3
2
1
Boom!

But if it's in hurry, it counts down two steps:

5
3
1
Boom!

I know that it's pretty simple and we can do it with for-loop. But let's think in a bit of an object-oriented way. The main function will look like this. How should we design those counters?

def is_in_hurry() -> bool:
    # Mock implementation
    return True
 
def main():
    counter: Counter
    initial = current = 5
    if is_in_hurry():
        counter = StepTwoCounter(initial)
    else:
        counter = StepOneCounter(initial)
 
    while current > 0:
        print(current)
        current = counter.count_down()
 
    counter.alert("Boom!")
 
if __name__ == "__main__":
    main()

At first, I will define the interface of the Counter. This is how we do it in Python.

from abc import ABC, abstractmethod
 
 
class Counter(ABC):
 
    @abstractmethod
    def count_down(self) -> int:
        pass
 
    @abstractmethod
    def alert(self, message: str):
        pass

Python doesn't have interface keyword. It is a dynamically typed language that was popular with duck-typing. It introduced type-hinting at 3.5 but it still doesn't have the interface. Instead, we can define an abstract class that has only abstract methods. It is supported by ABC (Abstract Base Classes).

Let's define actual counters.

class StepOneCounter(Counter):
 
    __num: int
 
    def __init__(self, initial: int):
        self.__num = initial
 
    def alert(self, message: str):
        print(f"I am a StepOneCounter! {message}")
 
    def count_down(self) -> int:
        self.__num -= 1
        return self.__num
 
class StepTwoCounter(Counter):
 
    __num: int
 
    def __init__(self, initial: int):
        self.__num = initial
 
    def alert(self, message: str):
        print(f"I am a StepTwoCounter! {message}")
 
    def count_down(self) -> int:
        self.__num -= 2
        return self.__num

If you forget to override abstract methods, Python raises an error when the class is instantiated, not when the method is called. It's not as safe as compiled language but we can notice the error in the early stage.

Traceback (most recent call last):
  File "/Users/alpaca0984/count_down.py", line 61, in <module>
    main()
  File "/Users/alpaca0984/count_down.py", line 50, in main
    counter = StepTwoCounter(initial)
TypeError: Can't instantiate abstract class StepTwoCounter with abstract method count_down

Okay, good. Now the program works. But you realize that the alert() is duplicated in StepOneCounter and StepTwoCounter. It's not clean. But I won't move it into the Counter because, as I said in the first line, I believe in composition over inheritance. Then, what should I do?

In Python, we can create Mixin. It's not the language feature but a convention. We create a class that doesn't inherit anything and, just has portable functionalities. For this case, I will create AlertMixIn and include it in counters.

class AlertMixIn:
 
    def alert(self, message: str):
        print(f"Here is {type(self).__name__}! {message}")
 
class StepOneCounter(AlertMixIn, Counter):
 
    __num: int
 
    def __init__(self, initial: int):
        self.__num = initial
 
    def count_down(self) -> int:
        self.__num -= 1
        return self.__num
 
class StepTwoCounter(AlertMixIn, Counter):
 
    __num: int
 
    def __init__(self, initial: int):
        self.__num = initial
 
    def count_down(self) -> int:
        self.__num -= 2
        return self.__num

Of course, you can add any other mixins to those counters. Keep in mind that the order StepOneCounter(AlertMixIn, Counter) is very important. AlertMixIn overrides Counter and, StepOneCounter overrides AlertMixIn. So if you do StepOneCounter(Counter, AlertMixIn), you will see an error saying like "undefined method alert()" because the implementation in AlertMixIn is overwritten by the abstract function in the Counter.

Django also uses mixins and it's well described. It is really helpful to understand how mixins are supposed to be used.