The Copy-Paste Problem in a School Cricket Academy App
Suppose your school's sports committee asks you to build a small program that keeps digital records for the cricket academy: every batsman, bowler, and wicketkeeper needs a name, a team, and a match count stored somewhere, plus a way to print that information. If you don't know about inheritance yet, the obvious first attempt looks like this:
class Batsman:
def __init__(self, name, team, matches_played, centuries):
self.name = name
self.team = team
self.matches_played = matches_played
self.centuries = centuries
def get_info(self):
return f"{self.name} plays for {self.team}, has played {self.matches_played} matches."
class Bowler:
def __init__(self, name, team, matches_played, wickets):
self.name = name
self.team = team
self.matches_played = matches_played
self.wickets = wickets
def get_info(self):
return f"{self.name} plays for {self.team}, has played {self.matches_played} matches."
Look closely at the two __init__ methods and the two get_info methods. The lines that set self.name, self.team, and self.matches_played, and the entire get_info method, are word-for-word identical. Now add WicketKeeper and you triple that duplication. The real damage shows up later: if the sports committee asks you to also store a player's jersey number, you now have to remember to edit three separate classes in three separate places. Miss one, and that class silently falls out of sync with the others — a bug that's easy to introduce and hard to notice. This is exactly the problem inheritance was invented to solve: when several classes share the same data and the same behaviour, that shared part should be written once, in one place, and every specific class should simply reuse it.
The Fix: Give Every Player Type One Common Parent
Instead of repeating name, team, matches_played, and get_info in every class, we write them once in a general-purpose class called Player, and let Batsman reuse that code by declaring Player as its parent:
class Player:
def __init__(self, name, team, matches_played):
self.name = name
self.team = team
self.matches_played = matches_played
def get_info(self):
return f"{self.name} plays for {self.team}, has played {self.matches_played} matches."
def celebrate_milestone(self):
return f"{self.name} celebrates a personal milestone."
class Batsman(Player):
def __init__(self, name, team, matches_played, centuries):
super().__init__(name, team, matches_played)
self.centuries = centuries
def celebrate_milestone(self):
return f"{self.name} raises the bat and helmet after reaching a century!"
Player is called the base class (or parent class). Batsman is called the derived class (or child class), and writing class Batsman(Player): is what creates the relationship — this single parent-to-single-child link is called single inheritance. Because Batsman inherits from Player, every Batsman object automatically gets self.name, self.team, self.matches_played, and the get_info() method for free, without a single line of that code being retyped. Batsman only needs to add what's genuinely new to it: the centuries attribute, and its own version of celebrate_milestone() — which brings us to the line that confuses most beginners the first time they see it: super().__init__(...).
Tracing super().__init__(): What Python Actually Does
Consider this call: riya = Batsman("Riya Verma", "Delhi Public School XI", 18, 3). Here is exactly what happens, step by step:
- Python creates a blank
Batsmanobject in memory and callsBatsman.__init__on it with the arguments("Riya Verma", "Delhi Public School XI", 18, 3). - Inside
Batsman.__init__, the parameters are bound:name = "Riya Verma",team = "Delhi Public School XI",matches_played = 18,centuries = 3. - The line
super().__init__(name, team, matches_played)runs.super()means "go find the next class up the chain from here" — in this case,Player. This line is a genuine function call: it jumps intoPlayer.__init__, passing along just the three valuesPlayerknows how to handle. - Inside
Player.__init__, three attributes get attached to the object:self.name = "Riya Verma",self.team = "Delhi Public School XI",self.matches_played = 18.Player.__init__finishes and control returns toBatsman.__init__. - Back in
Batsman.__init__, the remaining line runs:self.centuries = 3.
The finished object carries all four attributes, even though only one of them was set inside Batsman itself. Without super().__init__(), you would have to copy the three self.x = x lines from Player into Batsman by hand — which is precisely the duplication we set out to remove.
Hierarchical Inheritance: Multiple Children, One Parent
The academy needs bowlers and wicketkeepers too, and both share the same base data as batsmen. So both inherit from Player the same way Batsman does:
class Bowler(Player):
def __init__(self, name, team, matches_played, wickets):
super().__init__(name, team, matches_played)
self.wickets = wickets
def celebrate_milestone(self):
return f"{self.name} points to the sky after taking a five-wicket haul!"
class WicketKeeper(Player):
def __init__(self, name, team, matches_played, dismissals):
super().__init__(name, team, matches_played)
self.dismissals = dismissals
def celebrate_milestone(self):
return f"{self.name} pumps a gloved fist after a sharp stumping!"
We now have three separate classes — Batsman, Bowler, WicketKeeper — each with its own single-inheritance link to the same parent, Player. This specific shape, one parent class with several independent child classes branching off it, has its own name: hierarchical inheritance. It's different from single inheritance not because the individual links are different (each one is still just one child pointing to one parent) but because of the overall shape: one class sits at the top, and many classes fan out beneath it, each reusing the same shared code without knowing or caring that the others exist.
Multilevel Inheritance: A Chain of Three Generations
Now suppose the academy wants to track opening batsmen separately, because they face the new ball and their strike rate in the first six overs matters in a way it doesn't for a batsman coming in at number six. An opening batsman is a batsman — it should inherit everything Batsman has — plus one extra attribute of its own:
class OpeningBatsman(Batsman):
def __init__(self, name, team, matches_played, centuries, strike_rate):
super().__init__(name, team, matches_played, centuries)
self.strike_rate = strike_rate
def celebrate_milestone(self):
base_celebration = super().celebrate_milestone()
return base_celebration + " Then sprints down for a signature drinks-break fist bump with the non-striker."
Here, super().__init__() calls Batsman.__init__, which in turn calls Player.__init__ — a chain three classes deep: Player → Batsman → OpeningBatsman. This is multilevel inheritance: instead of several classes branching directly off one parent, each class inherits from the child of another child, forming a chain rather than a fan. Notice also that super().celebrate_milestone() inside OpeningBatsman doesn't skip Batsman's version — it calls it and reuses its returned string, then adds more text to it. This is a common and useful pattern: overriding a method doesn't have to mean throwing away the parent's version; it can mean building on top of it.
Multiple Inheritance: One Class, Two Parents
An all-rounder is trickier: they genuinely need both batting data and bowling data. A class can inherit from more than one parent at once by listing both in the parentheses:
class AllRounder(Batsman, Bowler):
def __init__(self, name, team, matches_played, centuries, wickets):
Player.__init__(self, name, team, matches_played)
self.centuries = centuries
self.wickets = wickets
Notice this constructor calls Player.__init__ directly by name, not super().__init__(). That's a deliberate choice, not a style preference. When a class inherits from two parents that each expect different arguments, super() can hand control to the wrong parent's __init__ — Python doesn't simply run "the parent listed first," it follows a computed search order across all ancestors called the Method Resolution Order (MRO), and for AllRounder that order is AllRounder → Batsman → Bowler → Player → object. If AllRounder.__init__ had called super().__init__(name, team, matches_played), it would have landed inside Batsman.__init__ — but since Python resolves super() using the actual object's MRO, a further super().__init__() call written inside Batsman would try to reach Bowler.__init__ next in that order, which demands a wickets argument that was never passed, crashing with a missing-argument error. Calling the shared ancestor Player.__init__ directly sidesteps that entirely, which is the simplest safe pattern when your parent classes weren't specifically designed to cooperate with each other.
This whole shape — one class with two (or more) direct parents — is multiple inheritance. It's worth knowing that print(AllRounder.__mro__) would show (<class 'AllRounder'>, <class 'Batsman'>, <class 'Bowler'>, <class 'Player'>, <class 'object'>) — Python decided this order the moment the class was defined, by walking the parents left to right the way you listed them in class AllRounder(Batsman, Bowler):.
Seeing All Four Shapes Together
The diagram below places every class from this chapter into one picture. Solid arrows point from a child class up to its parent — the standard way to draw "inherits from." The three arrows converging on Player from Batsman, Bowler, and WicketKeeper are hierarchical inheritance. The single arrow from OpeningBatsman up to Batsman, one link in a longer chain, is multilevel inheritance. The two dashed arrows from AllRounder — one to Batsman, one to Bowler — are multiple inheritance.
Polymorphism: One Method Call, Many Behaviours
Now put several different player objects into one list and call the same method name on each of them:
squad = [
Batsman("Riya Verma", "Delhi Public School XI", 18, 3),
Bowler("Arjun Mehta", "Delhi Public School XI", 22, 34),
WicketKeeper("Simran Kaur", "Delhi Public School XI", 15, 12),
OpeningBatsman("Aditya Rao", "Delhi Public School XI", 20, 5, 138.4),
AllRounder("Kabir Singh", "St. Xavier's XI", 20, 2, 15),
]
for player in squad:
print(player.celebrate_milestone())
The loop contains exactly one line that calls celebrate_milestone(), yet it prints five completely different sentences:
Riya Verma raises the bat and helmet after reaching a century!
Arjun Mehta points to the sky after taking a five-wicket haul!
Simran Kaur pumps a gloved fist after a sharp stumping!
Aditya Rao raises the bat and helmet after reaching a century! Then sprints down for a signature drinks-break fist bump with the non-striker.
Kabir Singh raises the bat and helmet after reaching a century!
This is polymorphism — literally "many forms." The word player in the loop refers to a different actual object type on every iteration, and Python decides, at the moment the line actually runs, which class's version of celebrate_milestone() to use. For Kabir Singh (an AllRounder), Python doesn't find celebrate_milestone defined directly on AllRounder, so it walks the MRO you saw earlier — AllRounder → Batsman → Bowler → Player — and stops at the first match, which is Batsman's version. That's why an all-rounder celebrates like a batsman here: Batsman happens to come before Bowler in the parentheses of class AllRounder(Batsman, Bowler):.
Compare this to the alternative: without polymorphism, you'd need a chain like if isinstance(player, Batsman): ... elif isinstance(player, Bowler): ... for every single place in your program that needs to react differently per player type. Every time the academy adds a new player category, you'd have to hunt down and update every one of those chains. With polymorphism, adding a new class that defines its own celebrate_milestone() is enough — every existing loop that calls player.celebrate_milestone() automatically starts producing the right output for it, with zero changes to that loop.
Common Misconception: "Python Supports Method Overloading Like Java"
Students who have seen Java or C++ often expect this to work, because in those languages it does:
# (imagine this written inside class Bowler)
def bowl(self):
return f"{self.name} bowls a delivery."
def bowl(self, delivery_type):
return f"{self.name} bowls a {delivery_type}."
In Java, two methods can share a name as long as their parameter lists differ — the compiler picks the right one based on how many arguments you pass. Python has no such mechanism. A class body is executed top to bottom like ordinary code, and each def statement simply assigns a function object to the name bowl inside the class's namespace. The second def bowl doesn't add a second option — it overwrites the first one, exactly the way x = 1 followed by x = 2 leaves x equal to 2. After this class body finishes running, only the two-argument version exists at all. Calling arjun.bowl() with no arguments now raises TypeError: bowl() missing 1 required positional argument: 'delivery_type' — the zero-argument version isn't "still there as a backup"; it's gone.
The idiomatic Python fix is a default parameter value, which lets one method definition cover both cases:
def bowl(self, delivery_type="a good-length ball"):
return f"{self.name} bowls {delivery_type}."
Now arjun.bowl() prints "Arjun Mehta bowls a good-length ball." and arjun.bowl("yorker") prints "Arjun Mehta bowls yorker." — one method, two ways of calling it. The polymorphism you saw in the previous section — many classes, each with its own version of the same method name, chosen automatically based on the object's actual type — is runtime polymorphism through method overriding, and it is the real mechanism Python gives you. What Java calls overloading, Python simply doesn't have; reaching for default arguments (or, for more flexibility, *args) is how Python programmers get the same convenience.
Polymorphism Without Any Inheritance: Duck Typing
One more twist worth knowing: polymorphism in Python doesn't actually require a shared parent class at all. Consider a commentator, who has nothing to do with the Player hierarchy:
class Commentator:
def __init__(self, name):
self.name = name
def celebrate_milestone(self):
return f"{self.name} shouts into the mic: 'What a moment for the crowd!'"
Commentator doesn't inherit from Player — it's a completely unrelated class. Yet for entity in [riya, Commentator("Harsha")]: print(entity.celebrate_milestone()) works perfectly fine, printing both sentences correctly. Python never checks what class an object "officially" belongs to before calling a method on it; it only checks whether the object has that method at all. This is called duck typing, from the saying "if it walks like a duck and quacks like a duck, treat it like a duck." Inheritance is one common way to guarantee two classes share a method name, but in Python it is not the only way, and it was never a requirement for polymorphism to work.
Active Recall
Q1. Trace this code and predict exactly what gets printed.
class MiddleOrderBatsman(Batsman):
pass
farhan = MiddleOrderBatsman("Farhan Ali", "St. Xavier's XI", 12, 1)
print(farhan.celebrate_milestone())
Answer: Farhan Ali raises the bat and helmet after reaching a century! — MiddleOrderBatsman defines no methods of its own (the body is just pass), so Python walks its MRO, MiddleOrderBatsman → Batsman → Player → object, and uses the first celebrate_milestone it finds, which belongs to Batsman.
Q2. A gym app defines class Coach:, then class BattingCoach(Coach):, class BowlingCoach(Coach):, and class FieldingCoach(Coach):. What type of inheritance is this?
Answer: Hierarchical inheritance — one parent class, Coach, with three independent child classes branching directly off it.
Q3. Given the two bowl definitions shown in the misconception section above (both written inside the same class body, one after the other), what does arjun.bowl() do, and how would you rewrite it so both arjun.bowl() and arjun.bowl("yorker") work?
Answer: It raises TypeError, because the second def bowl silently replaced the first one, and the surviving version requires a delivery_type argument that wasn't supplied. Fix it with a single method using a default parameter: def bowl(self, delivery_type="a good-length ball"):.
Q4. A new class is defined as class AllRounderKeeper(WicketKeeper, Batsman):, and it defines no celebrate_milestone of its own. Both WicketKeeper and Batsman define their own version of that method. Whose version runs when you call it on an AllRounderKeeper object, and why?
Answer: WicketKeeper's version runs, because WicketKeeper is listed first inside the parentheses, so it comes first in the MRO right after AllRounderKeeper itself — the same left-to-right rule that made AllRounder(Batsman, Bowler) favour Batsman earlier in this chapter.
Summary
- Inheritance lets a class (the derived/child class) reuse the attributes and methods of another class (the base/parent class) instead of duplicating that code, declared with
class Child(Parent):. - super().__init__() calls the parent's constructor from inside the child's constructor, so shared attributes only need to be set in one place. It resolves based on the object's actual Method Resolution Order (MRO), not just the class it's textually written in — which matters once multiple inheritance is involved.
- Single inheritance: one child, one parent (
Batsman(Player)). - Hierarchical inheritance: several independent child classes share the same parent (
Batsman,Bowler, andWicketKeeperall inheriting fromPlayer). - Multilevel inheritance: a chain of parent → child → grandchild (
Player → Batsman → OpeningBatsman). - Multiple inheritance: one class inherits from two or more parents at once (
AllRounder(Batsman, Bowler)); when parent constructors expect different arguments, call the shared ancestor's__init__directly rather than chainingsuper(), to avoid landing in the wrong parent. - Method overriding: a child class redefines a method it inherited, giving it new behaviour while keeping the same name — the mechanism behind runtime polymorphism.
- Polymorphism means the same method call produces different behaviour depending on the actual type of the object at runtime; it does not require a shared parent class at all (see duck typing).
- Python has no true method overloading. Two same-named methods in one class body do not coexist — the later definition overwrites the earlier one. Use a default parameter (or
*args) to accept a variable number of arguments in one method instead.
Practice Exercises
Now it is time to practice! Complete these challenges to solidify your understanding:
- Exercise 1: Write a short program that demonstrates the core concept from this chapter. Test it with at least 3 different inputs.
- Exercise 2: Find a real-world example where advanced oop: inheritance and polymorphism is used in an Indian company (like TCS, Infosys, Flipkart, or ISRO). Write a paragraph explaining the connection.
- Exercise 3: Create a mind-map connecting advanced oop: inheritance and polymorphism to at least 3 other topics you have studied.