Python inheritance polymorphism super override abstract classes guide FP2

Inheritance and polymorphism in Python — super(), override and abstract classes

Python Inheritance is the mechanism that lets one class reuse and extend the behaviour of another. If you’ve already built the Student, BankAccount and Product classes from the previous articles, you’ve seen that different classes can share a lot of structure — a Student and a Teacher are both Person objects, a SavingsAccount and a CurrentAccount are both BankAccount objects. Inheritance is how you express and exploit those relationships without repeating code.

This article covers everything: the is-a relationship, super(), method overriding, isinstance(), multiple inheritance and abstract classes with @abstractmethod.

The is-a relationship — when to use inheritance

Inheritance models the “is-a” relationship. Before creating a subclass ask yourself: “is a [subclass] a [superclass]?” If yes, inheritance makes sense. If not, use composition (one class containing an instance of another).

# IS-A relationships — good candidates for inheritance
class Animal: pass
class Dog(Animal): pass         # A Dog IS AN Animal ✓
class Cat(Animal): pass         # A Cat IS AN Animal ✓

class Person: pass
class Student(Person): pass     # A Student IS A Person ✓
class Teacher(Person): pass     # A Teacher IS A Person ✓

# HAS-A relationships — use composition instead
class Engine: pass
class Car: pass
# A Car HAS AN Engine — not IS AN Engine
# → car = Car(); car.engine = Engine()  ← composition, not inheritance

Basic inheritance syntax

class Animal:
    def __init__(self, name, species):
        self.name = name
        self.species = species

    def eat(self):
        return f'{self.name} is eating'

    def __str__(self):
        return f'{self.name} ({self.species})'


class Dog(Animal):      # Dog inherits from Animal
    def __init__(self, name, breed):
        super().__init__(name, 'Canis lupus familiaris')   # call parent __init__
        self.breed = breed

    def bark(self):     # method unique to Dog
        return f'{self.name} barks!'

    def __str__(self):  # override parent's __str__
        return f'{self.name} — {self.breed}'
rex = Dog('Rex', 'German Shepherd')

# Inherited from Animal
print(rex.eat())      # → Rex is eating
print(rex.species)    # → Canis lupus familiaris

# Own method
print(rex.bark())     # → Rex barks!

# Overridden __str__
print(rex)            # → Rex — German Shepherd (Dog's version, not Animal's)

super() — calling the parent class

super() gives you access to the parent class. Its most common use is in __init__ to initialise the parent’s attributes before adding the subclass’s own:

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

    def introduce(self):
        return f'I am {self.name}, {self.age} years old'


class Student(Person):
    def __init__(self, name, age, student_id, degree):
        super().__init__(name, age)    # initialise Person first
        self.student_id = student_id   # then add Student's own attrs
        self.degree = degree
        self.grades = []

    def introduce(self):               # override
        base = super().introduce()     # call Person's introduce()
        return f'{base}, studying {self.degree}'
s = Student('Sergio', 20, 'S001', 'GCID')
print(s.name)          # → Sergio (from Person)
print(s.degree)        # → GCID (from Student)
print(s.introduce())   # → I am Sergio, 20 years old, studying GCID

The golden rule: call super().__init__() as the first line of a subclass __init__. This ensures the parent is fully initialised before you start adding subclass-specific attributes.

Method overriding

A subclass can redefine any method inherited from the parent. The subclass version replaces the parent’s version for that class and all its subclasses:

class Shape:
    def __init__(self, colour):
        self.colour = colour

    def area(self):
        return 0    # default — subclasses should override this

    def describe(self):
        return f'{self.colour} shape with area {self.area():.2f}'


class Circle(Shape):
    def __init__(self, colour, radius):
        super().__init__(colour)
        self.radius = radius

    def area(self):                 # override
        import math
        return math.pi * self.radius ** 2


class Rectangle(Shape):
    def __init__(self, colour, width, height):
        super().__init__(colour)
        self.width = width
        self.height = height

    def area(self):                 # override
        return self.width * self.height
shapes = [Circle('red', 5), Rectangle('blue', 4, 6)]

for shape in shapes:
    print(shape.describe())
# → red shape with area 78.54
# → blue shape with area 24.00

Notice that describe() is defined only in Shape — but when called on a Circle, it uses Circle.area(), not Shape.area(). Python always calls the method from the object’s actual class, not the class where the calling method is defined. This is polymorphism.

Polymorphism — one interface, many implementations

Polymorphism means that different objects can respond to the same method call in different ways. You’ve already seen it above — shape.describe() produces different results for Circle and Rectangle because area() is implemented differently.

class Animal:
    def __init__(self, name):
        self.name = name

    def sound(self):
        return '...'

    def describe(self):
        return f'{self.name} says: {self.sound()}'


class Dog(Animal):
    def sound(self): return 'Woof!'

class Cat(Animal):
    def sound(self): return 'Meow!'

class Duck(Animal):
    def sound(self): return 'Quack!'


animals = [Dog('Rex'), Cat('Whiskers'), Duck('Donald')]

for animal in animals:
    print(animal.describe())
# → Rex says: Woof!
# → Whiskers says: Meow!
# → Donald says: Quack!

The loop doesn’t know or care what type each animal is — it just calls describe() and Python dispatches to the right implementation. This is what makes polymorphism powerful: you can add a new animal type (say Lion) and the loop still works without any changes.

isinstance() and issubclass()

isinstance() checks whether an object is an instance of a class or any of its subclasses:

rex = Dog('Rex')

print(isinstance(rex, Dog))     # → True  (rex is a Dog)
print(isinstance(rex, Animal))  # → True  (Dog inherits from Animal)
print(isinstance(rex, Cat))     # → False

# issubclass() checks class relationships
print(issubclass(Dog, Animal))  # → True
print(issubclass(Cat, Dog))     # → False
print(issubclass(Dog, Dog))     # → True (a class is a subclass of itself)

isinstance() is the right tool when you need to know what type an object is at runtime — for example, to decide how to process it:

def make_sound_loud(animal):
    sound = animal.sound()
    if isinstance(animal, Dog):
        return sound.upper() + '!!'
    return sound

print(make_sound_loud(Dog('Rex')))   # → WOOF!!!
print(make_sound_loud(Cat('Mia')))   # → Meow!

Multiple inheritance

Python supports inheriting from more than one parent class:

class Flyable:
    def fly(self):
        return f'{self.name} is flying'

class Swimmable:
    def swim(self):
        return f'{self.name} is swimming'

class Duck(Animal, Flyable, Swimmable):
    def __init__(self, name):
        super().__init__(name)

    def sound(self):
        return 'Quack!'


donald = Duck('Donald')
print(donald.fly())     # → Donald is flying
print(donald.swim())    # → Donald is swimming
print(donald.sound())   # → Quack!
print(isinstance(donald, Flyable))    # → True
print(isinstance(donald, Swimmable)) # → True

MRO — Method Resolution Order

When a class inherits from multiple parents, Python uses a specific order to decide which parent’s method to call. This order is called MRO (Method Resolution Order) and follows the C3 linearisation algorithm:

class A:
    def method(self): return 'A'

class B(A):
    def method(self): return 'B'

class C(A):
    def method(self): return 'C'

class D(B, C):    # inherits from both B and C
    pass

d = D()
print(d.method())        # → 'B' — Python finds B first in MRO
print(D.__mro__)         # shows the full resolution order
# → (<class 'D'>, <class 'B'>, <class 'C'>, <class 'A'>, <class 'object'>)

The MRO is: D → B → C → A → object. Python searches left to right, depth-first. You can always check the MRO with ClassName.__mro__.

Abstract classes — forcing subclasses to implement methods

An abstract class defines the interface that subclasses must implement. You can’t create instances of an abstract class directly — it’s a blueprint for subclasses.

from abc import ABC, abstractmethod

class Shape(ABC):               # ABC = Abstract Base Class
    def __init__(self, colour):
        self.colour = colour

    @abstractmethod
    def area(self):
        pass                    # no implementation here

    @abstractmethod
    def perimeter(self):
        pass

    def describe(self):        # concrete method — not abstract
        return (f'{self.colour} shape: '
                f'area={self.area():.2f}, '
                f'perimeter={self.perimeter():.2f}')
# Cannot instantiate abstract class
s = Shape('red')    # → TypeError: Can't instantiate abstract class Shape
                    # with abstract methods area, perimeter
class Circle(Shape):
    def __init__(self, colour, radius):
        super().__init__(colour)
        self.radius = radius

    def area(self):           # must implement
        import math
        return math.pi * self.radius ** 2

    def perimeter(self):      # must implement
        import math
        return 2 * math.pi * self.radius


class Rectangle(Shape):
    def __init__(self, colour, width, height):
        super().__init__(colour)
        self.width = width
        self.height = height

    def area(self):
        return self.width * self.height

    def perimeter(self):
        return 2 * (self.width + self.height)


# Now these work fine
c = Circle('red', 5)
r = Rectangle('blue', 4, 6)

shapes = [c, r]
for shape in shapes:
    print(shape.describe())
# → red shape: area=78.54, perimeter=31.42
# → blue shape: area=24.00, perimeter=20.00

If a subclass doesn’t implement all abstract methods, it also becomes abstract and can’t be instantiated:

class IncompleteShape(Shape):
    def area(self):
        return 42
    # Missing perimeter() — still abstract

s = IncompleteShape('green')    # → TypeError: Can't instantiate abstract class

A complete inheritance hierarchy

from abc import ABC, abstractmethod
import math

class Animal(ABC):
    def __init__(self, name, age):
        self.name = name
        self.age = age

    @abstractmethod
    def sound(self):
        pass

    @abstractmethod
    def move(self):
        pass

    def describe(self):
        return (f'{self.name} ({self.__class__.__name__}, {self.age}y): '
                f'says "{self.sound()}", {self.move()}')

    def __str__(self):
        return f'{self.__class__.__name__}({self.name})'


class Domestic(ABC):
    def __init__(self, owner):
        self.owner = owner

    @abstractmethod
    def breed(self):
        pass


class Dog(Animal, Domestic):
    def __init__(self, name, age, owner, breed):
        Animal.__init__(self, name, age)
        Domestic.__init__(self, owner)
        self._breed = breed

    def sound(self): return 'Woof!'
    def move(self):  return 'runs'
    def breed(self): return self._breed
    def fetch(self): return f'{self.name} fetches the ball!'


class Cat(Animal, Domestic):
    def __init__(self, name, age, owner, indoor=True):
        Animal.__init__(self, name, age)
        Domestic.__init__(self, owner)
        self.indoor = indoor

    def sound(self): return 'Meow!'
    def move(self):  return 'sneaks'
    def breed(self): return 'Domestic cat'
    def purr(self):  return f'{self.name} purrs...'


class Wolf(Animal):
    def __init__(self, name, age, pack_size):
        super().__init__(name, age)
        self.pack_size = pack_size

    def sound(self): return 'Howl!'
    def move(self):  return 'hunts'
    def howl(self):  return f'{self.name} howls at the moon!'


# Usage
rex  = Dog('Rex', 3, 'Sergio', 'German Shepherd')
mia  = Cat('Mia', 5, 'María')
luna = Wolf('Luna', 4, 8)

animals = [rex, mia, luna]

print('=== ALL ANIMALS ===')
for animal in animals:
    print(animal.describe())

print('\n=== DOMESTIC ANIMALS ===')
for animal in animals:
    if isinstance(animal, Domestic):
        print(f'{animal.name} — owner: {animal.owner}, breed: {animal.breed()}')

print('\n=== SPECIFIC BEHAVIOURS ===')
if isinstance(rex, Dog):
    print(rex.fetch())
if isinstance(mia, Cat):
    print(mia.purr())
if isinstance(luna, Wolf):
    print(luna.howl())

Output:

=== ALL ANIMALS ===
Rex (Dog, 3y): says "Woof!", runs
Mia (Cat, 5y): says "Meow!", sneaks
Luna (Wolf, 4y): says "Howl!", hunts

=== DOMESTIC ANIMALS ===
Rex — owner: Sergio, breed: German Shepherd
Mia — owner: María, breed: Domestic cat

=== SPECIFIC BEHAVIOURS ===
Rex fetches the ball!
Mia purrs...
Luna howls at the moon!

Visualise with Python Tutor

Copy this code into pythontutor.com and step through it:

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

    def introduce(self):
        return f'I am {self.name}'


class Student(Person):
    def __init__(self, name, age, degree):
        super().__init__(name, age)
        self.degree = degree

    def introduce(self):
        base = super().introduce()
        return f'{base}, studying {self.degree}'


people = [
    Person('Carlos', 35),
    Student('Sergio', 20, 'GCID')
]

for p in people:
    print(p.introduce())

print(isinstance(people[1], Person))
print(isinstance(people[0], Student))

Step through and observe three key moments. When Student.__init__ runs, super().__init__(name, age) calls Person.__init__ — you can see Python temporarily executing code in the parent class’s frame before returning to Student’s. When the for loop calls p.introduce(), Python checks the object’s actual type — for people[0] (a Person) it calls Person.introduce; for people[1] (a Student) it calls Student.introduce. That runtime type dispatch is polymorphism. When Student.introduce calls super().introduce(), it goes up to Person.introduce and gets the base string before adding the degree. Watch how base gets the value 'I am Sergio' from the parent method.

Quick summary

# BASIC INHERITANCE
class Child(Parent):
    def __init__(self, child_attr, *parent_args):
        super().__init__(*parent_args)    # always first
        self.child_attr = child_attr

# super() — access parent class
super().__init__(args)      # call parent constructor
super().method()            # call parent's method version

# METHOD OVERRIDE — redefine in subclass
class Child(Parent):
    def method(self):               # replaces Parent.method()
        parent_result = super().method()   # optionally call parent
        return parent_result + ' more'

# POLYMORPHISM — same call, different behaviour
for obj in [Dog(), Cat(), Duck()]:
    print(obj.sound())    # calls each class's own sound()

# isinstance() — check type at runtime
isinstance(obj, Dog)       # True if obj is Dog or subclass of Dog
isinstance(obj, Animal)    # True if Dog inherits from Animal
issubclass(Dog, Animal)    # True — class relationship, not instance

# MULTIPLE INHERITANCE
class Duck(Animal, Flyable, Swimmable):
    def __init__(self, name):
        super().__init__(name)    # follows MRO

Duck.__mro__    # shows method resolution order

# ABSTRACT CLASSES
from abc import ABC, abstractmethod

class Shape(ABC):
    @abstractmethod
    def area(self):              # subclasses MUST implement this
        pass

    def describe(self):          # concrete — inherited as-is
        return f'Area: {self.area()}'

# Shape() → TypeError — can't instantiate abstract class
# Circle(Shape) with area() → OK

# WHEN TO USE EACH
# Inheritance:    A IS A B (Dog is an Animal)
# Composition:    A HAS A B (Car has an Engine)
# Abstract class: define interface, enforce implementation in subclasses
# Multiple inheritance: mixin pattern — add capabilities (Flyable, Swimmable)

# self.__class__.__name__ — get the actual class name at runtime
# useful in base class methods that subclasses don't need to override
def __str__(self):
    return f'{self.__class__.__name__}({self.name})'

# COMMON ERRORS
# 1. Forgetting super().__init__() → parent attrs not initialised
# 2. Calling super() in wrong order → parent not ready yet
# 3. Overriding without calling super() → lose parent behaviour
# 4. Not implementing all @abstractmethod → TypeError on instantiation
# 5. isinstance vs type: isinstance(dog, Animal) → True
#                         type(dog) == Animal → False (it's Dog)

In the next article we practice inheritance with real programs — a zoo, an employee hierarchy and a shape system.

Similar Posts

2 Comments

Leave a Reply

Your email address will not be published. Required fields are marked *