Introduction to Object Oriented Programming

Programming languages are built upon certain models to ensure the code behaves predictably. Python primarily follows what is known as an object oriented paradigm or model. Object-oriented programming or OOP, relies heavily on simplicity and reusability to improve workflow.

Programming paradigms are a strategy for reducing code complexity and determining the flow of execution. There are several different paradigms such as declarative, procedural, object-oriented, function, logic, event-driven, flow-driven more.

These paradigms are not mutually exclusive. Programs and programming languages can opt for multiple paradigms.

Python is primarily object-oriented, but it’s also procedural and functional. In simple terms, a paradigm can be defined as a style of writing a program.

OOP is one of the most widely used paradigms today due to the growing popularity of languages that use it, such as Java, Python, C++, and more.

NOTE

But the OOP is ability to translate real-world problems into code is arguably the biggest factor in its success.

OOP has high modularity, which makes code easier to understand, makes it reusable, adds layers of abstraction and allows for code blocks to be moved between projects.

Some key components of OOP

  • classes
  • objects
  • methods

Classes

A class is a logical code block that contains attributes and behavior. In Python, a class is defined with the class keyword. The attributes can be variables and the behavior can be functions inside of it.

Objects

You can create instances from these classes which are called objects. In other words, a class provides a blueprint for creating an object.

The state of an object comprises its attributes and behavior, and each one has a unique identifier to distinguish it from other instances. The attributes and behavior of the class are what define the state of the object.

Methods

Which are the functions defined inside a class that determine the behavior of an object instance.

The concepts that OOP hinges upon

Inheritance

Which is the creation of a new class by deriving from an existing one. The original is called the parent or superclass, or any derivatives are referred to as the subclass or child class.

Polymorphism

It’s a word that means having many forms. In the context of Python, polymorphism means that a single function can act differently depending on the object or the causes.

Example

Using ’+’ with numbers, it will add numbers together, but with strings, concatenation will be done.

Encapsulation

Broadly, this means that Python can bind methods and variables from direct access by wrapping them within a single unit of scope, such as a class.

Encapsulation helps prevent unwanted modifications, in effect, reducing the occurrence of errors and outputs.

Abstraction

This refers to the ability to hide implementation details to make data safer and more secure.

NOTE

Note that Python does not support abstraction directly and uses inheritance to achieve it.

Some others

  • Method overloading
  • Method overriding
  • Constructors
  • 


OOP Principles

This reading introduces you to the OOP principles in more detail using some examples.

The object oriented paradigm was introduced in the 1960s by Alan Kay. At the time, the paradigm was not the best computing solution given the small scalability of software developed then. As the complexity of software and real-life applications improved, object oriented principles became a better solution.

You previously encountered the four main pillars of object oriented programming. These are: encapsulation, polymorphism, inheritance and abstraction. Let’s look at a few examples that demonstrate how these principles translate when using Python.

Encapsulation

The idea of encapsulation is to have methods and variables within the bounds of a given unit. In the case of Python, this unit is called a class. And the members of a class become locally bound to that class. These concepts are better understood with scope, such as global scope (which in simple terms is the files I am working with), and local scope (which refers to the method and variables that are ‘local’ to a class). Encapsulation thus helps in establishing these scopes to some extent.

For example, the Little Lemon company may have different departments such as inventory, marketing and accounts. And you may be required to deal with the data and operations for each of them separately. Classes and objects help in encapsulating and in turn restrict the different functionalities.

Encapsulation is also used for hiding data and its internal representation. The term for this is information hiding. Python has a way to deal with it, but it is better implemented in other programming languages such as Java and C++. Access modifiers represented by keywords such as public, private and protected are used for information hiding. The use of single and double underscores for this purpose in Python is a substitute for this practice. For example, let’s examine an example of protected members in Python.

class Alpha:
 
def __init__(self):
    self._a = 2.  # Protected member ‘a’
    self.__b = 2.  # Private member ‘b’

self._a is a protected member and can be accessed by the class and its subclasses.

Private members in Python are conventionally used with preceding double underscores: __. self.__b is a private member of the class Alpha and can only be accessed from within the class Alpha.

It should be noted that these private and protected members can still be accessed from outside of the class by using public methods to access them or by a practice known as name mangling. Name mangling is the use of two leading underscores and one trailing underscore, for example:

_class__identifier

Class is the name of the class and identifier is the data member that I want to access.

Polymorphism

Polymorphism refers to something that can have many forms. In this case, a given object. Remember that everything in Python is inherently an object, so when I talk about polymorphism, it can be an operator, method or any object of some class. I can illustrate the case for polymorphism using built-in functions and operations, for example:

string = "poly"
num = 7
sequence = [1,2,3]
new_str = string * 3
new_num = 7 * 3
new_sequence = sequence * 3
 
print(new_str, new_num, new_sequence)

The output is:

polypolypoly 21 [1, 2, 3, 1, 2, 3, 1, 2, 3]

In the example, I have used the same operator () to perform on a string, integer and a list. You can see the () operator behaves differently in all three cases.

Let’s examine one more example.

string = "poly"
sequence = [1,2,3]
print(len(string))
print(len(sequence))

The output is:

4
3

The len() function is able to take variable inputs. In the example above it is a string and a list that provides the output in integer format.

Inheritance

Inheritance in Python will be covered later in the course, but the basic template for it is as follows:

class Parent:
    Members of the parent class
 
class Child(Parent):
    Inherited members from parent class
    Additional members of the child class

As the structure of inheritance gets more complicated, Python adheres to something called the Method Resolution Order (MRO) that determines the flow of execution. MRO is a set of rules, or an algorithm, that Python uses to implement monotonicity, which refers to the order or sequence in which the interpreter will look for the variables and functions to implement. This also helps in determining the scope of the different members of the given class.

Abstraction

Abstraction can be seen both as a means for hiding important information as well as unnecessary information in a block of code. The core of abstraction in Python is the implementation of something called abstract classes and methods, which can be implemented by inheriting from something called the abc module. “abc” here stands for abstract base class. It is first imported and then used as a parent class for some class that becomes an abstract class. Its simplest implementation can be done as below.

from abc import ABC,   
class ClassName(ABC):
    pass

Python classes and instances

Classes have the ability to combine data and functionality, which is a very useful feature when you are coding.

You may have also heard of classes discussed in terms of attributes and behaviors. In general, attributes refer to variables declared in a class, while behaviors are associated with the methods in the class.

Creating a class creates a new type of object from which you can create instances.

NOTE

An important thing to keep in mind is that everything in Python is an object or derived from the object class.

The pass keyword plays the role of a placeholder when nothing needs to be executed. In practice, this tells Python that I won’t do anything with this class just yet.

class MyClass:
    pass
 
myclass = MyClass()
class MyClass:
    print("Hello")
 
myclass = MyClass()
Hello
  1. Class definition
  2. Creating a new instance
  3. Initializing the new instance

Since everything in Python is an object, it makes sense to follow naming conventions to make things less confusing later.

There is a third type of object called the method object, which you can use the column method whenever it’s needed.

Classes mainly perform two kinds of operations, attribute references and instantiation.

class MyClass:
    a = 5
    print("Hello")
 
myc = MyClass()
print(MyClass.a)
Hello
5
class MyClass:
    a = 5
    print("Hello")
 
myc = MyClass()
print(a)
Hello
Traceback (most recent call last):
  File "PATH", line 6, in <module>
    print(a)
          ^
NameError: name 'a' is not defined
class MyClass:
    a = 5
    print("Hello")
 
myc = MyClass()
print(myc.a)
Hello
5
class MyClass:
    a = 5
    
    def hello():
        print("Hello, world!")
 
myc = MyClass()
print(myc.a)
print(myc.hello())
5
Traceback (most recent call last):
  File "PATH", line 9, in <module>      
    print(myc.hello())
          ^^^^^^^^^^^
TypeError: MyClass.hello() takes 0 positional arguments but 1 was given
class MyClass:
    a = 5
    
    def hello(self):
        print("Hello, world!")
 
myc = MyClass()
print(myc.a)
print(myc.hello())
5
Hello, world!
None

There is None, because there is no return value in the function.


Exercise: Define a Class

Learning Objectives

You have encountered the basic principles of Object Oriented programming and in some preliminary ways demonstrated how the different principles can be put into practice with the help of classes, the building blocks of OOP. Let us now look at the structure of these classes.

Here you will learn how to create classes and objects with the help of examples. Let’s first look at the basic members of a class. These can be the attributes or the data members, the methods, and additionally the comments that you can include. These members can be shown with the help of an example below. Let us imagine you want to make a class of some house. You begin by creating a class for it.

Example 1

class House:
    '''
    This is a stub for a class representing a house that can be used to create objects and evaluate different metrics that we may require in constructing it.
    '''
    num_rooms = 5
    bathrooms = 2
    def cost_evaluation(self):
        print(self.num_rooms)
        pass
        # Functionality to calculate the costs from the area of the house

In the code above, you start with a multiline comment, which alternatively can also be called a docstring (''' enclosed comments ''' ). In the next line you have the class definition, followed by a couple of data members or attributes: num_rooms and bathrooms. This is then followed by a function definition, which is empty except for the pass keyword that basically signals Python to continue execution without throwing an error. The last line in the code block is the single-line comment preceded by #.

The code completely defines the class and functions present inside it, but it is effectively not useful unless you call or instantiate it. You can do this by one of the two ways: Calling the class directly Instantiating an object of that class

You can add a few lines of code below your code that will call the variable num_rooms on the house object and the House class after we create a house object from House class:

house = House()
print(house.num_rooms)
print(House.num_rooms)

The effective output for this will be:

5
5

To follow up with this example, add few more lines to this code and see the output, this time after you have updated the num_rooms variable called on house object to 7:

house.num_rooms = 7
print(house.num_rooms)
print(House.num_rooms)

The new output this time will be:

5
5
7
5

What has happened in the code above is, you have created an instance of a class called house and then modified the attribute for that instance with a value of 7. It updates the value of the instance attribute, but not the class attribute. So the num_rooms attribute of the class remains unchanged as 5, but the instance attribute associated with house object changes to 7. Let’s now insert an alternate piece of code in this.

This time, instead of an instance attribute, you will modify the class attribute by directly calling it over the class as follows:

House.num_rooms = 7
print(house.num_rooms)
print(House.num_rooms)

The output for it will be:

5
5
7
7

You will notice that the changes on a class attribute will affect even the instances that you will create over it. Also note the use of the keywork self in this example. self is a convention in Python, and you may use any other word in its place, but as a practice, it is easy to recognize. self here is passed inside the method cost_evaluation() as it is an instance method and facilitates the method to point to any instance of the House when that method is called. It should be noted how any number of parameters can be passed to these instance methods but the first one is always the reference to the instance of that class.

You can interact and run the entire program that you just saw in the code block below:

class House:
    '''
    This is a stub for a class representing a house that can be used to create objects and evaluate different metrics that we may require in constructing it.
    '''
    num_rooms = 5
    bathrooms = 2
    def cost_evaluation(self):
        print(self.num_rooms)
        pass
        # Functionality to calculate the costs from the area of the house
 
house = House()
print(house.num_rooms)
print(House.num_rooms)
 
house.num_rooms = 7
print(house.num_rooms)
print(House.num_rooms)
 
House.num_rooms = 7
print(house.num_rooms)
print(House.num_rooms)

Define a Class - solution

Solution code

class House:
    '''
    This is a stub for a class representing a house that can be used to create objects and evaluate different metrics that we may require in constructing it.
    '''
    num_rooms = 5
    bathrooms = 2
 
    def cost_evaluation(self, rate):
        # Functionality to calculate the costs from the area of the house
        cost = rate * self.num_rooms
        return cost
 
 
 
house = House()
print(house.num_rooms)
print(House.num_rooms)
house.num_rooms = 7
# House.num_rooms = 7
print(house.num_rooms)
print(House.num_rooms)

Instantiate a custom Object

INFO

Code reusability is the use of existing code to build new software. Reusability is a core programming concept.

Two special methods in python

new

class Recipe():
    def __new__(cls) -> Self:
        pass

cls

cls here is not a keyword, but rather a convention. It acts as a placeholder for passing the class as its first argument, which will be used for creating the new empty object.

init

Which is similar to what is known as a constructor in some other programming languages.

It takes the objects created using the new method along with other arguments to initialize the new object being created.

class Recipe():
    def __new__(cls) -> Self:
        pass
    def __init__(self) -> None:
        pass

self

The self keyword here is another convention. It has no function itself but serves as a placeholder for self-reference by the instance object.

class Recipe():
    def __init__(self, dish, items, time) -> None:
        self.dish = dish
        self.items = items
        self.time = time
 
    def contents(self):
        print("The " + self.dish + " has " + str(self.items) + \
                " and takes " + str(self.time) + " min to prepare")
 
pizza = Recipe("Pizza", ["cheese", "bread", "tomato"], 45)
pasta = Recipe("Pasta", ["penne", "sauce"], 55)
 
print(pizza.items)
print(pasta.items)
['cheese', 'bread', 'tomato']
['penne', 'sauce']

NOTE

I find that despite passing the same function and variable items, the two instances produce different contents.

class Recipe():
    def __init__(self, dish, items, time) -> None:
        self.dish = dish
        self.items = items
        self.time = time
 
    def contents(self):
        print("The " + self.dish + " has " + str(self.items) + \
                " and takes " + str(self.time) + " min to prepare")
 
pizza = Recipe("Pizza", ["cheese", "bread", "tomato"], 45)
pasta = Recipe("Pasta", ["penne", "sauce"], 55)
 
print(pizza.items)
print(pasta.items)
 
print(pizza.contents())
['cheese', 'bread', 'tomato']
['penne', 'sauce']
The Pizza has ['cheese', 'bread', 'tomato'] and takes 45 min to prepare
None

Exercise: Instantiate a custom Object

This is your first experience creating classes and objects in Python. You will be following a sequential process where you will create a class, define its state by creating variables and functions to define its attributes and behavior, and then instantiate it using some variable. Finally, you will use the class members to get the desired output.

Follow the steps to build and run your program in the environment provided at the bottom of the reading.

Step 1

1.1 Define a class called MyFirstClass.

1.2 Add a print statement inside it such as “Who wrote this?”.

Step 2

Create a string variable named index and initialize it with a string “Author-Book”.

Step 3

3.1 Define a function called hand_list() with the help of def keyword.

3.2 Pass the parameter  self to it. And then pass two parameters, philosopher and book to it.

Step 4

4.1 Write a print statement using the print() function and pass the class variable by accessing it.

Hint: Class variables are accessed directly by calling it over the class name using dot notation.

4.2 Write a print statement that will give output such as: “Plato wrote the book: Republic” where “Plato” is the philosopher and “Republic” is the book.

Hint: You can make use of the built-in (+) concatenation operator to join these strings.

Step 5

5.1 Create and instantiate an object of that class, called whodunnit

5.2 Call method hand_list() over this object “whodunnit” and pass two values to it namely “Sun Tzu” and “The Art of War”.

Use the following code block to build your program:

# Define class MyFirstClass
 
    # Define string variable called index
    
    # Define function hand_list()
    
        # variable + “ wrote the book: ” + variable
        
 
# Call function handlist()

Instantiate a custom Object - solution

Solution code

# Sample Solution code
class MyFirstClass():
    print("Who wrote this?")
    index = "Author-Book"
 
    def hand_list(self, philosopher, book):
        print(MyFirstClass.index)
        print(philosopher + " wrote the book: " + book)
 
whodunnit = MyFirstClass()
whodunnit.hand_list("Sun Tzu", "The Art of War")

Instance methods

class Payslips:
    def __init__(self, name, payment, amount) -> None:
        self.name = name
        self.payment = payment
        self.amount = amount
 
    def pay(self):
        self.payment = "yes"
    
    def status(self):
        if self.payment == "yes":
            return self.name + " is paid" + str(self.amount)
        else:
            return self.name + " is not paid yet"
        
nathan = Payslips("Nathan", "no", 1000)
roger = Payslips("Roger", "no", 3000)
 
print(nathan.status(), roger.status())
Nathan is not paid yet Roger is not paid yet
class Payslips:
    def __init__(self, name, payment, amount) -> None:
        self.name = name
        self.payment = payment
        self.amount = amount
 
    def pay(self):
        self.payment = "yes"
    
    def status(self):
        if self.payment == "yes":
            return self.name + " is paid" + str(self.amount)
        else:
            return self.name + " is not paid yet"
        
nathan = Payslips("Nathan", "no", 1000)
roger = Payslips("Roger", "no", 3000)
 
print(nathan.status(), roger.status())
 
nathan.pay()
print("After payment")
print(nathan.status(), roger.status())
Nathan is not paid yet Roger is not paid yet
After payment
Nathan is paid1000 Roger is not paid yet

Parent classes vs. child classes

When instantiating objects from a class, you may find that the class is missing some properties that you use frequently. In that case, you could decide to make a new class that replicates the first one, but also adds a few more properties. It would be cumbersome to write everything from scratch, but thanks to inheritance, you don’t have to.

Everything in python is an object. It specifically means that every class Python inherits from a built-in base class called objects, which is found in built-ins dot objects.

In other words, a class declaration such as Someclass with empty parentheses implies some class with object as its arguments.

class Someclass():
	...
 
class Someclass(object):
	...

When speaking of class derivation, the originating class is known as the parent class, super class, or base class.

The class which inherits from it is the child class, subclass, or derived class.

Any named pairing is acceptable.

NOTE

Child class extends the attributes and behaviors of its parent class.

This allows you to do two things:

  1. You can add new properties to the child class.
  2. You can modify inherited properties in the child class without affecting the parents.
class P:
    def __init__(self) -> None:
        self.a = 7
    
class C(P):
    pass
 
c = C() # Instance of child class
 
print(c.a)
7

NOTE

Any changes in the parent class will also affect any child classes.

Example

class Employees:
    def __init__(self, name, last) -> None:
        self.name = name
        self.last = last
 
 
class Supervisors(Employees):
    def __init__(self, name, last) -> None:
        super().__init__(name, last)

TIP

By calling the Employees class, the super method has automatically been applied to access the variables there and initialize them within the Supervisors class.

class Employees:
    def __init__(self, name, last) -> None:
        self.name = name
        self.last = last
 
 
class Supervisors(Employees):
    def __init__(self, name, last, password) -> None:
        super().__init__(name, last)
        self.password = password
 
class Chefs(Employees):
    def leave_request(self, days):
        return "May I take a leave for " + str(days) + " days"
    
 
adrian = Supervisors("Adrian", "A", "apple")
 
emily = Chefs("Emily", "E")
juno = Chefs("Juno", "J")
 
print(emily.leave_request(3))
print(adrian.password)
print(emily.name)
May I take a leave for 3 days
apple
Emily

Inheritance and Multiple Inheritance

Let’s say there two classes, namely class A and class B. If you have to perform simple inheritance, it can be done as follows:

Class A:
    pass
Class B(A):
    pass

If class A is the parent class and class B is inheriting from it, then class A is passed inside class B as a parameter. This will allow class B to directly access the attributes and methods inside class A.

Multiple inheritance

You have learned about single inheritance so far, but Python also gives us the ability to perform multiple inheritance between classes.

Here is a simple example of how that can be done.

class A:
   a = 1
 
class B(A):
   a = 2
 
class C(B):
   pass
 
c = C()
print(c.a)

The output is 2 because C derives from the immediate super class of C, and that’s B.

The case above is an example of multi-level inheritance where the derived class C inherits from base class B. The class B is in turn a derived class of base class C. Class B here is an intermediary derived class. There are three levels of inheritance in this case, but it could be extended as long as I want, though it may become impractical after a while.

Built-in functions

There are two built-in functions that can come in handy when trying to find the relationship between different classes and objects: issubclass() and isinstance().

The first one, issubclass () is demonstrated below.

issubclass(class A, class B)

Two classes are passed as arguments to this function and a Boolean result is returned. The above example can be extended as follows.

print(issubclass(A,B))
print(issubclass(B,A))

The output is:

False
True

This illustrates how the child class is passed as the first argument. To avoid confusion, this can be read as: “Is B subclass of A?“ You can see the result is “True” in the second case where child B is the subclass.

Another built-in function similar to this one is isinstance() thatdetermines if some object is an instance of some class. So if I write:

Class A:
	pass
Class B(A):
	pass
 
b = B()
print(isinstance(b,B))
print(isinstance(b,B))

The output that I will get is “True”.

Now that you know how classes can be extended from other classes, let’s look at another useful built-in function called the super() function.

The super() function is a built-in function that can be called inside the derived class and gives access to the methods and variables of the parent classes or sibling classes. Sibling classes are the classes that share the same parent class. When you call the super() function, you get an object that represents the parent class in return.

The super() function plays an important role in multiple inheritance and helps drive the flow of the code execution. It helps in managing or determining the control of from where I can draw the values of my desired functions and variables.

If you change anything inside the parent class, there is a direct retrieval of changes inside the derived class. This is mainly used in places where you need to initialize the functionalities present inside the parent class in the child class as well. You can then add additional code in the child class.

Here is an example.

class Fruit():
    def __init__(self, fruit):
        print('Fruit type: ', fruit)
 
 
class FruitFlavour(Fruit):
    def __init__(self):
        super().__init__('Apple')
        print('Apple is sweet')
 
apple = FruitFlavour()

The output is:

Fruit type:  Apple
Apple is sweet

In the code above, if I had commented out the line for super() function, the output is:

Apple is sweet

This happened because when you initialize the child class, you don’t initialize the base class with it. super() function helps you to achieve this and add the initialization of base class with the derived class.


Exercise: Classes and object exploration

In this reading, you will explore the behaviour of functions, objects and classes in Python and how the flow of execution of different program statements works to enable better understanding.

You will perform minor modifications in the given code to observe how it changes the output.

First, set up a file called class_explore.py that contains the following piece of code. Alternatively, you can use the interactive environment here.

class A:
   def __init__(self, c):
       print("---------Inside class A----------")
       self.c = c
   print("Print inside A.")
 
   def alpha(self):
       c = self.c + 1
       return c
 
print(dir(A))
print("Instantiating A..")
a = A(1)
print(a.alpha())
 
class B:
   def __init__(self, a):
       print("---------Inside class B----------")
       self.a = a
 
   print(a.alpha())
   d = 5
   print(d)
   print(a)
 
print("Instantiating B..")
b = B(a)
print(a)

Now, modify the code as per the instructions below and observe the changes.

Step 1: Run the code and observe its output. Take note of every line in the output and how it is different from the output you expected.

Algorithmically we can view the program consisting of the following:

  1. Class definition of A
    • 1.1 Constructor for A
    • 1.2 Definition of local method alpha()
  2. Instantiating object a over class A
  3. Calling method alpha() over object of class A
  4. Class definition of B
  5. Constructor for B
    • 5.1 Calling method alpha() over object of class A
    • 5.2 Instantiating object a over class B *.

Additional print statements distributed through the code.

Step 2: Comment out lines #13, 14, 21, 24, 27 and 28. Run the code again.

The output is:

Hello World!
Print inside A.
['__class__', '__delattr__', '__dict__', '__dir__', '__doc__', '__eq__', '__format__', '__ge__', '__getattribute__', '__gt__', '__hash__', '__init__', '__init_subclass__', '__le__', '__lt__', '__module__', '__ne__', '__new__', '__reduce__', '__reduce_ex__', '__repr__', '__setattr__', '__sizeof__', '__str__', '__subclasshook__', '__weakref__', 'alpha']
Instantiating A..
5
Instantiating B..

Even if you have commented out the creation of instances for both classes A and B, the output still shows “Print inside A” and “Print inside B” and also the value of variable d, which is 5. How is that possible?

It’s because statements inside a class body get executed irrespective of the instance creation. You will also see how the print statement “Inside class A”, which is inside the constructor, is not executed because it’s inside a function.

The value of d=5 being printed demonstrates that the namespace and scope of the variable is determined by the interpreter before you create any instance of the class or call any function inside it. If you observe the list you get by calling the dir() function, you’ll note that the last entry is the alpha() function added to the namespace of A.

Step 3: Now remove the comment for lines 21 and 24.

If you run the code at this point, it will throw an error, “NameError: name ‘a’ is not defined”. Take note of how you have passed the object a to the constructor of class B and the code still worked fine earlier. Only when you tried to ‘use’ object a, did you get an error because it has not been instantiated. In other words, Python still does not know what ‘a’ means. The same will happen if you remove commenting next to line 28.

To make the code work, now remove the # in front of line 14 and run it again.

The output is:

Hello World! 
Print inside A.
['__class__', '__delattr__', '__dict__', '__dir__', '__doc__', '__eq__', '__format__', '__ge__', '__getattribute__', '__gt__', '__hash__', '__init__', '__init_subclass__', '__le__', '__lt__', '__module__', '__ne__', '__new__', '__reduce__', '__reduce_ex__', '__repr__', '__setattr__', '__sizeof__', '__str__', '__subclasshook__', '__weakref__', 'alpha']
Instantiating A..
---------Inside class A----------
2
5
<submission.A object at 0x7fcab3ef6940>
Instantiating B..

Step 4: Remove the commenting for line 27 and 28.

The variable c of class A is modified over object a inside class B. Even though the instance of class B is still not created, the value of a.c is still getting updated, even outside the class, as evidenced by the final line in the output which shows the output is 2.

Step 5: Finally remove all the remaining comments and run the code one more time.

Here are a few observations.

  • When you try and print the ‘object’ of class A as in lines 21 and 28, you get the address of the object instead of the contents.
  • Note how the address of object a is the same both inside class B and in the global scope of the program. It remains the same irrespective of from where it is called.
  • The alpha() function is called twice in the program, but you still get the output as 2 every time and not 3. That’s because the value is updated only temporarily and not assigned to anything.

Revise items about classes, function calls and scope in case of confusion.


Abstract classes and methods

If you have an abstract class, you can ensure the functionality of every class that is derived from it. For example, a vehicle could be an abstract class. You can’t create a vehicle, but you can derive a car, a tractor, or a boat from a vehicle.

The methods we put in the abstract class are guaranteed to be present in the derived class because they must be implemented.

If a vehicle has a turn on engine method, then we assure that any method calls to a derived class that is looking for turn on engine will find it.

This could be for reasons of interoperability, consistency, and avoiding code duplication in general.

Abstract classes

In object oriented programming, the abstract class is a type of class for which you cannot create an instance. Python also does not support abstraction directly. You need to impose a module just to define an abstract class. Furthermore, methods in an abstract class needs to be defined before they can be implemented.

Why use abstract classes?

One of the key advantages is the ability to hide the details of implementation without sacrificing functionality.

Abstract Class

Implementation in abstract classes can be done in two ways:

  1. One is that, as base abstract classes lack implementation of their own, the methods must be implemented by the derived class.
  2. Another possibility is that the super function can be used.

The module is known as the abstract base class or ABC, and needs to be imported with some code.

After that, you can create a class called SomeAbstractClass and pass in the ABC module so that it inherits that class.

The next step is to import the abstract method decorator inside the same module.

Decorator

A decorator is a function that takes another function as its arguments and gives a new function as its output. It’s denoted by the add sign.

For now, it’s enough to know that decorators are like helper functions that add functionality to an already existing function.

Finally, here you’ll define an abstractmethod which cannot be called on an object of this class. You will be able to call this method over objects of classes that inherit from this class.

from abc import ABC, abstractclassmethod
 
class SomeAbstractClass(ABC):
    @abstractmethod
    def someabstractmethod(self):
        pass

Abstract method

Similarly, we can define abstract methods with the help of what we call an abstract method decorator present inside the same module.

Any given abstract class can consist of one or more abstract methods. However, a class that has abstract class as its parents cannot be instantiated unless you override all the abstract methods present in at first.

Example

Imagine a scenario in which an employer wants to collect donations from employees for a charitable cause.

from abc import ABC, abstractclassmethod
 
class Employee(ABC):
 
    @abstractmethod
    def donate(self):
        pass

NOTE

There’s no implementation to this method here.

from abc import ABC, abstractclassmethod
 
class Employee(ABC):
 
    @abstractmethod
    def donate(self):
        pass
 
class Donation(Employee):
    def donate(self):
        a = input("How much would you like to donate: ")
        return a
    
 
amounts = []
john = Donation()
j = john.donate()
amounts.append(j)
 
peter = Donation()
p = peter.donate()
amounts.append(p)
 
print(amounts)
from abc import ABC, abstractclassmethod
 
class Employee(ABC):
 
    @abstractclassmethod
    def donate(self):
        pass
 
class Donation(Employee):
    def donate(self):
        a = input("How much would you like to donate: ")
        return a
    
 
amounts = []
john = Donation()
j = john.donate()
amounts.append(j)
 
peter = Donation()
p = peter.donate()
amounts.append(p)
 
print(amounts)
How much would you like to donate: 1
How much would you like to donate: 2
['1', '2']

Method Resolution Order

Up to this point, you’ve explored class relationships that were relatively straight forward. But what happens when things get complex? How will you know which classes inherit from which, fortunately method resolution order or MRO provides rules that can help make sense of that.

Python has many types of inheritance. The categorization types are based on the number of parents and child classes as well as the hierarchical order, including simple inheritance.

There are broadly four types of inheritance:

  1. Simple Inheritance
  2. Multiple Inheritance : which involves a child class inheriting from more than one parent.
  3. Multi Level Inheritance : which is inheritance taking place on several levels.
  4. Hierarchical Inheritance : which concerns how several sub classes inherit from a common parent.
  5. Hybrid Inheritance : you could say that there is fifth type called hybrid inheritance, which mixes characteristics of the others.

As these inheritance types demonstrate inheritance becomes increasingly complex as the number of classes in a project grow and become more interdependent.

MRO

Method Resolution Order

MRO determines the order in which a given method or attributes is passed through in a search of the hierarchy of classes for its resolution, or in other words, from where it belongs.

The order of the resolution is called linearization of a class, and MRO defines the rules that’s follows.

The default order in python is bottom to top, left to right when imagining the inheritance of these python classes in a tree structure.

  1. Z
  2. Y
  3. X

But things become much more complicated when more levels are added to the hierarchy. So developers rely on algorithms to build MROs.

Old style classes used in depth-first search algorithm or DFS from python version three onwards, python versions have moved to the new style of classes that rely on the C3 linearization algorithm.

C3 Linearization Algorithm

The implementation of the C three linearization algorithm is complex.

  • The algorithm follows monotonicity, which broadly means that an inherited property cannot skip over direct parent classes.
  • It also follows the inheritance graph of the class
  • and the super class is visited only after visiting the methods of the local classes.

mro()

class A:
    pass
class B(A):
    pass
class C(B):
    pass
 
print(C.mro())
[<class '__main__.C'>, <class '__main__.B'>, <class '__main__.A'>, <class 'object'>]

Imagine the class A has a variable num with value of five and then Class B also has a number variable with a value of nine Here, the morrow function tells you quickly that class C will inherit the nine value from class B.

help()

class A:
    pass
class B(A):
    pass
class C(B):
    pass
 
print(help(C))
Help on class C in module __main__:
 
class C(B)
 |  Method resolution order:
 |      C
 |      B
 |      A
 |      builtins.object
 |
 |  Data descriptors inherited from A:
 |
 |  __dict__
 |      dictionary for instance variables
 |
 |  __weakref__
 |      list of weak references to the object
 
None

It provides a much more detailed output with MRO information at the top.

It also contains information about the data descriptors and types used inside the code.


Working with Methods: Examples

You have learned how to use objects, classes and the methods inside them. You have covered these both in cases where there is only one class, as well as when there are multiple classes. You also explored how multiple inheritance works in Python and the role Method Resolution Order(MRO) plays in determining the call for the method.

The following examples demonstrate how the function call is resolved in cases of multiple inheritance in different scenarios. Note that all the functions have the same names in all of the examples.

Example 1

# Example 1
class A:
   def a(self):
       return "Function inside A"
 
class B:
    def a(self):
        return "Function inside B"
 
class C(B,A):
    pass
 
# Driver code
c = C()
print(c.a())

Output:

Function inside B

Class C inherits from classes B and A. When I don’t find any function a() inside class C, I should search for classes B and A and its important that I do it in that order.

I will now add one more level to this and note the output.

Example 2

class A:
    def b(self):
        return "Function inside A"
 
class B:
    def b(self):
        return "Function inside B"
 
class C(A, B):
    def b(self):
        return "Function inside C"
    pass
 
class D(C):
    pass
 
d = D()
print(d.b())

Output:

Function inside C

Class D inherits from class C, which in turn inherits from classes A and B. Class D accesses the immediate superclass of class D, which is class C and resolves the value of the variable once it’s found in that superclass.

Now let’s say I comment out the declaration inside class C.

    # def b(self):
    #     return "Function inside C" 

And replace it with the pass keyword to keep the code functional.

Since there was no value present inside class C either, the function call above would go to A. That is because class C will point to class A as having higher precedence while inheriting.

Now let’s take another example of a similar scenario.

Example 3

class A:
    def c(self):
        return "Function inside A"
 
class B:
    def c(self):
        return "Function inside B"
 
class C(A, B):
    def c(self):
        return "Function inside C"
 
class D(A, C):
    pass
 
d = D()
print(d.a)

The output is:

Traceback (most recent call last):
  File "/Users/intropython/PycharmProjects/practicePython/inherit.py", line 10, in <module>
    class D(A, C):
TypeError: Cannot create a consistent method resolution
order (MRO) for bases A, C

Note that this throws an error. In the code above, class D inherits from both class A and class C.

Class C is its immediate superclass, but since this is multiple inheritance, the rules are more complicated and it also has to check the classes passed to it for precedence.

In this particular case, class D is unable to resolve the order that should be followed, while resolving the value for the variable in cases where the variable is not present in the class of the given object.

It results in a TypeError because it’s unable to create method resolution order (MRO). MRO is Python’s way of resolving the order of precedence of classes while dealing with inheritance.

Let’s examine one final example.

Example 4

class A:
    def d(self):
        return "Function inside A"
 
class B:
    def d(self):
        return "Function inside B"
 
 
class C:
    def d(self):
        return "Function inside C"
 
 
class D(A, B):
    def d(self):
        return "Function inside D"
 
 
class E(B, C):
    def d(self):
        return "Function inside E"
 
 
class F(E,D,C):
    pass
 
f = F()
print(f.d())
print(F.mro())

Output:

Function inside E
[<class '__main__.F'>, <class '__main__.E'>, <class '__main__.D'>, <class '__main__.A'>, <class '__main__.B'>, <class '__main__.C'>, <class 'object'>]

The code here is simple. class F directly inherits from its immediate superclass and the first class that is passed to it. The second line then demonstrates the return from the mro() function.

The examples in this reading demonstrate how code in which multiple inheritance is used, can get complicated and very messy, very fast. Multiple inheritance, with all the advantages and flexibility that it provides, should only be used once you have a strong command of Python as a language to avoid creating ‘spaghetti code’ that’s difficult to understand and update.


Exercise: Working with Methods

  1. Guess the output for the following block of code and try running the code once you have a solution in mind:
class A:
    def b(self):
        return "Function inside A"
 
class B:
    pass
 
class C:
    def b(self):
        return "Function inside C"
 
class D(B, C, A):
    pass
 
class D(C):
    pass
 
d = D()
print(d.b())
  1. Guess the output for the following block of code and try running the code once you have a solution in mind:
class A:
    def c(self):
        return "Function inside A"
 
class B(A):
    def c(self):
        return "Function inside B"
 
class C(A,B):
    pass
 
class D(C):
    pass
 
d = D()
print(d.a)
  1. Guess the output for the following block of code and try running the code once you have a solution in mind:
class A:
    pass
 
class B(A):
    pass
 
class C(B):
    pass
 
 
c = C()
print(c.a())

Working with Methods - solution

Solutions:

  • Output Example 1:
Function inside C
  • Output Example 2:
Error on line 9:
    class C(A,B):
TypeError: Cannot create a consistent method resolution
order (MRO) for bases A, B
  • Output Example 3:
Error on line 12:
    print(c.a())
AttributeError: 'C' object has no attribute 'a'

Additional resources

The following resources will be helpful as additional references in dealing with different concepts related to the topics you have covered in this lesson.


OOPMROPythonIngeritanceAbstractionClassParadigmPython

Previous one → 6.Functional programming | Next one → 8.Modules