Classes in Python - An introduction to OOP

Modules - Basics

Last edited: February 17th 2021


Object oriented programming(OOP) is an important paradigm of programming. Many of the most popular programming languages support object oriented programming, including Python. As the name suggests, OOP is oriented around the concept of "objects"; an object is some piece of data, which may be structured in many ways.

Exactly when OOP was invented is a matter of definition. Already in the late 50s some of the concepts that make up OOP was being discussed. However the development of the Norwegian based programming language Simula is widely regarded as one of the most important work towards what is now OOP. As OOP became more and more available as it was supported by widely used languages it soon became one of the leading programming paradigms, and remains one of the most important innovations in computer programming to this day.

In this notebook we will provide an introduction to working with classes in Python. The guide should be simple to follow, whether you have experience with object oriented programming or not. We will throughout the notebook build classes of increasing complexity, and explore some of the features and mechanisms we may leverage.

A quick note about naming conventions: You will see that we throughout this notebook will capitalize class names, such as class Person. Instances of classes will be in lower case, as in peter = Person(). This is a common naming convention that you will encounter in most languages.

The basics

The reader might have heard of the words 'class' and 'instance' in the context of object oriented programming before. In a class-based paradigm, one creates 'classes' which represent some concept, be it an abstract idea or a specific data structure. The programmer may then create 'instances', realizations, of that class. Plato's allegory of the cave, would not be unfitting. The class may be though of as a recipe, while the instance is the actual data that has been allocated in the computer's memory. What all this means will hopefully become clearer when reading the code examples below.

In [3]:
class Person:
    # An attribute
    is_monkey = False


# Create an instance of the class Person, stored in the variable `peter`.
peter = Person()
print("Is Peter a monkey?")
print(" The answer is:", peter.is_monkey)
Is Peter a monkey?
 The answer is: False

We have created a class Person; it has one attribute, or property, which is is_monkey. We then create an instance of the class, with the line peter = Person(). An instance is a 'realization' of the conceptual object represented by the class Person. Let us create a new person, Lisa.

In [4]:
lisa = Person()
print("Is Lisa a monkey?")
print(" The answer is:", lisa.is_monkey)
Is Lisa a monkey?
 The answer is: False

Lisa is another instance of the same class. Like all people, Lisa is also not a monkey; the attribute is_monkey, together with its value, is common for all instances of the class.

We would, however, like to have a little more personality to our Person class. We do this by adding a very special method, __init__, known as the initializer (a method is the word we use about a function that belongs to a class).

In [5]:
# We rewrite our class, making it more personal.
class Person:
    # An attribute
    is_monkey = False

    # The initializer
    # We set the name and age of the person.
    def __init__(self, name, age):
        self.name = name
        self.age = age


def print_person(person):
    """Print information about person"""
    print(f"This person is called {person.name}, and is {person.age} years old.")


peter = Person("Peter", 23)
lisa = Person("Lisa", 56)

# Print information about peter and lisa.
print_person(peter)
print_person(lisa)
This person is called Peter, and is 23 years old.
This person is called Lisa, and is 56 years old.

Much happened in this last code cell, and we will break it down in more detail.

Firstly, our class got a new method, __init__. This is the initializer, used when we create instances of our classes to endow them with individual information not common to all instances, unlike is_monkey. We use this initializer to set the name and age of our people. Inside the initializer we refer to some object self. This is the instance that we are initializing. So to set the name of the instance, we write self.name = name. Failing to include this line, simply having an empty initializer

def __init__(self, name, age):
    pass

would not set the name and age of our Person. The arguments of the initializer, in this case name and age, are passed to the constructor when creating a new instance, as in the line peter = Person("Peter", 23). We may also change the values of attributes by accessing them directly, as in

peter = Person("Peter", 23)
peter.age += 1  # Add one to Peter's age.

The importance of class vs instance variables

In our above introduction, we have been somewhat sloppy in our treatment of member variables. We will here point out an important distinction. The first way to set an attribute that we saw, where it is defined directly in the class, outside any initializer, is called a class variable. The data we set inside the initializer, are instance variables. In short, instance variables are for data unique to each instance and class variables are for attributes shared by all instances of the class. The important consequence of which, will be demonstrated here by example.

Firstly, we remind ourselves of how Python assign and copy variables. We create a list lst. We then create a new list, which we set equal to the original list, lst2 = lst. When altering the original list, however, lst2 is also altered! This is because we did not copy the original list, we simply created a new variable that points to the same memory.

In [3]:
lst = [1, 2]
lst2 = lst  # NB. This simply assign lst to the name lst2.
lst.append(10)  # Altering lst, will also alter lst2, since they are the same list.
print(lst, lst2)
[1, 2, 10] [1, 2, 10]

If you are confused by the above example, we advice you to read our appendix on assignment and copying in Python.

We will now demonstrate how this affects class and instance variables. In the following example, we create two instances of a class that has both class and instance variables. When we change the values of one instance, the class variable of the other instance is also affected!

In [28]:
class MyClass:
    # A class variable.
    class_list = []

    def __init__(self):
        # An instance variable.
        self.instance_list = []
        
def print_instance(name, instance):
    print(name, f"{instance.class_list}, {instance.instance_list}")
    
instance1 = MyClass()
instance2 = MyClass()
print_instance("instance1:", instance1)
print_instance("instance2:", instance2)

# Alter the lists in instance1
instance1.class_list.append(314159265)
instance1.instance_list.append(27182818)

print(f'{" After altering instance1 ":#^30}')
print_instance("instance1:", instance1)
print_instance("instance2:", instance2)
instance1: [], []
instance2: [], []
## After altering instance1 ##
instance1: [314159265], [27182818]
instance2: [314159265], []

More information about this can be found in the official documentation's page on class and instance variables.

Functions as members - methods

Classes can have more than just attributes, they may also have methods. The initializer __init__ is an example of this, however it is quite special. We may create our own custom methods to call upon when we wish, using the same dot-notation used to get attributes. As you have probably noticed already, the syntax for methods include an argument self as the first argument. This is nothing more than a convention, you may name the first argument anything you like, but it is highly recommended to stick with this convention; you must, however, include at least one argument. When writing something like my_instance.my_method(), where we did not explicitly pass any arguments to the method, it still receives one argument, which is the instance. As with the initializer, this first argument, self, is used by the method to either retrieve data from or write data to the instance. We now write a method introduce to our Person class, instead of using the external function print_person.

In [6]:
class Person:
    # An attribute
    is_monkey = False

    # The initializer
    def __init__(self, name, age):
        self.name = name
        self.age = age

    # A method
    def introduce(self):
        print(f"Hello, I am {self.name}. I am {self.age} years old.")


peter = Person("Peter", 23)
peter.introduce()
Hello, I am Peter. I am 23 years old.

Just like normal functions, methods may also take arguments.

In [ ]:
class Person:
    # An attribute
    is_monkey = False

    # The initializer
    def __init__(self, name, age):
        self.name = name
        self.age = age

    # A method
    def introduce(self):
        print(f"Hello, I am {self.name}. I am {self.age} years old.")

    # A method taking an argument
    def set_balance(self, amount):
        """Sets the balance of the Person"""
        self.balance = amount


peter = Person("Peter", 23)
peter.set_balance(31415)
peter.introduce()
print(f"Peter has a balance of {peter.balance}.")

A quick note about access specifiers

Those familiar with languages such as C++ and Java are used to work with the concept of access limitation and access specifiers such as public and private. This is much less used in Python, and you will generally not seen it used. It is possible to get functionality similar to that of private members, by giving the variable a name prefixed by two underscores, __my_variable.

class DemonstrateAccess:
    __name = "My name"

inst = DemonstrateAccess()
inst.__name  # Causes exception

If the reason that you wish to have this functionality is to achieve getters and setters that may for example impose some logic on the argument of the setter, you are probably better off looking at Python's property decorator.

The __str__ method

Another special method we should mention is the __str__ method. Consider first the following code:

In [53]:
# Demonstrate the __str__ method.
import numpy as np

np_array = np.array([3, 4])
my_dict = {"key1": "value1", "key2": 2}

print(np_array)
print(my_dict)
print(peter)
[3 4]
{'key1': 'value1', 'key2': 2}
Person Peter

In the code above we created a NumPy array and a normal Python dictionary. When printing the three objects np_array, my_dict, and peter we notice that the former two are printed in an understandable manner, while the latter is printed as some obscure reference to a location in the computer's memory. Both NumPy's arrays and dictionaries are in fact also classes, so why do they appear so much nicer than our class? The answer is the __str__ method of classes; like __init__ it is a special method reserved for a specific purpose. When passing any object to print, it needs to know how to print it. For anything except strings, that is not obvious. The __str__ method returns a string which is supposed to be a human readable description of the object, for example [3 4] in the case of the NumPy array. Let us implement such a method for our class.

In [1]:
class Person:
    # An attribute
    is_monkey = False

    # The initializer
    def __init__(self, name, age):
        self.name = name
        self.age = age

    # The string representation of the class
    def __str__(self):
        return f"{self.name}({self.age})"

    # A method
    def introduce(self):
        print(f"Hello, I am {self.name}. I am {self.age} years old.")

    # A method taking an argument
    def set_balance(self, amount):
        """Sets the balance of the Person"""
        self.balance = amount


peter = Person("Peter", 23)
print(peter)
Peter(23)

One more special method, __call__

There is one more special method that deserves to be mentioned, although it is more of a convenience function rather than strictly necessary. The __call__ method lets us treat instances as functions, and "call" them. The main advantage of this is clearer and more intuitive code. It is best demonstrated by an example, which we have written out below.

In [3]:
class Clipper:
    """A utility class for clipping the elements of list so that they fall within some interval."""

    def __init__(self, low, high):
        self.low = low
        self.high = high

    def apply(self, lst):
        """Clip lst so that all elements fall within [low, high]."""
        new_lst = []
        for element in lst:
            # min(max(element, a), b) is a common way to get
            # element if it is within [a, b], else get the limit a or b.
            new_lst.append(min(max(element, self.low), self.high))
        return new_lst

    def __call__(self, lst):
        return self.apply(lst)


my_clipper = Clipper(0, 256)
some_data = [-1, 230, 100, 284, 300, 1e5, -10, 4.4]

# The __call__ method allows us to omit explicitly calling apply.
print(my_clipper.apply(some_data))
print(my_clipper(some_data))
[0, 230, 100, 256, 256, 256, 0, 4.4]
[0, 230, 100, 256, 256, 256, 0, 4.4]

In the code above we have created a utility for clipping the values of a list within some limits. Such a utility could for example be used as a naive solution to filter noisy data. The object has a method apply that actually performs the filtering. However, we have also defined __call__, which simply returns the result of apply. The advantage of this is that given an instance of the clipper, for example my_clipper, we may write my_clipper(some_list) instead of my_clipper.apply(some_list). This may seem trivial, but it does offer much simpler code, allows for writing more modular code, and when you start to look for it you will discover that it is widely used, for example in frameworks like Keras and Django.

Our notebook on image filtering using Fourier transformation displays a possible usage of the __call__ method.

When should you use object oriented programming for numerical applications?

Now that we have introduced the fundamentals of object oriented programming, we will briefly discuss some dos and don'ts when using OOP for numerical applications. There are two main pitfalls of OOP when doing numerical work:

  1. Writing too complicated data structures, generating unnecessary complexity in both the code and execution.
  2. Being unable to utilize NumPy, thus having reduced performance.

Despite this, OOP can also be very applicable in the field of numerics. The simple remedy for avoiding the pitfalls mentioned above, is to think about what will be done to the data you are considering to put in a class. If you are simulating thousands of particles, representing each particle as a class is probably not a good idea, as iteration over particles and time will have to be done by for-loops instead of NumPy-operations, giving a massive reduction in performance. However, as is demonstrated in our notebook on machine learning, OOP can be very efficient in for example ordering complex data structures, while still utilizing NumPy internally for demanding computations.

Inheritance

We have now covered most of the basics of classes, and are ready to see one of the most important features of object oriented programming: inheritance. One of the merits of object oriented programming is that it compels us to write modular and reusable code, by facilitating for common design patterns. Inheritance is a central part of this. It lets us group similar objects together, making it easy to reuse code and provides framework for orderering our thoughts. In Python, we declare inheritance by

class Parent:
    ...

class Child(Parent):
    ...

The code above creates some class Parent. Then, we create a new class Child, which is a subclass of Parent. This both indicates some logical connection between the two classes, and, importantly, lets us use the code from Parent directly in Child.

Let us see it in action.

In [2]:
class Person:
    def __init__(self, name, age):
        self.name = name
        self.age = age

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

    def introduce(self):
        print(f"Hello, I am {self.name}. I am {self.age} years old.")


# Student is a subclass of Person.
class Student(Person):
    def introduce(self):
        super().introduce()  # First, run Person's introduce.
        print(f"I am a student.")


peter = Student("Peter", 23)
lisa = Person("Lisa", 56)

peter.introduce()
lisa.introduce()
Hello, I am Peter. I am 23 years old.
I am a student.
Hello, I am Lisa. I am 56 years old.

Wow! As all students are also people, we were able to utilize some of the code written for Person in our class Student, only adding new code where needed. As the complexity of the code increases, this functionality will only become more powerful.

Notice that we called a function super in Student's introduce. When overriding a method in a child class, as is the case with introduce, we might want to also run the parent's method. super returns the parent object of the current instance, allowing us to access the parent's methods, in this case the original introduce. If we had omitted the line with super, calling introduce from a student, such as Peter, would only print "I am a student." Whether we wish the method from the parent to also be executed or not, depends on the specific case; we must therefore explicitly call the parent's method when overriding methods. For methods that are not overridden, we do not have to to this explicitly, it is done automatically, as for __str__ and __init__.

A more involved example

We will now combine what we have learned in a more involved example. One thing to notice is that the member variables of one class, may very well be another class.

In [14]:
class Car:
    def __str__(self):
        return "Generic car"


class BMW(Car):
    def __str__(self):
        return "BMW"


class Honda(Car):
    def __str__(self):
        return "Honda"


class Person:
    def __init__(self, name, age):
        self.name = name
        self.age = age
        self.car = None

    def __str__(self):
        return (
            f"{self.name}({self.age}){f', whose car is {self.car}' if self.car else ''}"
        )

    def buy_car(self, car):
        self.car = car


class Student(Person):
    def __init__(self, name, age, school):
        # Firstly, call the initializer of Person
        super().__init__(name, age)
        self.school = school
        self.username = school + "_" + name  # Generate a username.

    def __str__(self):
        return super().__str__() + f" ({ self.username.lower() })"


## ========================================= ##

knut = Person("Knut", 10)
lisa = Person("Lisa", 56)
peter = Student("Peter", 23, "NTNU")
accord = Honda()
i3 = BMW()

peter.buy_car(accord)
lisa.buy_car(i3)

print(knut)
print(lisa)
print(peter)
Knut(10)
Lisa(56), whose car is BMW
Peter(23), whose car is Honda (ntnu_peter)

Read through the example above, and make sure you understand why we get the output that we do. Specifically, notice that Student's initializer now is slightly more involved. Until now, initializers have simply set the values of the instance directly from the arguments.

def __init__(self, arg1, arg2):
    self.arg1 = arg1
    self.arg2 = arg2

Now, however, we set the attribute username, which is a function of the arguments. In general

def __init__(self, arg1, arg2):
    self.my_var = some_function(arg1, arg2)

Overriding operators

Why is it that when adding Python lists, they are appended, while when adding NumPy arrays they are added element-wise?

[1, 2] + [3, 4]                      # -> [1, 2, 3, 4]
np.array([1, 2]) + np.array([3, 4])  # -> [4, 6]

Both lists and NumPy arrays are objects, and Python cannot implicitly know how to add two objects in general. We must therefore explicitly define how objects are supposed to be added, and Python lists are added by returning a new list which consists of the two lists' elements, while NumPy arrays are added by returning an array where each element is the element-wise sum over the two initial arrays. We may define how to add elements also for our own classes, in fact we may override most of the default operators such as +, -, *, .... This is known as operator overriding.

In the following example we will create a class Vector for which we will define operations such as addition, subtraction, and multiplication. For a more in-depth treatment of this, see the Python documentation on data models under 'Emulating numeric types', here. The operands we will consider here are either binary or unary, meaning that they either take one or two values. For example, the binary operator - takes two values as in a - b, while the unary operator - takes one value as in -b. The syntax for a binary operator is

def __oper__(self, other):
    ...

while a unary operator will obviously take no other argument

def __oper__(self):
    ...

Take also note of the fact that the return type varies between operators. The dot product, here implemented as the __mul__ method returns a float, while addition, __add__, return a new Vector.

In [63]:
class Vector:
    """A 2D vector with real values."""

    def __init__(self, x, y):
        assert all(np.isreal([x, y])), "Values must be real!"
        self.x = x
        self.y = y

    def __add__(self, vector):
        """Vector addition"""
        return Vector(self.x + vector.x, self.y + vector.y)

    def __sub__(self, vector):
        """Vector subtraction"""
        return self + (-vector)

    def __neg__(self):
        """Negative of vector"""
        return Vector(-self.x, -self.y)

    def __mul__(self, vector):
        """Dot product"""
        return self.x * vector.x + self.y * vector.y

    def __abs__(self):
        """Absolute value"""
        return np.sqrt(self.x ** 2 + self.y ** 2)

    def __str__(self):
        return f"Vector({self.x}, {self.y})"


# Create two vectors a and b.
# Then, find their sum (c) and the vector d which goes from b to a.
a = Vector(1, 2)
b = Vector(5, -2)
c = a + b  # This is equivalent to c = a.__add__(b).
d = a - b

print("c\t:", c)
print("d\t:", d)
print("a * b\t:", a * b)
print("|a|\t:", abs(a))
c	: Vector(6, 0)
d	: Vector(-4, 4)
a * b	: 1
|a|	: 2.23606797749979

Our notebook on image filtering using Fourier transformation displays how operator overriding may be used.

Some final remarks

We hope that you find OOP a useful utility in numerical work, and programming in general. OOP is not the be-all and end-all solution to every problem, and it may very well cause unnecessary complication in certain applications. However, when used correctly and appropriately it is a powerful tool, and certainly one that everyone going into professional programming and computational physics will encounter and benefit from.

Appendix: Assignment and copying in Python

The behavior of class variables and instance variables may be somewhat confusing. To better understand some of the apparent inconsistencies we are forced to look a bit more carefully at how assignment and copying works in Python. As a starting point for this discussion, consider the following code.

In [16]:
# Create a string and a list.
string_1 = "My string"
list_1 = [0, 1, 2]

# Assign our variables to new names.
string_2 = string_1
list_2 = list_1

# Alter the original string and list.
string_1 += " and some more."
list_1.append(3)

# Print the values of the non-original variables.
print(string_2, list_2)
My string [0, 1, 2, 3]

Notice how changing the original list, also changed the new list, while altering the original string, did nothing to the new string! In order to understand this strange behavior, we will use the id command - given a variable, it returns the memory of the data stored in that variable. We will also have to introduce the notion of mutable and immutable types. Python types (ints, strings, objects, etc.) are either mutable or immutable, meaning that their value may either be edited or not. Both integers and strings are examples of immutable types, while lists are mutable. This means that in order to execute something like string_1 += " and some more.", Python cannot simply change the value of the original string, it must create a new string, and then move the variable name to point to that new string. Lists on the other hand, are mutable, so they may be edited in place, and the variable name is left alone.

In [20]:
lst = [0, 1]
a = 10
print(f"lst: {id(lst)}, a: {id(a)}")

lst.append(2)
a += 1
print(f"lst: {id(lst)}, a: {id(a)}")
lst: 140159033561792, a: 140159071451728
lst: 140159033561792, a: 140159071451760

Notice how lst still points at the same memory, while a now points somewhere else! So the explanation of our example with list_1 and list_2, and string_1 and string_2 is that when changing the immutable string_1, we must create a new string, and then move the name string_1 to point to that new string. The name string_2 does, however, still point at the original string, and is unaffected. For the mutable list_1, we are able to change the list directly, and so we do not have to create any new objects nor move any variables. Thus, as list_2 still points at the same location as list_1, it is also changed.

If we instead wanted to create a new list with the same values as the original list, but as a separate object, we would have to explicitly copy it.

In [21]:
lst = [0, 1]
lst_copy = lst.copy()

lst.append(2)

print(lst, lst_copy)
[0, 1, 2] [0, 1]

It is important to keep all of this in mind when working with OOP in Python. Class variables have common memory for all instances; if we have a list as a class variable, and change its value in any of the instances, its value is also changed for all other instances! Instance variables, however, are have separate memory.

For a more in depth treatment, we refer to the official documentation. See the the page on assignment for more information on how Python handles assignments for different situations. See standard types for more information on the built in types in Python, and how they are treated. Lastly, the page on copying describes the difference between shallow and deep copy, which is of importance when dealing with for example lists of objects.

Appendix: Multiple inheritance

Danger zone: Multiple inheritance is an advanced feature, and much care must be taken in avoiding errors. This section may be skipped without much loss, and is intended as a reference for advanced users only. As of such, the code and text will assume that the user has an intimate knowledge of Python, and not go into as much detail in explanations as the rest of this notebook.

Sometimes, inheritance is not only vertical, but also horizontal. With this we mean that different classes may share some functionality, even though it would be strange to group them together. For example, both students and companies have loans, though grouping them together under the same parent class would not make much sense in our mental framework. Also, as systems grow, that would be a road to infinitely much overhead, if all classes with similarities were to be grouped. Python supports what is known as multiple inheritance, which solves this issue. Note that multiple inheritance is an advanced feature, and it carries with it many complications connected to initialization. In our simple example it suffices to say that the later the parent class come in the inheritance list, the more "important" it is, as in

class Child(Parent3, Parent2, Parent1):
    ...

In the above example, we would sometimes refer to Parent1 as the "base class". For more complicated uses of multiple inheritance, much care must be taken.

In [32]:
# A minimal example of multiple inheritance
class A:
    pass


class B:
    pass


class C(A, B):
    pass


c = C()
# Let's check that c is actually of type A, B, and C
for class_type in [A, B, C]:
    print(class_type.__name__, ":", isinstance(c, class_type))
A : True
B : True
C : True

When we have classes that implement __init__, it is important that each class calls super().__init__, in order for Python to be able to properly call all the initializers needed. For example, in the code below, when initializing C, Python also calls the initializers of A and B. Care must also be taken with regards to arguments, if the different initializers take different sets of arguments. Here, it is solved using variable argument length (indicated by the asterisk *), which requires knowledge of the order in which the initializers are called. In the next example, the issue is solved using keyword arguments (indicated by two asterisks **).

In [44]:
# A minimal example including initializer arguments
class A:
    def __init__(self, argA1, argA2, *args):
        print("init A", argA1, argA2)
        super().__init__(*args)


class B:
    def __init__(self, argB1, argB2, *args):
        print("init B", argB1, argB2)
        super().__init__(*args)


class C(A, B):
    def __init__(self, argA1, argA2, argB1, argB2):
        print("init C")
        super().__init__(argA1, argA2, argB1, argB2)


c = C("A1", "A2", "B1", "B2")
init C
init A A1 A2
init B B1 B2

Following is a quite involved example using multiple inheritance. HasLoan is inherited both by Student and TechCompany, and care must be taken with the arguments of the initializers. As opposed to the previous example, this is here resolved by keyword arguments.

In [7]:
import uuid  # We want to generate random and unique strings.


class Person:
    def __init__(self, name, age, **kwargs):
        self.name = name
        self.age = age
        print("Person's init")  # Used only for demonstration purposes.d
        super().__init__(**kwargs)

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

    def introduce(self):
        print(f"Hello, I am {self.name}. I am {self.age} years old.")


class Company:
    def __init__(self, name, **kwargs):
        self.name = name
        print("Company's init")
        super().__init__(**kwargs)


class HasLoan:
    """Represents someone or something that has a loan."""

    def __init__(self, **kwargs):
        self.loan = 0
        # Generate a random unique identifier used by bank for security purposes.
        self.hash = uuid.uuid4()
        print("HasLoan's init")  # Used only for demonstration purposes.
        super().__init__(**kwargs)

    def pay_down_loan(self, amount):
        self.loan -= amount

    def print_loan_information(self):
        print("Loan amount:\t", self.loan)
        print("Loan identifier:\t", self.hash)


class Student(Person, HasLoan):
    def __init__(self, name, age):
        super().__init__(name=name, age=age)


class TechCompany(Company, HasLoan):
    pass


peter = Student("Peter", 23)
peter.loan = 31415
peter.pay_down_loan(10)
peter.print_loan_information()

print(" -- ")

my_company = TechCompany("Banana")
my_company.loan = 31415e10
my_company.pay_down_loan(10)
my_company.print_loan_information()
Person's init
HasLoan's init
Loan amount:	 31405
Loan identifier:	 7947fbea-b0c7-4af2-8bea-919d1516ec86
 -- 
Company's init
HasLoan's init
Loan amount:	 314149999999990.0
Loan identifier:	 e90566d4-077a-4886-9c15-4d127310dfcb

Lastly we mention that the flavor in which you are most likely to encounter multiple inheritance is mixins. Mixins is not a part of the Python language itself, but rather a design pattern. It is a controversial and comprehensive subject, and we will not go into it in much depth. The key aspect is that mixins should in general be "building blocks" that alter or add features of our objects. The Django web framework uses this concept to a great extent.

Below, we show a simple example of a mixin, UUIDMxin, which endows our objects with a UUID (a unique identifier).

In [9]:
class UUIDMixin:
    def __init__(self):
        self.uuid = uuid.uuid4()
        super().__init__()


class Parent1:
    pass


class Parent2:
    pass


class Object1(UUIDMixin, Parent1):
    pass


class Object2(UUIDMixin, Parent2):
    pass


o1 = Object1()
o2 = Object2()

print(o1.uuid)
print(o2.uuid)
8c2c9e16-608e-4e69-9d33-3073366c6a6d
1c037047-bc87-4493-9720-2c02fd634a98