You are viewing a free preview of this lesson.
Subscribe to unlock all 10 lessons in this course and every other course on LearningBro.
Two of the four pillars of object-oriented programming exist to solve the same problem: how to reuse and extend behaviour without copying code. Inheritance lets a new class build on an existing one, acquiring its attributes and methods and adding or refining its own. Polymorphism lets a single piece of code work uniformly with objects of many related types, each responding in its own way. Together they are what make object-oriented systems extensible — you can add a new kind of thing without rewriting the code that already handles the old kinds.
This lesson develops the code mechanics — super/parent calls, method overriding, abstract classes, and polymorphic dispatch — with traced examples. The complementary skill of deciding when to use inheritance and modelling a hierarchy in a class diagram is owned by Object-Oriented Design in the Software Systems course; build on the OOP Fundamentals lesson for the class/object basics this one assumes.
Keep one through-line in mind as you read: inheritance and polymorphism are two halves of a single idea. Inheritance creates the family of related types (a superclass and its subclasses); polymorphism exploits that family by letting one piece of code drive them all. You rarely use one without the other — a hierarchy with no polymorphic code wastes most of its value, and polymorphism with no shared supertype has nothing to dispatch over. Watching how the worked examples below set up a hierarchy and then write code that treats the whole family uniformly is the best way to internalise why these two pillars are taught together.
Within H446 2.2, this lesson covers inheritance and polymorphism as object-oriented techniques. You should be able to:
super / parent) so a subclass reuses initialisation;These points paraphrase the specification; nothing is quoted verbatim.
Inheritance is the mechanism by which one class (the subclass, child or derived class) acquires the attributes and methods of another (the superclass, parent or base class). The subclass may then add new members and override inherited ones. Inheritance models an "is-a" relationship — the subclass is a more specific kind of the superclass:
| Term | Definition |
|---|---|
| Superclass (parent/base) | The general class being inherited from. |
| Subclass (child/derived) | The specialised class that inherits from the superclass. |
| Inheritance | One class acquiring the attributes and methods of another. |
| Override | A subclass replacing an inherited method with its own version. |
The "is-a" test is the litmus for whether inheritance is appropriate at all. If you cannot honestly say "a subclass is a superclass", you almost certainly want composition ("has-a") instead — a point returned to at the end of this lesson and central to good design.
Why does inheritance matter? Its central benefit is eliminating duplication of shared behaviour. Imagine modelling a bank's products without inheritance: SavingsAccount, CurrentAccount and BusinessAccount would each re-declare balance, deposit, withdraw and getBalance — four copies of identical code. If a bug were found in withdraw, it would have to be fixed in every copy, and any new account type would start by copy-pasting the lot. With inheritance, those common members live once in a BankAccount superclass, and each product subclass adds only what makes it different (an interest rate, an overdraft limit). A fix to the shared withdraw is made in one place and instantly applies to every subclass. This single point of definition for shared behaviour is the practical reason inheritance is so valuable in large systems — and the reason careless duplication is treated as a code smell.
In single inheritance a subclass inherits from exactly one superclass — the common and safest case.
# OCR-style OOP pseudocode
CLASS Animal
PRIVATE name: STRING
PRIVATE age: INTEGER
PUBLIC PROCEDURE new(n: STRING, a: INTEGER)
name = n
age = a
END PROCEDURE
PUBLIC FUNCTION getName() RETURNS STRING
RETURN name
END FUNCTION
PUBLIC PROCEDURE speak()
OUTPUT name + " makes a sound"
END PROCEDURE
END CLASS
CLASS Dog INHERITS Animal
PRIVATE breed: STRING
PUBLIC PROCEDURE new(n: STRING, a: INTEGER, b: STRING)
SUPER.new(n, a) // reuse the Animal constructor
breed = b
END PROCEDURE
PUBLIC PROCEDURE speak() // override
OUTPUT getName() + " barks!"
END PROCEDURE
PUBLIC FUNCTION getBreed() RETURNS STRING
RETURN breed
END FUNCTION
END CLASS
class Animal:
def __init__(self, name: str, age: int):
self.__name = name
self.__age = age
def get_name(self) -> str:
return self.__name
def speak(self):
print(f"{self.__name} makes a sound")
class Dog(Animal):
def __init__(self, name: str, age: int, breed: str):
super().__init__(name, age) # call the superclass constructor first
self.__breed = breed
def speak(self): # override Animal.speak
print(f"{self.get_name()} barks!")
def get_breed(self) -> str:
return self.__breed
dog = Dog("Rex", 5, "Labrador")
dog.speak() # Rex barks! (overridden method)
print(dog.get_name()) # Rex (inherited method)
print(dog.get_breed()) # Labrador (subclass-only method)
Trace the three calls on dog. speak() finds the Dog version (overriding the Animal one), so it prints "Rex barks!". get_name() is not defined on Dog, so the search rises to Animal and uses the inherited version — "Rex". get_breed() exists only on Dog. Notice Dog did not redefine get_name or re-store the name; super().__init__(name, age) delegated that to Animal. This is the core economy of inheritance: write the shared parts once in the parent.
If Dog.__init__ omitted super().__init__(name, age), the Animal part of the object would never be initialised — __name would not exist, and get_name() would fail. The super call ensures the inherited slice of the object is set up before the subclass adds its own. A frequent exam point: a subclass constructor should normally call the parent constructor first, then initialise its own extra attributes.
Method overriding is a subclass supplying its own implementation of a method already defined in its superclass. For objects of the subclass, the new version replaces the inherited one.
super() if it wants to extend rather than fully replace it.class Shape:
def area(self) -> float:
return 0.0
class Circle(Shape):
def __init__(self, radius: float):
self.__radius = radius
def area(self) -> float: # overrides Shape.area
return 3.14159 * self.__radius ** 2
class Rectangle(Shape):
def __init__(self, width: float, height: float):
self.__width = width
self.__height = height
def area(self) -> float: # overrides Shape.area
return self.__width * self.__height
Circle(5).area() runs Circle's version → 3.14159 × 25 = 78.53975; Rectangle(4, 6).area() runs Rectangle's → 24. The same method name now means different computations depending on the object — which is precisely the hook polymorphism exploits.
These are easily confused and the distinction is examinable.
| Overriding | Overloading | |
|---|---|---|
| Where | Across classes (sub vs super) | Within one class |
| Signatures | Same name, same parameters | Same name, different parameters |
| Resolved | At runtime, by the object's actual type | At compile time, by the argument types |
| Python | Fully supported | Not supported (later def simply replaces the earlier) |
Overriding does not have to throw away the parent's work. Sometimes a subclass wants to add to the inherited behaviour rather than replace it wholesale, and it does so by calling super() from inside the override:
class Account:
def __init__(self, balance: float):
self._balance = balance
def describe(self) -> str:
return f"Balance: {self._balance}"
class SavingsAccount(Account):
def __init__(self, balance: float, rate: float):
super().__init__(balance)
self.__rate = rate
def describe(self) -> str: # extends, not replaces
base = super().describe() # run the parent's version
return f"{base}, Interest rate: {self.__rate}%"
s = SavingsAccount(1000, 2.5)
print(s.describe()) # Balance: 1000, Interest rate: 2.5%
Trace s.describe(): the SavingsAccount override runs, first calling super().describe() which executes Account's describe and returns "Balance: 1000"; the subclass then appends its own rate information. This "call up, then add" pattern is extremely common — a subclass reuses the general behaviour and specialises only the extra part. Recognising that an override may invoke the original via super(), rather than always discarding it, is a more sophisticated understanding of overriding than the simple "replace" picture, and it mirrors how a subclass constructor extends the parent constructor rather than rewriting initialisation.
Polymorphism ("many forms") lets objects of different classes that share a common superclass be treated uniformly, with the same call producing type-appropriate behaviour.
def print_area(shape: Shape):
print(f"Area: {shape.area()}")
print_area(Circle(5)) # Area: 78.53975
print_area(Rectangle(4, 6)) # Area: 24
print_area was written once and knows nothing about circles or rectangles — only that whatever it receives has an area() method. At runtime Python looks up area on the actual object: a Circle runs Circle's area, a Rectangle runs Rectangle's. This is runtime (dynamic) polymorphism, and its payoff is open-ended extensibility: add a Triangle(Shape) tomorrow with its own area, and print_area handles it with no change. Code that does not need to know about new types in order to keep working is the hallmark of a well-factored object-oriented design.
| Type | Mechanism | Resolved | Example |
|---|---|---|---|
| Runtime (dynamic) | Method overriding | At runtime, by actual object type | shape.area() → Circle's or Rectangle's |
| Compile-time (static) | Method overloading | At compile time, by argument types | add(int,int) vs add(double,double) in Java |
Exam Tip: For polymorphism, never stop at the definition — show the same call on at least two different object types giving different results. The worked
print_areapattern is the model the markers expect.
An abstract class is one that cannot be instantiated on its own. It exists to define a common interface and shared behaviour for subclasses, and may declare abstract methods — methods named but deliberately left unimplemented — that every concrete subclass must override.
from abc import ABC, abstractmethod
class Shape(ABC):
@abstractmethod
def area(self) -> float:
pass
@abstractmethod
def perimeter(self) -> float:
pass
class Circle(Shape):
def __init__(self, radius: float):
self.__radius = radius
def area(self) -> float:
return 3.14159 * self.__radius ** 2
def perimeter(self) -> float:
return 2 * 3.14159 * self.__radius
# Shape() -> TypeError: cannot instantiate an abstract class
circle = Circle(5) # OK: Circle supplies both abstract methods
Why forbid instantiating Shape? Because a bare "shape" has no concrete area — the concept is incomplete until specialised. Making it abstract turns "you forgot to implement area" into an error the moment you try to create the object, rather than a baffling wrong answer later. Abstract classes therefore serve two purposes at once: they guarantee that every subclass provides the required methods, and they let polymorphic code rely on that guarantee.
It is worth distinguishing an abstract class from an ordinary superclass. An ordinary superclass like Animal can be instantiated and provides usable default behaviour; subclasses override only what they wish to change. An abstract class like Shape cannot be instantiated and may leave some methods completely unimplemented, compelling subclasses to fill them in. Choose an abstract class when the base concept is genuinely incomplete on its own and you want the compiler or interpreter to enforce that every concrete type supplies the missing pieces; choose an ordinary superclass when the base has sensible default behaviour worth inheriting as-is. Articulating this difference — can it stand alone, and does it force its children to implement? — is a reliable way to show depth on abstraction questions.
An interface is a pure contract — a set of method signatures an implementing class must provide, with no implementation of its own. It answers "what can this object do?" without saying how.
| Feature | Abstract class | Interface |
|---|---|---|
| Instantiable? | No | No |
| Implemented methods? | Yes (may mix concrete and abstract) | Traditionally none (some languages allow default methods) |
| Attributes/state? | Yes | Typically none |
| Multiple at once? | Usually one superclass | A class may implement many interfaces |
In Python there is no separate interface keyword; an abstract class whose methods are all abstract serves the role, and a class may inherit several of them:
class Printable(ABC):
@abstractmethod
def to_string(self) -> str:
pass
class Saveable(ABC):
@abstractmethod
def save(self):
pass
class Document(Printable, Saveable):
def __init__(self, content: str):
self.__content = content
def to_string(self) -> str:
return self.__content
def save(self):
print(f"Saving: {self.__content[:20]}...")
Document promises to be both Printable and Saveable, so any code expecting either contract can use it. Interfaces are how statically-typed languages get the flexibility of multiple inheritance of behaviour contracts without the dangers of multiple inheritance of implementation.
Multiple inheritance is a subclass inheriting from more than one superclass, combining their features:
class Flyable:
def fly(self):
print("Flying...")
class Swimmable:
def swim(self):
print("Swimming...")
class Duck(Flyable, Swimmable):
def quack(self):
print("Quack!")
duck = Duck()
duck.fly() # Flying...
duck.swim() # Swimming...
duck.quack() # Quack!
This is convenient, but multiple inheritance of implementation invites a famous ambiguity.
The diamond problem arises when a class inherits, by two different routes, from a common ancestor — so it is unclear which inherited version of a method applies.
classDiagram
A <|-- B
A <|-- C
B <|-- D
C <|-- D
Subscribe to continue reading
Get full access to this lesson and all 10 lessons in this course.