#!/usr/bin/env python # coding: utf-8 # # # # # Classes in Python - An introduction to OOP # # ### Modules - Basics #
# By Thorvald Ballestad, Jenny Lunde, Sondre Duna Lundemo, and Jon Andreas Støvneng #
# 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. # ## Table of contents # # - [The basics](#The-basics) # - [When to use OOP](#when) # - [Inheritance - Parent and Children](#Inheritance) # - [Overriding operators - make classes behave like numbers](#override) # - [Appendix: Assignment and copying in Python](#assign_copy) # - [Appendix: Multiple inheritance - a powerful, but advanced feature](#multiple_inheritance) # ## 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) # 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) # 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) # 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 # ```Python # 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 # ```Python # 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) # If you are confused by the above example, we advice you to read our [appendix on assignment and copying in Python](#assign_copy). # # 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) # More information about this can be found in the official documentation's page on [class and instance variables](https://docs.python.org/3/tutorial/classes.html#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() # 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`. # ```Python # 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](https://docs.python.org/3/library/functions.html#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) # 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) # ## 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)) # 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](https://nbviewer.jupyter.org/urls/www.numfys.net/media/notebooks/image_filtering_using_fourier_transform.ipynb) 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](https://nbviewer.jupyter.org/urls/www.numfys.net/media/notebooks/ML_from_scratch_tekst.ipynb), 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 # ```Python # 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() #
# #
# # 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) # 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. # ```Python # 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 # ```Python # 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? # ```Python # [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](https://docs.python.org/3/reference/datamodel.html#emulating-numeric-types). # 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 # ```Python # def __oper__(self, other): # ... # ``` # while a unary operator will obviously take no other argument # ```Python # 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)) # Our notebook on [image filtering using Fourier transformation](https://nbviewer.jupyter.org/urls/www.numfys.net/media/notebooks/image_filtering_using_fourier_transform.ipynb) 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) # 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)}") # 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) # 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](https://docs.python.org/3/reference/simple_stmts.html#assignment-statements) for more information on how Python handles assignments for different situations. # See [standard types](https://docs.python.org/3/library/stdtypes.html) for more information on the built in types in Python, and how they are treated. # Lastly, the page on [copying](https://docs.python.org/3/library/copy.html) 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 # ```Python # 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)) # 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") # 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() # 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)