Inheritance and Polymorphism in Python: super() and ABCs

By Engr. Henry Akpan

Reusing Code Without Copying It

A school system has students, lecturers and administrative staff. They all have a name, an email address and an ID, but each also has something special: students have a level, lecturers have courses, and staff have a department. Inheritance and polymorphism in Python handle this neatly. Copying the same code into three separate classes would be wasteful and error-prone. Object-oriented programming solves this with inheritance: shared code goes in a parent class, and each child class adds or changes only what is different. Closely linked is polymorphism: different classes responding to the same method call in their own way. This tutorial covers both ideas: parent and child classes, method overriding, super(), isinstance() and issubclass(), polymorphism and duck typing, abstract base classes, multiple inheritance and the method resolution order, and when to prefer composition. It assumes you have read the tutorial on Python classes and objects. All outputs shown come from running the code on Python 3.13.

A Parent Class and a Child Class

To inherit from a class, put the parent's name in brackets after the child's name. The child automatically gets all the parent's attributes and methods.

class Person:
    def __init__(self, name, email):
        self.name = name
        self.email = email

    def greet(self):
        return f"Hello, I am {self.name}."

    def contact(self):
        return f"{self.name} ({self.email})"


class Student(Person):
    pass


s = Student("Ada Obi", "ada@uni.edu.ng")
print(s.greet())
print(s.contact())

Output:

Hello, I am Ada Obi.
Ada Obi (ada@uni.edu.ng)

Student has no code of its own, yet it can greet() and give a contact() line, because it inherited them from Person. We say Person is the parent (base class or superclass), and Student is the child (derived class or subclass). Inheritance models an "is a" relationship: a student is a person.

Extending the Parent with super()

Usually a child needs extra attributes. It defines its own __init__, and calls the parent's __init__ with super() so the shared setup is not repeated:

class Student(Person):
    def __init__(self, name, email, level):
        super().__init__(name, email)   # let Person set name and email
        self.level = level

    def describe(self):
        return f"{self.name} is a {self.level}-level student."


class Lecturer(Person):
    def __init__(self, name, email, courses):
        super().__init__(name, email)
        self.courses = courses

    def describe(self):
        return f"{self.name} teaches {', '.join(self.courses)}."


ada = Student("Ada Obi", "ada@uni.edu.ng", 200)
dr_bello = Lecturer("Dr Bello", "bello@uni.edu.ng", ["CSC 201", "CSC 305"])
print(ada.describe())
print(dr_bello.describe())
print(dr_bello.greet())

Output:

Ada Obi is a 200-level student.
Dr Bello teaches CSC 201, CSC 305.
Hello, I am Dr Bello.

If you forget to call super().__init__(), the parent's setup never runs, and using self.name later raises an AttributeError.

Overriding Methods

A child can override a parent method by defining a method with the same name. Python always looks in the child class first. The child can still call the parent's version through super() and build on it:

class Lecturer(Person):
    def __init__(self, name, email, courses):
        super().__init__(name, email)
        self.courses = courses

    def greet(self):
        basic = super().greet()
        return basic + f" I lecture {len(self.courses)} courses."


dr_bello = Lecturer("Dr Bello", "bello@uni.edu.ng", ["CSC 201", "CSC 305"])
print(dr_bello.greet())
print(Person("Musa", "musa@uni.edu.ng").greet())

Output:

Hello, I am Dr Bello. I lecture 2 courses.
Hello, I am Musa.

Checking Types: isinstance() and issubclass()

print(isinstance(ada, Student))
print(isinstance(ada, Person))
print(isinstance(ada, Lecturer))
print(issubclass(Lecturer, Person))
print(type(ada).__name__)
print([cls.__name__ for cls in Student.__mro__])

Output:

True
True
False
True
Student
['Student', 'Person', 'object']

A student is also a person, so isinstance(ada, Person) is True. Every class ultimately inherits from Python's built-in object. The __mro__ attribute lists the order in which Python searches classes for a method, explained further below.

Polymorphism

Polymorphism ("many forms") means that code can call the same method on different kinds of objects, and each object responds in its own way. The calling code does not need if statements to check the type.

class Shape:
    def area(self):
        return 0

class Rectangle(Shape):
    def __init__(self, w, h):
        self.w, self.h = w, h
    def area(self):
        return self.w * self.h

class Circle(Shape):
    def __init__(self, r):
        self.r = r
    def area(self):
        return round(3.14159 * self.r ** 2, 2)

class Triangle(Shape):
    def __init__(self, b, h):
        self.b, self.h = b, h
    def area(self):
        return 0.5 * self.b * self.h

shapes = [Rectangle(4, 5), Circle(3), Triangle(6, 2)]
for shape in shapes:
    print(f"{type(shape).__name__:10} area = {shape.area()}")
print("Total:", round(sum(s.area() for s in shapes), 2))

Output:

Rectangle  area = 20
Circle     area = 28.27
Triangle   area = 6.0
Total: 54.27

The loop simply calls shape.area(). To add a new shape, you write a new class, and the loop keeps working without a single change. This is the real power of polymorphism: code that is open for extension without being edited.

Duck Typing

Python is relaxed about types. Polymorphism does not even require a shared parent class; an object only needs the right method. This is called duck typing: "if it walks like a duck and quacks like a duck, it is a duck."

class Square:            # not a subclass of Shape
    def __init__(self, s):
        self.s = s
    def area(self):
        return self.s ** 2

print(sum(s.area() for s in [Rectangle(2, 3), Square(4)]))
print(len("hello"), len([1, 2, 3]), len({"a": 1}))

Output:

22
5 3 1

Built-in functions like len() are polymorphic in the same way: they work with any object that defines the matching special method, as covered in the tutorial on magic methods.

Abstract Base Classes

In the Shape example, nothing stops someone from forgetting to write area() in a new subclass; it would silently return 0. An abstract base class (ABC) makes a method compulsory. You cannot create an object of the abstract class itself, or of a subclass that has not implemented every abstract method.

from abc import ABC, abstractmethod

class Payment(ABC):
    def __init__(self, amount):
        self.amount = amount

    @abstractmethod
    def fee(self):
        """Every payment method must say what it charges."""

    def total(self):
        return self.amount + self.fee()

class Card(Payment):
    def fee(self):
        return round(self.amount * 0.015, 2)

class Transfer(Payment):
    def fee(self):
        return 50 if self.amount > 10_000 else 10

for p in [Card(20_000), Transfer(20_000), Transfer(5_000)]:
    print(type(p).__name__, p.total())

try:
    Payment(1000)
except TypeError as e:
    print("Error:", e)

Output:

Card 20300.0
Transfer 20050
Transfer 5010
Error: Can't instantiate abstract class Payment without an implementation for abstract method 'fee'

The abstract class also provides shared behaviour: total() is written once in Payment, yet it calls each subclass's own fee(). This pattern, where a parent defines the steps and children fill in the details, is called the template method pattern.

Multiple Inheritance and the MRO

A Python class can inherit from more than one parent. A common use is a small mixin class that adds one feature:

class JSONMixin:
    def to_json(self):
        import json
        return json.dumps(self.__dict__)

class TeachingAssistant(JSONMixin, Student):
    def __init__(self, name, email, level, course):
        super().__init__(name, email, level)
        self.course = course

ta = TeachingAssistant("Chidi Eze", "chidi@uni.edu.ng", 400, "CSC 101")
print(ta.describe())
print(ta.to_json())
print([cls.__name__ for cls in TeachingAssistant.__mro__])

Output:

Chidi Eze is a 400-level student.
{"name": "Chidi Eze", "email": "chidi@uni.edu.ng", "level": 400, "course": "CSC 101"}
['TeachingAssistant', 'JSONMixin', 'Student', 'Person', 'object']

When several parents are involved, Python searches for methods in the method resolution order (MRO): the class itself, then its parents from left to right, and so on, each class appearing only once. super() follows the MRO, which is why it is preferred over calling a parent class by name. Keep multiple inheritance simple: mixins that add small, independent features are fine, but deep, tangled hierarchies are hard to understand.

Inheritance or Composition?

Inheritance is not always the right tool. Use it for a genuine "is a" relationship. For a "has a" relationship, use composition: store another object as an attribute.

class Course:
    def __init__(self, code, units):
        self.code, self.units = code, units

class Timetable:                       # a timetable HAS courses
    def __init__(self):
        self.courses = []
    def add(self, course):
        self.courses.append(course)
    def total_units(self):
        return sum(c.units for c in self.courses)

t = Timetable()
t.add(Course("CSC 201", 3))
t.add(Course("MTH 201", 2))
print(t.total_units())

Output:

5

A timetable is not a kind of course, so inheriting from Course would make no sense. A widely quoted guideline is "favour composition over inheritance": it keeps classes small and loosely connected.

Inheritance and Polymorphism in Python: Practice Exercises

  1. Create a Staff subclass of Person with a department attribute and its own describe() method.
  2. Add a perimeter() method to every shape class and print the total perimeter of a list of shapes.
  3. Make Shape an abstract base class so that area() is compulsory.
  4. Write an abstract Account class with an abstract interest_rate() method, and Savings and Current subclasses.

Conclusion

Inheritance lets a child class reuse and extend a parent class: write class Child(Parent), call super().__init__() to run the shared setup, and override methods to change behaviour. Polymorphism lets one piece of code work with many classes through a common method name, and duck typing means Python only cares that the method exists. Abstract base classes enforce that required methods are implemented, the MRO decides which method runs under multiple inheritance, and composition is often the better choice when one object simply has another.

Related Tutorials