47.Inheritance, Polymorphism and Operator overloading in Python

Structuring Classes with Inheritance and Polymorphism

Object-based programming involves the use of objects, classes, and methods to solve problems.Object-oriented programming requires the programmer to master the following additional concepts:

1. Data encapsulation. Restricting the manipulation of an object’s state by external users to a set of method calls.Encapsulation restricts access to an object’s data to users of the methods of its class.This helps to prevent in discriminant changes to an object’s data.
2. Inheritance. Allowing a class to automatically reuse and extend the code of similar but more general classes.Inheritance allows one class to pick up the attributes and behavior of another class for free. The subclass may also extend its parent class by adding data and/or methods or modifying the same methods. Inheritance is a major means of reusing code
3. Polymorphism. Allowing several different classes to use the same general method names. Polymorphism allows methods in several different classes to have the same headers.This reduces the need to learn new names for standard operations.Function over riding and operator overloading provides Polymorphism.

Inheritance
Objects in the natural world and objects in the world of artefacts can be classified using Inheritance hierarchies.A simplified hierarchy of natural objects is depicted in Figure


At the top of a hierarchy is the most general class of objects. This class defines the features that are common to every object in the hierarchy.For example mass.Classes below this one have these features and additional ones.Thus, a living thing has a mass and can also grow and die.Each class below the topmost one inherits attributes and behaviours from its ancestors and extend these with additional attributes and behaviour.
An object oriented software system models this pattern of inheritance and extension in real-world system by defining classes that extends other classes.

Inheritance enables us to define a class that takes all the functionality from a parent class and allows us to add more.The new class is called derived (or child) class and the one from which it inherits is called the base (or parent) class.Derived class inherits features from the base class where new features can be added to it. This results in re-usability of code.

Python Inheritance Syntax
class BaseClass: 
  Body of base class
class DerivedClass(BaseClass): 
  Body of derived class

Example of Inheritance in Python
To demonstrate the use of inheritance, let us take an example.
A polygon is a closed figure with 3 or more sides. Say, we have a class called Polygon defined as follows.

class Polygon:
    def __init__(self, no_of_sides):
        self.n = no_of_sides
        self.sides = [0 for i in range(no_of_sides)]
    def inputSides(self):
        self.sides = [float(input("Enter side "+str(i+1)+" : ")) for i in range(self.n)]
    def dispSides(self):
        for i in range(self.n):
            print("Side",i+1,"is",self.sides[i])

This class has data attributes to store the number of sides n and magnitude of each side as a list called sides.

The inputSides() method takes in the magnitude of each side and dispSides() displays these side lengths.A triangle is a polygon with 3 sides. So, we can create a class called Triangle which inherits from Polygon. This makes all the attributes of Polygon class available to the Triangle class.
We don't need to define them again (code reusability). Triangle can be defined as follows.

class Triangle(Polygon): 
    def __init__(self): 
        Polygon.__init__(self,3) 
    def findArea(self): 
        a, b, c = self.sides # calculate the semi-perimeter 
        s = (a + b + c) / 2 
        area = (s*(s-a)*(s-b)*(s-c)) ** 0.5 
        print('The area of the triangle is %0.2f' %area)

However, class Triangle has a new method findArea() to find and print the area of the triangle. Here is a sample run.

t = Triangle()
t.inputSides() 
t.dispSides() 
t.findArea() 

Enter side 1 : 3
Enter side 2 : 5 
Enter side 3 : 4 

Side 1 is 3.0 
Side 2 is 5.0 
Side 3 is 4.0 

The area of the triangle is 6.00

We can see that even though we did not define methods like inputSides() or dispSides() for class Triangle separately, we were able to use them.
If an attribute is not found in the class itself, the search continues to the base class. This repeats recursively, if the base class is itself derived from other classes.

example:

class Area:
    def area(self):
        a=3.14*self.r**2
        print("area=",a)
    
class Circle(Area):#deriving a new class Circle from Area class
    def __init__(self,r=0):
        self.r=r
   def peri(self): # new method to find perimeter
        p=2*3.14*self.r
        print("Circum=",p)

C=Circle(3)
C.area()
C.peri()

Multiple Inheritance
A class can be derived from more than one base class in Python, similar to C++. This is called multiple inheritance.

In multiple inheritance, the features of all the base classes are inherited into the derived class. The syntax for multiple inheritance is similar to single inheritance.

Example
class Base1:
     pass 
class Base2: 
     pass 
class MultiDerived(Base1, Base2): 
     pass

Here, the MultiDerived class is derived from Base1 and Base2 classes.

When a class is derived from more than one base class it is called multiple Inheritance. The derived class inherits all the features of the base case.
example:
class Area:
    def __init__(self,r=0):
        self.r=r
    def area(self):
        a=3.14*self.r**2
        print("area=",a)

class Circum:
    def __init__(self,r=0):
        self.r=r
    def cir(self):
        p=2*3.14*self.r
        print("Circum=",p)

class Circle(Area,Circum):#deriving a class from two classes
    def area_circum(self):
        Area.area()
        Circum.cir()

a=Area(2)
a.area()
c=Circum(3)
c.cir()
C=Circle(3)
C.area_circum()
C.area()
C.cir()

Multilevel Inheritance
We can also inherit from a derived class. This is called multilevel inheritance. It can be of any depth in Python.In multilevel inheritance, features of the base class and the derived class are inherited into the new derived class.

An example  is given below.
class Base: 
     pass 
class Derived1(Base): 
     pass 
class Derived2(Derived1): 
     pass
Here, the Derived1 class is derived from the Base class, and the Derived2 class is derived from the Derived1 class.

example:
class Area:
    def __init__(self,r=0):
        self.r=r
    def area(self):
        a=3.14*self.r**2
        print("area=",a)

class Circum(Area):
    def cir(self):
        p=2*3.14*self.r
        print("Circum=",p)
        
class Circle(Circum):
    def area_circum(self):
        Area.area(self)
        Circum.cir(self)

c=Circle(3)
c.area_circum()

The Diamond problem

It refers to an ambiguity that arises when two classes Class2 and Class3 inherit from a superclass Class1 and class Class4 inherits from both Class2 and Class3. If there is a method “m” which is an overridden method in one of Class2 and Class3 or both then the ambiguity arises which of the method “m” Class4 should inherit.

When the method is overridden in both classes
# Python Program to depict multiple inheritance
# when method is overridden in both classes

class Class1:
def m(self):
print("In Class1") 
class Class2(Class1):
def m(self):
print("In Class2")

class Class3(Class1):
def m(self):
print("In Class3") 
class Class4(Class2, Class3):
pass
obj = Class4()
obj.m()

Output:
In Class2

Note: When you call obj.m() (m on the instance of Class4) the output is In Class2. If Class4 is declared as Class4(Class3, Class2) then the output of obj.m() will be In Class3.

When every class defines the same method
# Python Program to depict multiple inheritance
# when every class defines the same method

class Class1:
def m(self):
print("In Class1") 
class Class2(Class1):
def m(self):
print("In Class2")

class Class3(Class1):
def m(self):
print("In Class3")  
class Class4(Class2, Class3):
def m(self):
print("In Class4") 

obj = Class4()
obj.m()

Class2.m(obj)
Class3.m(obj)
Class1.m(obj)


Output:
In Class4 
In Class2 
In Class3 
In Class1

The output of the method obj.m() in the above code is In Class4. The method “m” of Class4 is executed. To execute the method “m” of the other classes it can be done using the class names.
Now, to call the method m for Class1, Class2, Class3 directly from the method “m” of the Class4 see the below example

# Python Program to depict multiple inheritance 
# when we try to call the method m for Class1, 
# Class2, Class3 from the method m of Class4 

class Class1:
def m(self):
print("In Class1") 
class Class2(Class1):
def m(self):
print("In Class2")

class Class3(Class1):
def m(self):
print("In Class3")  
class Class4(Class2, Class3):
def m(self):
print("In Class4") 
Class2.m(self)
Class3.m(self)
Class1.m(self)

obj = Class4()
obj.m()

Output:
In Class4 
In Class2 
In Class3 
In Class1
To call “m” of Class1 from both “m” of Class2 and “m” of Class3 instead of Class4 is shown below
# Python Program to depict multiple inheritance
# when we try to call m of Class1 from both m of
# Class2 and m of Class3

class Class1:
def m(self):
print("In Class1") 
class Class2(Class1):
def m(self):
print("In Class2")
Class1.m(self)

class Class3(Class1):
def m(self):
print("In Class3")
Class1.m(self) 
class Class4(Class2, Class3):
def m(self):
print("In Class4") 
Class2.m(self)
Class3.m(self)
obj = Class4()
obj.m()

Output:
In Class4 
In Class2 
In Class1 
In Class3 
In Class1

The output of the above code has one problem associated with it, the method m of Class1 is called twice. Python provides a solution to the above problem with the help of the super() function. Let’s see how it works.
# Python program to demonstrate
# super()

class Class1:
def m(self):
print("In Class1")

class Class2(Class1):
def m(self):
print("In Class2")
super().m()

class Class3(Class1):
def m(self):
print("In Class3")
super().m()

class Class4(Class2, Class3):
def m(self):
print("In Class4") 
super().m()
obj = Class4()
obj.m()

Output:
In Class4 
In Class2 
In Class3 
In Class1

Super() is generally used with the __init__ function when the instances are initialized. The super function comes to a conclusion, on which method to call with the help of the method resolution order (MRO).

Method Resolution Order in Python

Every class in Python is derived from the object class. It is the most base type in Python.

So technically, all other classes, either built-in or user-defined, are derived classes and all objects are instances of the object class.

print(issubclass(list,object)) 
# Output: True 

print(isinstance(5.5,object)) 
# Output: True 

print(isinstance("Hello",object))
# Output: True

In the multiple inheritance scenario, any specified attribute is searched first in the current class. If not found, the search continues into parent classes in depth-first, left-right fashion without searching the same class twice.

So, in the above example of MultiDerived class the search order is [MultiDerived, Base1, Base2, object]. This order is also called linearization of MultiDerived class and the set of rules used to find this order is called Method Resolution Order (MRO).

MRO must prevent local precedence ordering and also provide monotonicity. It ensures that a class always appears before its parents. In case of multiple parents, the order is the same as tuples of base classes.

MRO of a class can be viewed as the __mro__ attribute or the mro() method. The former returns a tuple while the latter returns a list.
>>> MultiDerived.__mro__ 
(<class '__main__.MultiDerived'>, 
 <class '__main__.Base1'>, 
 <class '__main__.Base2'>, 
 <class 'object'>) 
 >>> MultiDerived.mro() 
[<class '__main__.MultiDerived'>,
 <class '__main__.Base1'>, 
 <class '__main__.Base2'>, 
 <class 'object'>]

Example:
# Python program to demonstrate mro

class Class1:
    def m(self):
        print("In Class1")
class Class2(Class1):
    def m(self):
        print("In Class2")
        super().m()
class Class3(Class1):
    def m(self):
        print("In Class3")
        super().m()
class Class4(Class2, Class3):
     def m(self):
        print("In Class4")
        super().m()
        #This will print list
        print(Class4.mro()) 
        #This will print tuple
        print(Class4.__mro__) 

[<class '__main__.Class4'>, <class '__main__.Class2'>, <class '__main__.Class3'>, <class '__main__.Class1'>, <class 'object'>]

(<class '__main__.Class4'>, <class '__main__.Class2'>, <class '__main__.Class3'>, <class '__main__.Class1'>, <class 'object'>)


Polymorphism

Polymorphism is defined as the circumstance of occurring in several forms. It refers to the usage of a single type entity (method, operator, or object) to represent several types in various contexts. Polymorphism is made from 2 words – ‘poly‘ and ‘morphs.’ The word ‘poly’ means ‘many’ and ‘morphs’ means ‘many forms.’ Polymorphism, in a nutshell, means having multiple forms. To put it simply, polymorphism allows us to do the same activity in a variety of ways.

Polymorphism has the following advantages:
  • It is beneficial to reuse the codes.
  • The codes are simple to debug.
  • A single variable can store multiple data types
Polymorphism may be used in one of the following ways in an object-oriented language:
  • Overloading of operators
  • Class Polymorphism in Python
  • Method overriding, also referred to as Run time Polymorphism
  • Method overloading, also known as Compile time Polymorphism

Polymorphism is supported in Python via method overriding and operator overloading

Why do we need polymorphism?

Objects in object-oriented programming must take several shapes. This characteristic is very useful in software development. A single action can be done in multiple ways because to polymorphism. This notion is frequently used while discussing loose coupling, dependency injection, and interfaces, among other things. 

Method Overriding 

In some cases, the two classes have the same interface, or set of methods available to external users.In these cases, one or more methods in a subclass override the definition of the same methods in the super class to provide specialised versions of the abstract behaviour. Python supports this capability with polymorphic methods. The term polymorphic means "many bodies". and it applies to two methods that have the same header but have different definitions in different classes.

Method overriding is an ability of any object-oriented programming language that allows a subclass or child class to provide a specific implementation of a method that is already provided by one of its super-classes or parent classes. When a method in a subclass has the same name, same parameters or signature and same return type(or sub-type) as a method in its super-class, then the method in the subclass is said to override the method in the super-class.

Like other abstraction mechanisms, polymorphic methods make code easier to understand and use, because the programmer does not have to remember so many different names.

Example:
In Python, method overriding is achieved by defining a method in a child class with the same name and signature as a method in the parent class. The child class method can then override the parent class method by providing its own implementation.
For example, the following code shows a simple example of method overriding in Python:

class Animal:
    def speak(self):
        pass

class Dog(Animal):
    def speak(self):
        print("Woof!")

class Cat(Animal):
    def speak(self):
        print("Meow!")

dog = Dog()
dog.speak() # Prints "Woof!"

cat = Cat()
cat.speak() # Prints "Meow!"

Operator Overloading
Operator overloading is another kind of polymorphism. Python operators work for built-in classes. But the same operator behaves differently with different types. For example, the + operator will perform arithmetic addition on two numbers, merge two lists, or concatenate two strings.

This feature in Python that allows the same operator to have different meaning according to the context is called operator overloading.
class Point: 
     def __init__(self, x=0, y=0): 
         self.x = x 
         self.y = y 
     def __str__(self): 
         return "({0},{1})".format(self.x, self.y) 
     def __add__(self, other): 
         x = self.x + other.x 
         y = self.y + other.y 
         return Point(x, y) 

p1 = Point(1, 2) 
p2 = Point(2, 3) 
print(p1+p2)
Output
(3,5)
What actually happens is that, when you use p1 + p2, Python calls p1.__add__(p2) which in turn is Point.__add__(p1,p2). After this, the addition operation is carried out the way we specified.
Similarly, we can overload other operators as well. The special function that we need to implement is tabulated below.
Operator                        Expression                Internally
Addition                         p1 + p2                    p1.__add__(p2)
Subtraction                     p1 - p2                    p1.__sub__(p2)
Multiplication                p1 * p2                    p1.__mul__(p2)
Power                             p1 ** p2                  p1.__pow__(p2)
Division                         p1 / p2                     p1.__truediv__(p2)
Floor Division               p1 // p2                    p1.__floordiv__(p2)
Remainder (modulo)     p1 % p2                   p1.__mod__(p2)
Bitwise Left Shift          p1 << p2                 p1.__lshift__(p2)
Bitwise Right Shift        p1 >> p2                 p1.__rshift__(p2)
Bitwise AND                 p1 & p2                   p1.__and__(p2)
Bitwise OR                    p1 | p2                     p1.__or__(p2)
Bitwise XOR                 p1 ^ p2                    p1.__xor__(p2)
Bitwise NOT                ~p1                           p1.__invert__()


Overloading Comparison Operators

Python does not limit operator overloading to arithmetic operators only. We can overload comparison operators as well.

Suppose we wanted to implement the less than symbol < symbol in our Point class.

Let us compare the magnitude of these points from the origin and return the result for this purpose. It can be implemented as follows.
# overloading the less than operator 
class Point: 
     def __init__(self, x=0, y=0): 
         self.x = x 
         self.y = y 
     def __str__(self): 
         return "({0},{1})".format(self.x, self.y) 
     def __lt__(self, other): 
         self_mag = (self.x ** 2) + (self.y ** 2) 
         other_mag = (other.x ** 2) + (other.y ** 2)
         return self_mag < other_mag 
p1 = Point(1,1) 
p2 = Point(-2,-3)
p3 = Point(1,-1) 
 # use less than 
print(p1<p2) 
print(p2<p3) 
print(p1<p3)
Output
True 
False 
False
Similarly, the special functions that we need to implement, to overload other comparison operators are tabulated below.

Operator                                       Expression                            Internally
Less than                                       p1 < p2                                 p1.__lt__(p2)
Less than or equal to                     p1 <= p2                               p1.__le__(p2)
Equal to                                         p1 == p2                               p1.__eq__(p2)
Not equal to                                   p1 != p2                                p1.__ne__(p2)
Greater than                                   p1 > p2                                 p1.__gt__(p2)
Greater than or equal to                 p1 >= p2                               p1.__ge__(p2)

Comments

Popular posts from this blog

Python For Machine Learning - CST 283 - KTU Minor Notes- Dr Binu V P

46.Classes and Objects in Python- Accessors and mutators

KTU Python for machine learning Sample Question Paper and Answer Key Dec 2020