Python highly embraces the DRY(Don't Repeat Yourself) principle. It discourages programmers from having duplicate or similar code at various points in the program. The language itself is designed to minimize repetition and increase maintainability.

When defining classes you will likely come across a scenario where one class is just a small alteration of another class.  Or one class has features that are  similar to another class. Writing each of such classes from scratch will not only be against the DRY principle it will also be inefficient and thus increase the complexity of your code. 

Inheritance is a concept in object-oriented programming  in which a class inherits or acquires all the attributes and methods of another class. It makes it possible for a class to inherit and build upon the existing attributes and methods of another class instead of re-defining them from scratch. This helps in reducing redundant code and makes the code more organized and efficient.

The class that inherits behavior from another class is known as the derived class child class or Subclass. The class from which the derived class inherits behavior is known as the base classparent class or Superclass.

Inheritance in Python classes

Python offers an excellent and intuitive support for class inheritance.

Technically any class in Python uses inheritance this is because all classes are subclasses of the special builtin class named object

class MyClass:
    pass

print(object)
print(issubclass(MyClass, object))

The builtin issubclass() function checks whether one class is a subclass of another. i.e The class specified as the first argument is a subclass of the class specified as the second argument.

Essentially any Python class will automatically inherit from the class object when created, so we do not need to state this explicitly. Sometimes, however, we need  our classes to inherit features from custom classes. The syntax for achieving this is as shown below:

#Define the baseclass
class BaseClass:
     #baseclass body

#define the subclass
class SubClass(BaseClass):
    #subclass body

When you do that, all the attributes and methods of the base class are inherited by the derived class.

There is technically no  difference between the following classes. It is only that in the first case the inheritance happens under the hood while in the second case we state  explicitly that our class should inherit from the special class, object

class CustomClass:
     pass

class CustomClass(object):
   pass

Consider the following example.

#Define a parent class
class Person:
   #Define the class constructor
   def __init__(self, fname, lname, age):
       self.first_name = fname
       self.last_name = lname
       self.age = age
   #This method will return the details of the current instance
   def get_details(self):
       return "%s %s, %s"%(self.first_name, self.last_name, self.age)

#Define a subclass of the class Person
class Employee(Person):
    def is_employee(self):
         return True

#Use the issubclass function to check whether Employee is a subclass of Person
print(issubclass(Employee, Person))

#create an emplyee objects
e = Employee('John', 'Doe', 30)

#Call the methods of the created object.
print(e.get_details())
print(e.is_employee())

Note: After a class inherits from another class, we  can effectively call the methods and attributes that were inherited.

Alongside the inherited ones, the sub class can define its own unique attributes and methods .  In the above example, the subclass, Employee , defines a method is_employee() which doesn't exist in the Parent class.

overriding methods of the super class

A subclass can override a method defined in the superclass by defining another method with the same name. This allows the subclass to provide a different implementation of the same method as that in the superclass, and customize its behavior without changing the behavior of the superclass. Consider the following example:

#Define a parent class
class Person:
   def __init__(self, fname, lname, age):
       self.first_name = fname
       self.last_name = lname
       self.age = age
   def get_details(self):
       return "%s %s, {}"%(self.first_name, self.last_name, self.age)

#Define a subclass of the class Person
class Employee(Person):
    def is_employee(self):
         return True
    #Override the get_details from the parent class
    def get_details(self):
        return "Employee %s %s, age %s"%(self.first_name, self.last_name, self.age)


#create an emplyee objects
e = Employee('John', 'Doe', 30)

#Call the methods of the created object.
print(e.get_details())

In the above case, the Employee class  effectively overrides get_details method of the parent class.

Extending the methods of the super class- The super() function

In some cases we do not want to entirely override the methods of the superclass. Consider what happens if for example, a sub class defined a method called __init__().  In this case, the __init__() method of the subclass will be called instead of the __init__() method of the superclass.

#Define super class
class BaseClass:

    #define the constructor of the base class
    def __init__(self):
       print("Base Constructor")

#Define the child class
class SubClass(BaseClass):

     #Define the child's constructor
     def __init__(self):
        print("Child Constructor")

demo = SubClass()

As shown above, only the child's constructor gets called.

The builtin super() function allows the child class to access the methods and properties of the parent class. The function actually returns the super class itself.

#Define super class
class BaseClass:

    #define the constructor of the base class
    def __init__(self):
       pass

#Define the child class
class SubClass(BaseClass):

     #Define the child's constructor
     def __init__(self):
        print(super())

demo = SubClass()

Using the super() function, we can call methods of the super class from the child class. For Example:

#Define super class
class BaseClass:

    #define the constructor of the base class
    def __init__(self):
       print("Base Constructor")

#Define the child class
class SubClass(BaseClass):

     #Define the child's constructor
     def __init__(self):
        super().__init__()
        print("Child Constructor")

demo = SubClass()
class Car:
   def some_method(self):
       print("This a car")

class Audi(Car):
    def some_method(self):
        super().some_method()
        print("This is an Audi")

a = Audi()

a.some_method()

To demonstrate the super function better, let us improve our Person and Employee classes.

#Define a parent class
class Person:
   def __init__(self, fname, lname, age):
       self.first_name = fname
       self.last_name = lname
       self.age = age
   def get_details(self):
       return "%s %s, {}"%(self.first_name, self.last_name, self.age)

#Define a subclass of the class Person
class Employee(Person):
    def __init__(self, fname, lname, age,  employer, salary):
        super().__init__(fname, lname, age)
        self.employer = employer
        self.salary = salary

  
    #Override the get_details from the parent class
    def get_details(self):
        return "Employee %s %s, age %s"%(self.first_name, self.last_name, self.age)


#create an emplyee objects
e = Employee('John', 'Doe', 30, 'Facebook', 200000)

#Call the methods of the created object.
print(e.get_details())

Multiple Inheritance

Multiple inheritance is where a class inherits from more than one parent class. The definition is that simple, however, the practical implications can get much more complicated.

class Base1:
    def method_A(self): 
        print("Method A is called") 

class Base2: 
   def method_B(self):
      print("Method B is called") 

class Demo(Base1, Base2): 
    def method_C(self):
        print("Method C is called") 

d = Demo()
d.method_A()
d.method_B() 
d.method_C()

In multiple inheritance a class inherits all methods from each of the base classes. This can make the code unnecessarily complicated and hard to debug especially if the various base classes have conflicting properties and methods. You should, therefore, avoid multiple inheritance unless you really know what you are doing.

In fact some languages such as Java do not support multiple inheritance to prevent ambiguity.

Extending the Builtin data types

We now have a solid idea on what inheritance involves and how we can use it in Python classes. let us now mess a little bit with the builtin types.
Consider if we want to create a fixed list from the Python list such that if the list has a maximum length. If the maximum length is specified any additional items added to the list causes the list to discard the oldest item.

class FixedList(list):
     #Define the constructor
     def __init__(self, length= 5):
        super().__init__()
        self.max_length = length #The maximum length of the list, it defaults to 10

     #Modify the append method of the list class to be aware of the maximum length
     def append(self, item):
         if len(self) == self.max_length:
             self.pop(0)
         super().append(item)


fixed_list = FixedList(3)

fixed_list.append(1)
fixed_list.append(2)
fixed_list.append(3)
print(fixed_list)
fixed_list.append(4)
print(fixed_list)
fixed_list.append(100)
print(fixed_list)