Python
Class Inheritance

Class Inheritance in Python

In object-oriented programming, class inheritance is a powerful mechanism that allows you to create new classes(subclass) based on existing ones(superclass). This concept forms the foundation for building more complex and structured software systems. Inheritance enables you to model relationships between classes, reuse code, and establish a hierarchy of classes with shared attributes and behaviors.

Creating Subclass

We're going to create a Vehicle class and then create a Car class that inherits from it.

class Vehicle:
    def __init__(self, make, model, year):
        self.make = make
        self.model = model
        self.year = year
 
    def start_engine(self):
        print(f"The {self.model}'s engine is now running.")
 
    def stop_engine(self):
        print(f"The {self.model}'s engine has stopped.")
 
 
class Car(Vehicle):
    def __init__(self, make, model, year, doors):
        super().__init__(make, model, year)
        self.doors = doors
 
    def lock_doors(self):
        print(f"All {self.doors} doors are now locked.")
 
    def unlock_doors(self):
        print(f"All {self.doors} doors are now unlocked.")

Key Concepts in Inheritance

  • Superclass (Base Class): In the above example, Vehicle is the superclass. It serves as the blueprint for common attributes and methods that will be inherited by subclasses.

  • Subclass (Derived Class): Car is a subclass. It inherits attributes and methods from the Vehicle superclass while also having the ability to define their own unique attributes and methods. The Car class inherits the start_engine and stop_engine methods from Vehicle, and it also defines its own methods lock_doors and unlock_doors.

  • super function: In class inheritance, the super() function plays a crucial role in facilitating communication between subclasses and superclasses. It allows subclasses to access and utilize methods and attributes from their superclasses. The super().__init__(make, model, year) line is used to call the initializer of the parent class, which sets the make, model, and year attributes. The Car class also defines an additional attribute doors.

  • "Is-a" Relationship: In Python class inheritance, the "is-a" relationship refers to the relationship between a base class and a derived class. A derived class is a specialized version of the base class. In other words, a derived class "is-a" base class. This relationship highlights the hierarchy of classes and the specialization of subclasses.

Using the Subclass

Now that we have the Vehicle and Car classes, let's create instances and see how inheritance works:

# Creating an instance of Car
my_car = Car("Toyota", "Corolla", 2020, 4)
 
# Calling methods from the parent class
my_car.start_engine()  # Output: The Corolla's engine is now running.
my_car.stop_engine()  # Output: The Corolla's engine has stopped.
 
# Calling methods from the child class
my_car.lock_doors()  # Output: All 4 doors are now locked.
my_car.unlock_doors()  # Output: All 4 doors are now unlocked.
  1. my_car = Car("Toyota", "Corolla", 2020, 4) This line creates an instance of the Car class named my_car. The Car class requires four arguments to instantiate: make, model, year, and doors. In this case, we're creating a Car object that represents a 2020 Toyota Corolla with 4 doors.

  2. my_car.start_engine() and my_car.stop_engine() These lines calls the start_engine and stop_enginemethods on the my_car object. Because the Car class inherits from the Vehicle class, it has access to these methods defined in the Vehicle class. This method prints a message indicating that the car's engine is now running.

  3. my_car.lock_doors() & my_car.unlock_doors() These lines calls the lock_doors and unlock_doorsmethods on the my_car object. These method are defined in the Car subclass and unique to the subclass

Benefits of Class Inheritance

  1. Code Reusability: Inheritance promotes code reuse by allowing subclasses to inherit attributes and methods from a common superclass. This reduces redundancy and ensures a more efficient development process.

  2. Modularity: Superclasses encapsulate shared behaviors and attributes, creating a modular code structure. Subclasses can then focus on specific details while relying on the inherited behavior.

  3. Customization: Subclasses can customize inherited methods by overriding them. This enables subclasses to provide their own implementation of behaviors while maintaining a consistent interface.

Method Overriding

Method overriding is a key part of object-oriented programming that allows a subclass to provide a different implementation of a method that is already defined in its superclass. This mechanism enables specialized behavior in subclasses while adhering to the same method signature.

Here's an example:

class Animal:
    def make_sound(self):
        print("The animal makes a sound")
 
class Dog(Animal):
    def make_sound(self):
        print("The dog barks")
 
# Create instances of Animal and Dog
a = Animal()
d = Dog()
 
# Call make_sound method
a.make_sound()  # Output: The animal makes a sound
d.make_sound()  # Output: The dog barks

In this example, Animal is the superclass and Dog is the subclass. Both classes have a method named make_sound. When make_sound is called on an instance of Animal, it prints "The animal makes a sound". But when make_sound is called on an instance of Dog, it prints "The dog barks". This is because Dog has overridden the make_sound method of Animal.

More on super() Function

The super() function provides a way for subclasses to invoke methods and access attributes of their superclass. It is used to call methods in the superclass and can be particularly useful when you want to extend or customize the behavior of the superclass's method.

Sure, let's dive deeper into the super() function in Python.

1. Extending a Constructor with super():

The super() function is often used in the constructor method (__init__) of a subclass to ensure that the initialization code of the superclass is executed before the code of the subclass. Here's an example:

class Animal:
    def __init__(self, name, species):
        self.name = name
        self.species = species
 
class Dog(Animal):
    def __init__(self, name, breed):
        super().__init__(name, "Dog")  # Call the constructor of Animal
        self.breed = breed
 
# Create an instance of Dog
d = Dog("Fido", "Labrador")
print(d.name)    # Output: Fido
print(d.species) # Output: Dog
print(d.breed)   # Output: Labrador

In this example, Dog is a subclass of Animal. When an instance of Dog is created, super().__init__(name, "Dog") is used to call the constructor of Animal to set the name and species attributes. Then, Dog's constructor continues to set the breed attribute.

2. Using super() for Method Invocation:

The super() function can also be used to call other methods from the superclass that have been overridden in the subclass. Here's an example:

class Animal:
    def make_sound(self):
        print("The animal makes a sound")
 
class Dog(Animal):
    def make_sound(self):
        super().make_sound()  # Call the make_sound method of Animal
        print("The dog barks")
 
# Create an instance of Dog
d = Dog()
d.make_sound()
# Output:
# The animal makes a sound
# The dog barks

In this example, Dog is a subclass of Animal and both classes have a make_sound method. When make_sound is called on a Dog instance, super().make_sound() is used to call the make_sound method of Animal before the rest of Dog's make_sound method is executed. Compare this with method overriding implementation above to understand the difference better.

Multiple Inheritance

Multiple inheritance is a feature in object-oriented programming that allows a class to inherit attributes and methods from multiple superclasses. This concept enables the creation of complex relationships and behaviors by combining the attributes and methods of different classes.

Here's a basic example of multiple inheritance:

class Parent1:
    def method1(self):
        print("This is from Parent1")
 
class Parent2:
    def method2(self):
        print("This is from Parent2")
 
class Child(Parent1, Parent2):
    pass
 
# Create an instance of Child
child = Child()
 
# Call methods from Parent1 and Parent2
child.method1()  # Output: This is from Parent1
child.method2()  # Output: This is from Parent2

In this example, Child inherits from both Parent1 and Parent2, and it has access to method1 from Parent1 and method2 from Parent2.

Method Resolution Order (MRO)

However, what happens if both parent classes have a method of the same name? Python resolves this using the Method Resolution Order (MRO), which is determined by the C3 linearization algorithm. The MRO dictates the order in which Python will look for methods in superclass hierarchies.

Here's an example:

class Parent1:
    def method(self):
        print("This is from Parent1")
 
class Parent2:
    def method(self):
        print("This is from Parent2")
 
class Child(Parent1, Parent2):
    pass
 
# Create an instance of Child
child = Child()
 
# Call the method
child.method()  # Output: This is from Parent1

In this case, Parent1's method is called, because Parent1 appears before Parent2 in the MRO of Child. You can view the MRO using the mro method:

print(Child.mro())  # Output: [<class '__main__.Child'>, <class '__main__.Parent1'>, <class '__main__.Parent2'>, <class 'object'>]

This shows that Python will look for methods in the order: Child, Parent1, Parent2, object.

More on Method Resolution Order (MRO)

Method Resolution Order (MRO) is the order in which Python looks for a method in a hierarchy of classes. Mainly, it comes into play when dealing with multiple inheritance, as it can get pretty complicated when several parents have the same method and you need to figure out which one gets called.

Here is an example:

class A:
    def process(self):
        print('A process()')
 
class B(A):
    pass
 
class C(A):
    def process(self):
        print('C process()')
 
class D(B, C):
    pass
 
obj = D()
obj.process()  
# Output: C process()

In this example, both A and C classes have a method named process. D is a subclass of B and C. When process is called on an instance of D, Python needs to decide whether A's process or C's process should be called.

Python uses the C3 linearization or MRO algorithm to make this decision. The MRO order for class D is D -> B -> C -> A -> object. So, Python first looks in class D. If D doesn't have the method, Python looks in B, then in C, then in A, and finally in object (which is the ultimate base class of all classes in Python). In this case, Python finds the process method in C first, so C process() is printed.

You can view the MRO of a class using the mro method:

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

Comprehensive Example

Let's explore a more practical example to demonstrate class inheritance, accessing superclass methods, method overriding by a subclass, and using the super() function. In this example, we'll model a simple banking system with different types of accounts.

class BankAccount:
    def __init__(self, account_number, balance):
        self.account_number = account_number
        self.balance = balance
    
    def deposit(self, amount):
        self.balance += amount
    
    def withdraw(self, amount):
        if self.balance >= amount:
            self.balance -= amount
        else:
            print("Insufficient balance")
    
    def display_balance(self):
        print(f"Account {self.account_number}: Balance = ${self.balance:.2f}")
 
class SavingsAccount(BankAccount):
    def __init__(self, account_number, balance, interest_rate):
        super().__init__(account_number, balance)  # Call superclass constructor
        self.interest_rate = interest_rate
    
    def calculate_interest(self):
        return self.balance * self.interest_rate
 
class CheckingAccount(BankAccount):
    def __init__(self, account_number, balance, overdraft_limit):
        super().__init__(account_number, balance)  # Call superclass constructor
        self.overdraft_limit = overdraft_limit
    
    def withdraw(self, amount):  # Method overriding
        if self.balance + self.overdraft_limit >= amount:
            self.balance -= amount
        else:
            print("Exceeds overdraft limit")
 
# Creating instances
savings = SavingsAccount("12345", 1000, 0.05)
checking = CheckingAccount("67890", 500, 200)
 
# Using methods on savings account
savings.deposit(500)
savings.withdraw(200)
savings.display_balance()
print("Interest:", savings.calculate_interest())
 
# Using methods on checking accounts
checking.withdraw(100)
checking.display_balance()
checking.withdraw(500)
checking.display_balance()
checking.withdraw(500)
checking.display_balance()
Account 12345: Balance = $1400.00 
Interest: 70.0 

Account 67890: Balance = $400.00 
Account 67890: Balance = $-100.00 
Exceeds overdraft limit 
Account 67890: Balance = $-100.00

In this example:

  1. We define a BankAccount class as the superclass with basic methods to deposit, withdraw, and display balance.
  2. The SavingsAccount class inherits from BankAccount and introduces an interest_rate attribute. It overrides the deposit method from the superclass to calculate interest.
  3. The CheckingAccount class inherits from BankAccount and introduces an overdraft_limit attribute. It overrides the withdraw method from the superclass to allow overdrafts within the limit.
  4. We create instances of SavingsAccount and CheckingAccount to showcase the various aspects of class inheritance:
    • The SavingsAccount instance utilizes the inherited methods and attributes, as well as the overridden calculate_interest method.
    • The CheckingAccount instance demonstrates method overriding by customizing the withdraw method to accommodate overdrafts within the limit.