When Two Classes Start Looking Like Twins
Suppose you are building a small program to manage a cricket squad. You start with a class for a batsman and a class for a bowler, because in Part 1 of this course you learned to model real-world things as classes with attributes and methods. Here is what a beginner's first attempt usually looks like:
class BadBatsman:
def __init__(self, name, team, matches_played, runs, innings):
self.name = name
self.team = team
self.matches_played = matches_played
self.runs = runs
self.innings = innings
def show_info(self):
print(f"{self.name} ({self.team}) — {self.matches_played} matches")
class BadBowler:
def __init__(self, name, team, matches_played, wickets, runs_conceded):
self.name = name
self.team = team
self.matches_played = matches_played
self.wickets = wickets
self.runs_conceded = runs_conceded
def show_info(self):
print(f"{self.name} ({self.team}) — {self.matches_played} matches")
Look closely at the two classes. The lines that set self.name, self.team, and self.matches_played are word-for-word identical. The entire show_info method is word-for-word identical. If your coach later asks you to add a new field — say, jersey_number — you must remember to edit it in both classes. Forget one, and you have a silent bug: one class prints jersey numbers, the other doesn't, and nothing in Python warns you about the mismatch. Now imagine a squad with five player types (batsman, bowler, wicketkeeper, all-rounder, opener) — you would be maintaining the same four lines of code in five different places.
This is the exact problem inheritance was invented to solve. Whenever two or more classes share attributes and behaviour, that shared part belongs in one place, and the differences belong in separate places. Inheritance lets a class say, formally, "I am a more specific version of that other class" — and Python then gives it everything the more general class already has, for free.
Inheritance: Let One Class Build on Another
We rebuild the squad properly. First, we write one class, Player, that holds only what is common to every player, regardless of role:
class Player:
def __init__(self, name, team, matches_played):
self.name = name
self.team = team
self.matches_played = matches_played
def show_info(self):
print(f"{self.name} ({self.team}) — {self.matches_played} matches")
def calculate_rating(self):
return 50 # a neutral baseline rating for a generic player
In CBSE terminology, Player is the base class (also called the parent class or superclass). Now we build a Batsman class that reuses Player instead of retyping it:
class Batsman(Player):
def __init__(self, name, team, matches_played, runs, innings):
super().__init__(name, team, matches_played)
self.runs = runs
self.innings = innings
def batting_average(self):
return round(self.runs / self.innings, 2) if self.innings else 0
The line class Batsman(Player): is the entire trick. The name in parentheses tells Python: "every Batsman object is also a Player object — give it everything Player has, then let me add the extra parts specific to batting." Batsman is called a derived class or child class (also subclass). Notice that Batsman never re-writes self.name = name. It doesn't need to — that job already belongs to Player.
We do the same for bowlers:
class Bowler(Player):
def __init__(self, name, team, matches_played, wickets, runs_conceded):
super().__init__(name, team, matches_played)
self.wickets = wickets
self.runs_conceded = runs_conceded
def bowling_average(self):
return round(self.runs_conceded / self.wickets, 2) if self.wickets else 0
Now, if the coach wants a jersey_number field added to every player, you add one line to Player.__init__, and both Batsman and Bowler receive it automatically the next time they are created. One change, everywhere it's needed. That is the entire point of inheritance: it turns "copy this code" into "reuse this code."
Tracing What Really Happens When a Batsman Is Born
It helps to trace this line by line, the way you would trace any program, rather than trusting it works by magic. Run this:
p1 = Batsman("Rohan Verma", "Mumbai Warriors", 12, 245, 5)
p1.show_info()
print("Batting average:", p1.batting_average())
Step by step: Python sees Batsman(...) and looks for __init__ inside the Batsman class. It finds one, so it runs that one, not Player's. Inside Batsman.__init__, the very first line is super().__init__(name, team, matches_played). This hands control up to Player.__init__, which sets self.name = "Rohan Verma", self.team = "Mumbai Warriors", self.matches_played = 12 on the very same object. Control then returns to Batsman.__init__, which finishes the job by setting self.runs = 245 and self.innings = 5. By the time the constructor finishes, p1 has all five attributes, even though no single method wrote all five.
Next, p1.show_info() is called. Python looks for show_info starting in Batsman (the object's own class) — there isn't one yet at this stage of our example — so it climbs up to Player and runs the one it finds there, using p1's own data. This upward search for a method is called attribute lookup, and the specific order it climbs in (child first, then parent, then grandparent, and so on) is called the Method Resolution Order, or MRO. Output:
Rohan Verma (Mumbai Warriors) — 12 matches
Batting average: 49.0
(245 runs across 5 innings gives an average of exactly 49.0, and round(49.0, 2) stays 49.0.)
super(): Handing Off to the Parent Constructor
A very common beginner mistake is to skip the super().__init__(...) line entirely:
class BrokenBatsman(Player):
def __init__(self, name, team, matches_played, runs, innings):
self.runs = runs
self.innings = innings
# forgot to call super().__init__(...)
If you now create BrokenBatsman("Rohan", "Mumbai", 12, 245, 5) and call .show_info(), Python will crash with AttributeError: 'BrokenBatsman' object has no attribute 'name'. This is not a Python bug — it is exactly correct. Player.__init__ is the only code that ever sets self.name. If you never call it, that attribute simply never gets created on the object. super() is not decoration; it is the mechanism that actually wires the parent's setup into the child's object. The rule to memorize: whenever a child class defines its own __init__, its first job is almost always to call super().__init__(...) with whatever arguments the parent needs, before setting up anything new.
Method Overriding: Same Name, New Behaviour
Sometimes the child doesn't just want to add new attributes — it wants to replace how an inherited method behaves. This is called overriding. Watch Batsman customize both show_info and calculate_rating:
class Batsman(Player):
def __init__(self, name, team, matches_played, runs, innings):
super().__init__(name, team, matches_played)
self.runs = runs
self.innings = innings
def batting_average(self):
return round(self.runs / self.innings, 2) if self.innings else 0
def calculate_rating(self):
return round(50 + self.batting_average(), 2)
def show_info(self):
super().show_info()
print(f" Batting average: {self.batting_average()}")
Two things are happening here that deserve separate attention. First, calculate_rating is completely replaced — when you call it on a Batsman, Python finds Batsman's own version first during MRO lookup and never even looks at Player's version. Second, show_info is extended rather than fully replaced: its first line is super().show_info(), which deliberately runs the parent's version first, and then it adds one more line of its own. This pattern — call the parent's version, then add to it — is extremely common and usually better than rewriting the parent's logic from scratch, because if Player.show_info is later improved, every subclass benefits automatically.
Do the equivalent for Bowler:
class Bowler(Player):
def __init__(self, name, team, matches_played, wickets, runs_conceded):
super().__init__(name, team, matches_played)
self.wickets = wickets
self.runs_conceded = runs_conceded
def bowling_average(self):
return round(self.runs_conceded / self.wickets, 2) if self.wickets else 0
def calculate_rating(self):
bavg = self.bowling_average()
return round(100 - bavg, 2) if bavg else 50
def show_info(self):
super().show_info()
print(f" Bowling average: {self.bowling_average()}")
Notice the rating formulas are deliberately opposite in spirit: for a batsman, a higher average is better, so we add it to the baseline; for a bowler, a lower average is better (fewer runs conceded per wicket), so we subtract it from 100. This is not just a coding trick — it reflects a genuine difference in how the two roles are judged, and it is exactly the kind of role-specific logic that belongs inside the subclass, not the shared base class.
Four Shapes of Inheritance
CBSE Computer Science names a few standard shapes that inheritance hierarchies take. Rather than memorize the definitions in the abstract, let's extend our squad and see all four appear naturally.
First, add a class one level deeper — an opener is a specific kind of batsman who additionally tracks a powerplay strike rate:
class Opener(Batsman):
def __init__(self, name, team, matches_played, runs, innings, powerplay_strike_rate):
super().__init__(name, team, matches_played, runs, innings)
self.powerplay_strike_rate = powerplay_strike_rate
def show_info(self):
super().show_info()
print(f" Powerplay strike rate: {self.powerplay_strike_rate}")
Now add a class that draws from two parents at once — an all-rounder both bats and bowls:
class AllRounder(Batsman, Bowler):
def __init__(self, name, team, matches_played, runs, innings, wickets, runs_conceded):
Player.__init__(self, name, team, matches_played)
self.runs = runs
self.innings = innings
self.wickets = wickets
self.runs_conceded = runs_conceded
def calculate_rating(self):
return round((self.batting_average() + (100 - self.bowling_average())) / 2, 2)
With these five classes in place, here are the four shapes, all present in one small program:
- Single inheritance: one child, one parent.
Bowler(Player)on its own is single inheritance. - Hierarchical inheritance: several children share one parent.
BatsmanandBowlerboth extendingPlayeris hierarchical inheritance — the base class is reused, not duplicated, across siblings. - Multilevel inheritance: a chain, where a child becomes a parent to a grandchild.
Player → Batsman → Openeris a three-level chain. AnOpenerobject inherits from both its immediate parent (Batsman) and its grandparent (Player). - Multiple inheritance: one child, two or more direct parents.
class AllRounder(Batsman, Bowler):is multiple inheritance — anAllRounderis simultaneously a kind ofBatsmanand a kind ofBowler.
Multiple inheritance deserves one extra word of caution, because it is where super() can genuinely surprise you. Batsman and Bowler both inherit from Player — this diamond shape (one grandparent reached by two different parent paths) means Python must decide a single, unambiguous order to search when you ask for an attribute on an AllRounder. That order is computed once, when the class is defined, and stored as AllRounder.__mro__. For our five classes, that order is: AllRounder, then Batsman, then Bowler, then Player, then object (the built-in class every class in Python ultimately descends from, even if you never write it explicitly). This is precisely why AllRounder.__init__ above calls Player.__init__ directly by name instead of using super().__init__(...) inside Batsman and Bowler: with a diamond shape, super() inside Batsman would hand off to whatever comes next in the MRO of the actual object, which for an AllRounder is Bowler, not Player — and Bowler.__init__ expects wickets and runs_conceded, which Batsman doesn't have. This is a well-known trap with multiple inheritance, and calling the grandparent's constructor by its explicit class name, exactly once, is the simplest way around it for a program this size.
Polymorphism: One Call, Many Behaviours
Now that four different classes all define calculate_rating() in their own way, something powerful becomes possible. Build a squad list mixing every type freely:
rohan = Batsman("Rohan Verma", "Mumbai Warriors", 12, 245, 5)
ananya = Bowler("Ananya Iyer", "Chennai Strikers", 15, 10, 150)
priya = Opener("Priya Nair", "Kolkata Riders", 18, 540, 9, 145.5)
kabir = AllRounder("Kabir Singh", "Delhi Falcons", 20, 300, 6, 12, 180)
squad = [rohan, ananya, priya, kabir]
for player in squad:
print(player.name, "rating:", player.calculate_rating())
Trace it: the loop variable player holds a different concrete type on every pass, yet the loop body contains exactly one line, player.calculate_rating(), with no if-statement checking what type player is. Python looks up calculate_rating on whatever the actual object is, at the moment the line runs, and finds a different method body each time. The output is:
Rohan Verma rating: 99.0
Ananya Iyer rating: 85.0
Priya Nair rating: 110.0
Kabir Singh rating: 67.5
Verify one: Priya's average is 540 ÷ 9 = 60.0, and Opener doesn't override calculate_rating, so it inherits Batsman's formula, 50 + 60.0 = 110.0. Kabir's batting average is 300 ÷ 6 = 50.0 and bowling average is 180 ÷ 12 = 15.0, so his combined rating is (50.0 + (100 − 15.0)) ÷ 2 = 135.0 ÷ 2 = 67.5.
This behaviour — identical code, different outcomes, decided by the object's actual class at runtime — is the formal definition of polymorphism (from Greek: "many forms"). It is the direct payoff of overriding: because every subclass agreed to provide its own version of the same method name, code that only knows about the general concept of a Player can operate correctly on any of its specific subclasses, without ever being rewritten. This is sometimes phrased as "programming to the base class, not the subclass" — the for loop only ever talks about player, never mentions Batsman or Bowler by name, and still works when a fifth player type is added next year.
Two Misconceptions That Trip Up Every Beginner
Misconception 1: "Overriding a method deletes the parent's version." It does not. The parent's Player.calculate_rating still exists, untouched, inside the Player class. What changes is only which version gets found first when you call the method on a Batsman object, because of the MRO search order. Proof: Player.calculate_rating(rohan) — calling the parent's version explicitly, passing rohan as the object — still runs and returns 50, even though rohan.calculate_rating() returns 99.0. Overriding shadows a method for a particular class; it never erases it.
Misconception 2: "Polymorphism means defining the same method name twice with different parameter lists, and Python picks the right one based on the arguments." This describes method overloading, a feature of languages like Java and C++, and it is a common point of confusion because both ideas involve "one name, multiple behaviours." Python does not support overloading this way. If you write two methods with the same name in the same class, the second definition silently replaces the first — try it:
class Demo:
def greet(self):
print("Hello")
def greet(self, name):
print("Hello,", name)
d = Demo()
d.greet("Meera") # works: prints "Hello, Meera"
d.greet() # TypeError: greet() missing 1 required positional argument: 'name'
The first greet is gone entirely — not chosen between, just overwritten. Python's polymorphism, as demonstrated in the squad loop above, works across different classes, not multiple same-named methods inside one class, and it is resolved by which object you're calling the method on, not by counting arguments.
Checking Relationships at Runtime: isinstance() and issubclass()
Sometimes a program genuinely needs to ask "is this object a certain type of thing?" — for example, before calling a method that only some subclasses have. Python gives you two built-in functions for this rather than making you compare class names as text:
print(isinstance(priya, Player)) # True -- Opener IS-A Batsman IS-A Player
print(isinstance(priya, Bowler)) # False -- Opener never touches Bowler
print(issubclass(Opener, Player)) # True -- true at the class level, no object needed
print(issubclass(AllRounder, Bowler))# True -- multiple inheritance includes Bowler
print(issubclass(Bowler, Batsman)) # False -- siblings, not parent/child
isinstance(object, Class) checks a specific object against a class (and, importantly, against every class that object's class inherits from — the "is-a" relationship travels all the way up the chain, which is why priya, an Opener, correctly reports as an instance of Player two levels up). issubclass(ClassA, ClassB) checks the class relationship directly, without needing any object at all. Both read the same inheritance information the interpreter already used to build the MRO — they are not separate bookkeeping, just a different way of asking the same question Python is already answering every time you call a method.
Summary
- Inheritance lets a class (the child/subclass/derived class) reuse the attributes and methods of another class (the parent/superclass/base class) by writing
class Child(Parent):, eliminating duplicated code across related classes. super().__init__(...)hands the object's setup to the parent constructor; skipping it means the parent's attributes are never created, causing anAttributeErrorthe first time they're used.- Overriding means a child redefines a method its parent already has. The parent's version is not deleted — it is still reachable via
super()or by naming the parent class directly; only the search order changes which version runs by default. - CBSE recognizes four shapes of inheritance: single (one parent, one child), hierarchical (one parent, many children), multilevel (a chain across generations), and multiple (a child with more than one direct parent) — all four appeared together in the
Player/Batsman/Bowler/Opener/AllRounderhierarchy. - Multiple inheritance from a diamond shape needs care: Python resolves attribute lookup using a fixed Method Resolution Order (
Class.__mro__), and calling a grandparent's constructor by its explicit class name avoids surprises fromsuper()jumping sideways instead of upward. - Polymorphism means calling the same method name on objects of different classes and getting behaviour appropriate to each object's actual type, decided automatically at runtime — this is what makes a single loop over a mixed list of subclass objects work without any type-checking code.
- Python does not support classic method overloading (same name, different parameter counts, resolved by the caller's arguments) — a second same-named method in one class simply replaces the first.
isinstance(obj, Class)andissubclass(Child, Parent)let a program query the inheritance relationship directly instead of guessing from behaviour.
Practice
- Trace by hand: what does
issubclass(AllRounder, Player)return, and why, given thatAllRoundernever writesclass AllRounder(Player):directly? - A classmate writes
class Opener(Batsman): passwith no__init__at all. When they runOpener("Priya", "KKR", 18, 540, 9), does it work? Explain exactly which__init__runs and why, using what you know about attribute lookup. - Add a fifth class,
WicketKeeper(Batsman), that adds adismissalsattribute and overridescalculate_rating()to be50 + batting_average() + dismissals. Write the full class, then trace the output of creating one withruns=180, innings=6, dismissals=14and callingcalculate_rating(). - Explain, in your own words, why the line
Player.__init__(self, name, team, matches_played)is used insideAllRounder.__init__instead of callingsuper().__init__(...)the way every other subclass does. What specifically would go wrong if you replaced it withBatsman.__init__(self, name, team, matches_played, runs, innings)followed byBowler.__init__(self, name, team, matches_played, wickets, runs_conceded)? - A friend says: "Polymorphism in Python is just when you define two methods with the same name and different arguments in one class." Write two or three sentences correcting this, using the
calculate_rating()example from this chapter as evidence.