C. Designing Modular Programs · Modular and Object-Oriented DesignOverriding, Overloading and Polymorphism
A2.2 C1.4About 75 minutes
Learning goals- I can override an inherited method and extend it with
super().method(...). - I can explain polymorphism and write a loop that treats objects of different subclasses the same way.
- I can use an abstract base class to require that every subclass provides a method.
- I can explain method overloading, why Python does not support it by signature, and how default parameters give the same effect.
- I can overload operators such as +, == and < with special methods.
Words to know
overriding
Defining a method in a subclass with the same name (and parameters) as an inherited method, so the subclass's version is used.
polymorphism
"Many forms": the same method call works on objects of different classes, and each responds in its own way.
dynamic dispatch
Choosing which method body to run at run time, based on the object's actual class.
abstract class
A class that is not meant to have objects of its own; it defines methods that subclasses must provide. In Python, a subclass of ABC with @abstractmethod methods.
concrete class
A class that provides all of its methods and can be instantiated.
method overloading
Several methods with the same name but different parameter lists in one class (supported in Java and C++, not by signature in Python).
operator overloading
Giving an operator such as + or < a meaning for your own class by defining special methods like __add__ and __lt__.
duck typing
Python's style of polymorphism: any object that has the needed methods can be used, whatever its class.
special (dunder) method
A method with double underscores on both sides, such as __str__, that Python calls automatically for built-in operations.
Same question, different answers
Ask three rental items "What do you cost for 4 days?" Poles answer with the plain rate. A helmet adds a cleaning fee. A snowboard gives every 4th day free. The question is the same; the answer depends on what kind of item you asked.
This lesson covers the three remaining ideas named in expectation A2.2: method overriding (a subclass changes an inherited method), polymorphism (one call, many behaviours) and method overloading (one name, different parameter lists). Together with encapsulation and inheritance, they are the toolkit of object-oriented design.
Overriding
To override, define a method in the subclass with the same name and parameters as the inherited one. Because Python searches the object's own class first (the MRO from last lesson), the subclass version wins.
Often you want to extend the inherited behaviour rather than replace it. Call the superclass version with super().cost(days) and add to its result. This reuses the superclass's logic, so if the base rate rule changes in Item, Helmet's cost changes with it automatically.
You have already overridden methods without knowing it: every class inherits a plain __str__ from object, and writing your own __str__ overrides it.
Worked exampleExample 1: Overriding cost
Write Helmet and Snowboard subclasses of Item. Helmets cost the normal rate plus a one-time 200-cent cleaning fee. Snowboards get every 4th day free. Helmets also show the fee in their description. Print each item and its cost for 4 days.
- Helmet.cost calls
super().cost(days) for the base amount (800 × 4 = 3200) and adds 200: 3400. - Helmet.__str__ extends Item's text: it takes
super().__str__() and adds " (+$2.00 cleaning)". - Snowboard.cost works out the free days with integer division: 4 // 4 = 1, then charges
super().cost(4 - 1) = 4000 × 3 = 12 000. - Plain Item P1 (ski poles) uses the original method: 1500 × 4 = 6000.
- The loop never asks what kind of item it has. It just calls
g.cost(4). That is polymorphism.
class Item:
"""Any piece of rental equipment."""
def __init__(self, item_id, daily_cents):
self.item_id = item_id
self.daily_cents = daily_cents
def cost(self, days):
return self.daily_cents * days
def __str__(self):
return f"{self.item_id}: ${self.daily_cents / 100:.2f}/day"
class Helmet(Item):
CLEANING_CENTS = 200
def cost(self, days): # overrides Item.cost
return super().cost(days) + Helmet.CLEANING_CENTS
def __str__(self): # overrides Item.__str__
return super().__str__() + " (+$2.00 cleaning)"
class Snowboard(Item):
def cost(self, days): # overrides Item.cost
"""Every 4th day is free."""
free_days = days // 4
return super().cost(days - free_days)
gear = [Item("P1", 1500), Helmet("H1", 800), Snowboard("B1", 4000)]
for g in gear:
print(g, "->", g.cost(4))
overriding.py: two subclasses override cost, and Helmet also overrides __str__.
P1: $15.00/day -> 6000
H1: $8.00/day (+$2.00 cleaning) -> 3400
B1: $40.00/day -> 12000
Output of the program above (what Python prints).
Polymorphism
Polymorphism means "many forms". Code written against a superclass, like for g in gear: g.cost(4), works for every subclass, including subclasses written next year by someone else. Python decides at run time which cost to run by looking at each object's actual class; this is called dynamic dispatch.
Polymorphism is what makes inheritance pay off. Without it, you would write if type(g) == Helmet: ... elif type(g) == Snowboard: ... everywhere, and adding a new item kind would mean editing every one of those if-chains. With it, you add one subclass and the existing loops just work. This is sometimes called the open-closed principle: code should be open for extension (new subclasses) but closed for modification (no edits to working code).
Polymorphism: the loop makes the same call on every object, and each object answers with its own version of area. The loop never checks what kind of shape it has.Abstract classes: a contract for subclasses
Sometimes the superclass cannot provide a sensible method at all. What is the area of a "shape" in general? Nothing. A Shape is an abstract idea; only circles, rectangles and triangles are concrete.
Python's abc module (abstract base classes) lets you say so. Make the class inherit from ABC and mark the method with @abstractmethod. Then:
- trying to create a
Shape() raises TypeError; - any subclass that forgets to override
area also cannot be instantiated, so the mistake shows up immediately, not months later.
The abstract class can still contain concrete methods, such as describe(), that call the abstract one. Every subclass inherits describe() and it automatically uses that subclass's own area.
Worked exampleExample 2: A shape hierarchy
Write an abstract Shape class with an abstract area() and a concrete describe(). Write Circle, Rectangle and Triangle. Print a description of each shape in a list and the total area.
class Shape(ABC): with @abstractmethod def area(self):. The docstring is the method's whole body, which is allowed.describe() is written once, in Shape, and calls self.area(). Which area runs depends on the object.- Areas: circle π × 2² ≈ 12.566; rectangle 3 × 4 = 12; triangle 6 × 5 ÷ 2 = 15.0. The format
:.1f shows one decimal place. - Total: 12.566 + 12 + 15 = 39.566, rounded to 39.57.
Shape() raises TypeError because Shape is abstract.
Code and output below. Adding a new shape (say, a Hexagon) needs one new class; the loop and describe() need no changes.
import math
from abc import ABC, abstractmethod
class Shape(ABC):
"""Abstract superclass: every shape can report its area."""
@abstractmethod
def area(self):
"""Return the area. Every concrete subclass MUST override this."""
def describe(self):
return f"{type(self).__name__} with area {self.area():.1f}"
class Circle(Shape):
def __init__(self, r):
self.r = r
def area(self):
return math.pi * self.r ** 2
class Rectangle(Shape):
def __init__(self, w, h):
self.w = w
self.h = h
def area(self):
return self.w * self.h
class Triangle(Shape):
def __init__(self, base, height):
self.base = base
self.height = height
def area(self):
return self.base * self.height / 2
shapes = [Circle(2), Rectangle(3, 4), Triangle(6, 5)]
total = 0
for s in shapes:
print(s.describe()) # one call, three different area methods
total += s.area()
print("Total:", round(total, 2))
try:
Shape()
except TypeError:
print("Shape is abstract: you cannot create one")
shapes.py: an abstract base class with three concrete subclasses.
Circle with area 12.6
Rectangle with area 12.0
Triangle with area 15.0
Total: 39.57
Shape is abstract: you cannot create one
Output of the program above (what Python prints).
Method overloading
In Java or C++, one class can have several methods with the same name but different parameter lists, and the compiler picks one by looking at the arguments:
area(double r) for a circle, andarea(double w, double h) for a rectangle.
This is overloading. Python does not work this way. A def just assigns a function to a name, so a second def area replaces the first, whatever its parameters.
def area(r):
return 3.14159 * r * r
def area(w, h): # this REPLACES the first area completely
return w * h
print(area(3, 4))
try:
area(2)
except TypeError:
print("area(2) fails: the one-parameter version is gone")
In Python, the second def wins. There is only ever one function called area.
12
area(2) fails: the one-parameter version is gone
Output of the program above (what Python prints).
Python programmers get the effect of overloading in other ways:
- Default parameters, as in
def area(a, b=None):, so one function accepts one or two arguments. - Keyword arguments, such as
make_card(owner, balance=0). - Variable numbers of arguments, written with a star before a parameter name (for example, a function that adds any number of prices).
- Checking the type of an argument with isinstance, or the standard-library tool
functools.singledispatch, which picks a function by the type of the first argument.
The idea behind overloading, offering one name for one concept that can take different inputs, is still good design. Python just implements it inside a single function.
import math
def area(a, b=None):
"""Circle area for area(r); rectangle area for area(w, h)."""
if b is None:
return math.pi * a * a
return a * b
print(round(area(2), 2), area(3, 4))
One function, two ways to call it. Output: 12.57 12
Overriding happens across a hierarchy (a subclass replaces an inherited method). Overloading means one name with different parameter lists. Languages like Java support it directly; Python keeps only the last definition, so it uses default parameters instead.Operator overloading
Python does support overloading operators. You already did this for comparisons in Making Objects Comparable (Data Types, Files and Expressions): a == b calls a.__eq__(b) and a < b calls a.__lt__(b). The same mechanism works for arithmetic: a + b calls a.__add__(b) and a - b calls a.__sub__(b). Define those special methods in your class and the operators work on your objects. Built-in functions use them too: sorted() and min() compare with <, len(x) calls x.__len__(), and print(x) calls x.__str__().
Overload an operator only when its meaning is obvious. Adding two amounts of money is obvious. "Adding" two students is not; give that a named method instead.
Worked exampleExample 3: A Money class with operators
Write a Money class that stores cents and supports + (new) along with == and < (as in Making Objects Comparable) and readable printing. Add lunch ($12.50) and a snack ($3.75), compare amounts, and sort a list of prices.
__add__(self, other) returns a new Money object, so lunch + snack does not change either operand. 1250 + 375 = 1625 cents = $16.25.__eq__ compares cents. Without it, == would compare identity and Money(500) == Money(500) would be False.__lt__ compares cents. Defining it is enough for sorted() to order a list of Money objects.__str__ formats dollars; the list comprehension converts each to text for printing.
class Money:
"""An amount of money in whole cents, with operators overloaded."""
def __init__(self, cents):
self.cents = cents
def __add__(self, other): # runs for a + b
return Money(self.cents + other.cents)
def __eq__(self, other): # runs for a == b
return self.cents == other.cents
def __lt__(self, other): # runs for a < b (and lets sorted() work)
return self.cents < other.cents
def __str__(self):
return f"${self.cents / 100:.2f}"
lunch = Money(1250)
snack = Money(375)
print(lunch + snack)
print(Money(500) == Money(500), snack < lunch)
prices = sorted([lunch, snack, Money(899)])
print([str(p) for p in prices])
money.py: three operators and __str__ overloaded.
$16.25
True True
['$3.75', '$8.99', '$12.50']
Output of the program above (what Python prints).
Just for funThe word polymorphism comes from Greek words meaning "many shapes", which is why nearly every textbook teaches it with a Shape class. Computer scientists are not always creative, but they are consistent.
Hands-on activityThe noise-making zoo
You need: a computer with Python and a family member to add the "surprise" class.
- Write an abstract class
Animal(ABC) with a name attribute, an abstract method sound(), and a concrete method speak() that returns f"{self.name} says {self.sound()}". - Write three concrete subclasses for animals found in Canada, for example Loon, Moose and Beaver. Each overrides sound() (a loon's call is often described as a wail or yodel; any reasonable word is fine).
- Put one of each in a list and print
a.speak() in a loop. - Ask your family member to write a fourth subclass without looking at your loop. Add it to the list. The loop should work without any change. That is the open-closed principle in action.
- Try making a subclass that forgets sound(). Create one and read the error.
- Bonus: overload
__lt__ so animals sort by name, and print sorted(zoo).
What to look for: no if type(...) checks in the loop; the forgotten-method subclass raises TypeError when instantiated; sorting works after __lt__ is added.
Your turn
1What is the difference between overriding and overloading?
Overriding: a subclass redefines an inherited method with the same name and parameters. Overloading: several methods with the same name but different parameter lists (Python simulates it with default parameters).
2Using Example 1's classes, what is Helmet("H2", 1000).cost(2)? And Snowboard("B2", 4000).cost(9)?
Helmet: 1000 × 2 + 200 = 2200. Snowboard: 9 // 4 = 2 free days, so 4000 × 7 = 28 000.
3What does super().cost(days) do inside Helmet.cost?
It calls the superclass (Item) version of cost, so Helmet can reuse the base calculation and add the cleaning fee to it.
4Define polymorphism and give an example from this lesson.
The same method call works on objects of different classes, each responding in its own way. Example: s.describe() in the loop over Circle, Rectangle and Triangle objects.
5What happens when you run Shape() for the abstract Shape class? Why is that useful?
It raises TypeError. It stops anyone from creating a meaningless "general shape" and forces every subclass to provide area().
6Predict the output:
def greet(name):
return "Hi " + name
def greet(name, title):
return "Hello " + title + " " + name
print(greet("Ali", "Dr."))
Hello Dr. Ali. The second def replaced the first, so a call like greet("Ali") would now raise TypeError.
7Which special method must Money define for sorted([...]) to work?
More practice: warm-up
1What does the word polymorphism mean?
Many forms (many shapes).
2Which module provides ABC and abstractmethod?
3Which special method runs for a + b?
4Which special method runs for a == b?
5True or false: Python keeps both versions when you write two functions with the same name and different parameters.
False. The later def replaces the earlier one.
More practice: on your own
1Add a Square class to the shape hierarchy, as a subclass of Shape with one side length. What does Square(5).describe() return?
class Square(Shape):
def __init__(self, side):
self.side = side
def area(self):
return self.side ** 2
It returns "Square with area 25.0" (describe() is inherited, and :.1f shows 25 as 25.0).
2Write a function total(prices, tax_rate=0.13) returning the sum plus tax, rounded to cents. What is total([10, 6])? And total([10, 6], 0)?
def total(prices, tax_rate=0.13):
return round(sum(prices) * (1 + tax_rate), 2)
16 × 1.13 = 18.08. With tax_rate 0: 16. The default parameter gives two ways to call one function, like overloading.
3Add __sub__ to Money so Money(1000) - Money(250) prints $7.50.
def __sub__(self, other):
return Money(self.cents - other.cents)
4Rewrite this code so it uses polymorphism instead of type checks:
for g in gear:
if type(g) == Helmet:
print(g.daily_cents * 4 + 200)
else:
print(g.daily_cents * 4)
Move each rule into an overridden cost method (as in Example 1) and write:
for g in gear:
print(g.cost(4))
New item kinds now need no change to the loop.
5A subclass Hexagon(Shape) defines def area(self, side): with an extra parameter. Why does Hexagon(...).describe() crash, and what does this teach about overriding?
describe() calls self.area() with no arguments, but Hexagon's area needs one, so it raises TypeError. An override must keep the same parameters as the method it replaces, so code written for the superclass still works (the substitution principle). Store the side in __init__ instead.
Challenge
1Predict the output and explain each line:
class A:
def who(self):
return "A"
def hello(self):
return "hello from " + self.who()
class B(A):
def who(self):
return "B"
print(A().hello(), "|", B().hello())
hello from A | hello from B. hello() is inherited and defined only in A, but it calls self.who(). For a B object, self is a B, so dynamic dispatch runs B's who. A superclass method can call methods that subclasses override; this is how describe() worked with area().
2After adding __eq__ to Money, {Money(500): "five"} raises TypeError: unhashable type. Explain why, and fix it.
When a class defines __eq__ but not __hash__, Python sets __hash__ to None, because objects that are equal must have equal hashes and the default hash (based on identity) would break that rule. Fix by adding
def __hash__(self):
return hash(self.cents)
so equal amounts have equal hashes. (Only do this if cents never changes after creation, since a key's hash must stay the same.)
3Use functools.singledispatch to write describe(x) that returns "number: ..." for an int and "text: ..." for a str, with a general fallback. Why is this closer to true overloading than default parameters?
from functools import singledispatch
@singledispatch
def describe(x):
return "something: " + repr(x)
@describe.register
def _(x: int):
return "number: " + str(x)
@describe.register
def _(x: str):
return "text: " + x
describe(7) gives "number: 7", describe("maple") gives "text: maple", and describe(2.5) gives "something: 2.5". Like Java overloading, a separate body is chosen by the type of the argument, instead of one body that branches with if statements.
Check your understandingQuiz
1. A subclass method has the same name and parameters as a superclass method. This is called:
2. Using Example 1, what does Snowboard("B3", 3000).cost(8) return?
3. What is the main benefit of polymorphism?
4. In Python, what happens when you define two functions named area with different parameter lists in the same module?
5. Which special method lets you use < on your own objects?
6. What does @abstractmethod on Shape.area guarantee?
7. Inside Helmet.cost, what does super().cost(days) return for days = 3 and a daily rate of 800?
For parents and teachersBig idea: A2.2 lists method overloading, method overriding and polymorphism alongside encapsulation and inheritance as concepts that support reusable code. Overriding changes inherited behaviour; polymorphism lets one loop handle many kinds of object; overloading (one name, several parameter lists) works differently in Python than in Java, and students should know both views because university courses often use Java or C++.
How to help: The zoo activity is a good chance to be the "other programmer" who adds a new class. Celebrate when the loop works unchanged; that is the whole point.
Common difficulties:
- Confusing overriding (subclass, same parameters) with overloading (different parameters).
- Writing
if type(x) == ... chains instead of overriding a method. - Changing the parameters of an overridden method, which breaks code written for the superclass.
- Expecting Python to keep two functions with the same name.