Python inheritance — theory with the faculty library: super(), overriding, polymorphism and abstract classes (and the ebook that lost its title)
The week we finished the faculty library catalogue (the one from the Python classes topic), someone suggested adding ebooks. “Easy,” I said: an Ebook class that inherits from Book, with its own __init__ to store the megabytes. I created it, printed the megabytes, all good. And as soon as the catalogue asked for the book’s card, this:
class Book:
def __init__(self, title, author):
self.title = title
self.author = author
def card(self):
return f'‘{self.title}’, by {self.author}'
class Ebook(Book):
def __init__(self, title, author, megabytes):
# missing super().__init__(title, author): title and author are never stored
self.megabytes = megabytes
ebook = Ebook('Python for Everybody', 'Severance', 2.4)
print(ebook.megabytes)
print(ebook.card())
The real output:
2.4
Traceback (most recent call last):
File "hook.py", line 18, in <module>
print(ebook.card())
~~~~~~~~~~^^
File "hook.py", line 7, in card
return f'‘{self.title}’, by {self.author}'
^^^^^^^^^^
AttributeError: 'Ebook' object has no attribute 'title'
How can Ebook have no title if it inherits from Book and Book stores it in its __init__? That’s the trap: by writing my own __init__, the one in Book no longer runs by itself. Nobody stored the title. That slip is the most common mistake of this FP2 topic, and in this article you’ll see why it happens, how super() fixes it and everything else that comes up in the exam: method overriding, polymorphism, isinstance versus type, the object class, multiple inheritance and the method resolution order (MRO), private attributes in subclasses and abstract classes with ABC.
We keep working with the faculty library: books, magazines and three kinds of member (students, lecturers and visitors) with different loan periods and fines. The data is made up (these aren’t the real rules of the ULPGC library), but it looks like the real thing.
Table of Contents
—
What is inheritance in Python?
Inheritance lets you create a new class from one that already exists. The class you start from is called the superclass, base class or “parent”; the new one is the subclass, derived class or “child”. The child gets all the attributes and methods of the parent without writing them again, and it can add its own or override the ones that don’t suit it.
In Python you write the parent in brackets after the child’s name:
class Book:
"""What any book in the library catalogue has."""
def __init__(self, title, author):
self.title = title
self.author = author
def card(self):
return f'‘{self.title}’, by {self.author}'
def loan_days(self):
return 15 # the library's normal loan period
class Magazine(Book): # a Magazine IS A Book: inherits everything
pass # without writing a single extra line
r = Magazine('Scientific American', 'Various authors')
print(r.card()) # method inherited from Book
print(r.loan_days()) # inherited too
print(type(r).__name__, 'inherits from', Magazine.__bases__[0].__name__)
print('Does Magazine have its own card?', 'card' in Magazine.__dict__)
print('Does Book have its own card? ', 'card' in Book.__dict__)
Output:
‘Scientific American’, by Various authors 15 Magazine inherits from Book Does Magazine have its own card? False Does Book have its own card? True
Magazine doesn’t have a single line of its own (pass), and yet r.card() and r.loan_days() work. The last two lines prove why: card is not in the dictionary of Magazine (Magazine.__dict__); it’s in the one of Book. When you ask for r.card(), Python doesn’t find it in the object’s class and climbs to the parent. That upward search is the whole secret of inheritance: methods aren’t copied, they’re looked up.
When does inheriting make sense? When there’s an “is a” relationship between the two classes: a magazine is an item of the library, a student is a member. If the relationship is “has a” (a library has books, a member has loans), you don’t inherit: you store one object inside another, as we did with Library in the classes topic.
| Relationship | Example | What you do |
|---|---|---|
| “is a” | A Student is a Member | Inheritance: class Student(Member) |
| “has a” | A Library has Books | Composition: a list of books as an attribute |
| “is a”, but doesn’t behave the same | A DVD that can’t be taken home | Inheritance + override the method that changes |
In the FP2 notes you’ll see the same idea in Java with extends: class Two extends One. In Python it’s the brackets: class Two(One). And a difference that shows up in the test: Python allows multiple inheritance (several parents separated by commas); Java doesn’t.
—
Initialising subclasses: super()
Back to the hook. If the child doesn’t write __init__, it inherits the parent’s and there’s no problem. But as soon as it writes its own, that __init__ hides the parent’s: Python finds one in the child and stops climbing. That’s why, when the child needs its own __init__, the first thing it does is call the parent’s with super():
class Book:
def __init__(self, title, author):
self.title = title
self.author = author
def card(self):
return f'‘{self.title}’, by {self.author}'
class Ebook(Book):
def __init__(self, title, author, megabytes):
super().__init__(title, author) # 1) the parent stores the common part
self.megabytes = megabytes # 2) the child adds its own
ebook = Ebook('Python for Everybody', 'Severance', 2.4)
print(ebook.card())
print(vars(ebook)) # all three attributes, in the same object
Output:
‘Python for Everybody’, by Severance
{'title': 'Python for Everybody', 'author': 'Severance', 'megabytes': 2.4}
Look at vars(ebook): the three attributes live in the same object. There isn’t a “Book object” inside the “Ebook object”: there’s a single object, and the parent’s __init__ gives it title and author while the child’s gives it megabytes. The diagram compares both cases:
The practical rule has two parts:
- The child’s
__init__receives its own parameters and the parent’s (heretitle, author, megabytes). - It passes the parent’s ones to
super().__init__(…)and stores its own.
Does the order matter? It’s almost always better to call super().__init__ first, so that if the child needs something the parent sets (for example, to compute something with the title), it already exists. In the practice you’ll see a sensible exception: the Programmer validates its level before calling the parent, so nothing is created if the data is wrong.
Writing the child’s __init__ and forgetting super().__init__(…). It doesn’t fail when you create the object: the error comes later, in the first inherited method that uses a parent attribute (AttributeError: 'Ebook' object has no attribute 'title'). If you see that message with an attribute that “should” be there, look at the child’s __init__.
Why super() and not the class name
Calling the parent by its name also works:
class Book:
def __init__(self, title, author):
self.title = title
self.author = author
class Ebook(Book):
def __init__(self, title, author, megabytes):
Book.__init__(self, title, author) # works, but with self by hand
self.megabytes = megabytes # and the parent's name spelled out
ebook = Ebook('Python for Everybody', 'Severance', 2.4)
print(vars(ebook))
Output:
{'title': 'Python for Everybody', 'author': 'Severance', 'megabytes': 2.4}
Same result, so why do the notes insist on super()? Three reasons:
- You don’t pass
selfby hand. WithBook.__init__(self, …)you’re calling the class’s function and you must hand it the object yourself. Withsuper().__init__(…),selfgoes automatically, as in any method. - You don’t repeat the parent’s name. If tomorrow
Ebookinherits fromIteminstead ofBook, withsuper()you change nothing; with the name, you have to hunt down every call. - It respects the lookup order (MRO) with multiple inheritance, which we’ll see below.
super()doesn’t mean “my parent”, but “the next class in this object’s lookup order”. With a single parent they’re the same; with several, they aren’t.
—
Method overriding
The child can redefine an inherited method by writing one with the same name. It’s called overriding: when the object belongs to the child, Python finds the child’s version first and stops climbing.
class Member:
def __init__(self, name):
self.name = name
def limit(self): # how many books they can borrow at once
return 3
def loan_days(self):
return 15
def description(self):
return f'{self.name}: {self.limit()} books, {self.loan_days()} days'
class Student(Member):
pass # uses everything from Member
class Lecturer(Member): # teaching and research staff
def limit(self): # OVERRIDES Member's
return 10
def loan_days(self):
return 60
def description(self): # EXTENDS Member's: calls it and adds
return super().description() + ' (staff, no fines)'
for s in [Student('Laura'), Lecturer('Dr Ramos')]:
print(s.description())
Output:
Laura: 3 books, 15 days Dr Ramos: 10 books, 60 days (staff, no fines)
Three things happen at once here, and it’s worth separating them:
Studentdoesn’t override anything:laura.limit()climbs up toMemberand returns 3.Lectureroverrideslimitandloan_days: its objects no longer see the ones inMember. (ALectureris what Spanish universities call PDI: teaching and research staff.)descriptionis defined inMember, but it callsself.limit(). When the object is aLecturer, thatself.limit()searches from the object’s class, so it finds the 10 fromLecturer. The parent calls a method the child has changed, and the child’s runs. This is what surprises people most the first time.
To know which method runs, don’t look at where the call is written: look at what class the object is (self) and search upwards from there. self.limit() inside Member doesn’t mean “the limit of Member“.
Calling the parent’s method from the override
Overriding doesn’t force you to throw away what the parent did. In Lecturer.description, super().description() returns the text from Member (“Dr Ramos: 10 books, 60 days”) and the child adds ” (staff, no fines)”. There are two kinds of override:
| Kind | How you write it | When |
|---|---|---|
| Replace | def limit(self): return 10 | The parent’s behaviour is of no use |
| Extend | return super().description() + ' (staff…)' | The parent’s part is fine and the child adds something before or after |
Extending and forgetting the return: text = super().description() + '…' and nothing else. The method returns None and the print shows None. You’ll see it with real output in the “What’s wrong here?” section of the exercises.
In Java an override is marked with @Override and the compiler warns you if the signature doesn’t match. Python has no such safety net: if in the child you write def limits(self) (one s too many), you don’t override anything, you create a new method and the parent keeps using its own. Abstract classes, at the end of the article, partly protect you from that.
—
Polymorphism: same interface, different behaviour
Polymorphism means the same code works with objects of different classes, and each one responds in its own way. The fine function below computes any member’s fine without asking what kind of member it is: it asks for the loan period and the fine per day, and each object answers its own.
class Member:
def __init__(self, name):
self.name = name
def loan_days(self):
return 15
def fine_per_day(self): # euros per day late
return 0.20
class Lecturer(Member):
def loan_days(self):
return 60
def fine_per_day(self):
return 0.0
class Visitor(Member): # one-day card
def loan_days(self):
return 7
def fine_per_day(self):
return 0.50
def fine(member, days_out):
"""Doesn't know (or care) what class the member is: it asks."""
delay = max(0, days_out - member.loan_days())
return delay * member.fine_per_day()
for s in [Member('Laura'), Lecturer('Dr Ramos'), Visitor('Pablo')]:
print(f'{type(s).__name__:<9} {s.name:<10} 20 days out -> {fine(s, 20):.2f} €')
Output:
Member Laura 20 days out -> 1.00 € Lecturer Dr Ramos 20 days out -> 0.00 € Visitor Pablo 20 days out -> 6.50 €
Check the numbers by hand: 20 days out with a 15-day period is 5 days late; 5 × 0.20 = 1.00 € for Laura. For the lecturer, 20 − 60 is negative and max(0, …) turns it into 0. For the visitor, 20 − 7 = 13 days × 0.50 = 6.50 €.
What matters is what’s not in fine: not a single if type(member) == …. If tomorrow the library creates AlumniMember with 30 days and 0.10 €/day, the function works without touching it. That’s the practical advantage of polymorphism: code that uses the objects doesn’t change when new types appear.
In Python, polymorphism doesn’t even need inheritance. All fine needs is an object with loan_days() and fine_per_day(); if a class unrelated to Member has them, it works too. That’s duck typing (“if it walks like a duck and quacks…”). Inheritance is there to avoid repeating code and to write the contract down; polymorphism comes from calling the same methods.
—
isinstance vs type: the difference that confuses most
Two functions look like they do the same thing and they don’t:
type(obj)returns the object’s exact class.isinstance(obj, Class)tells you whether the object belongs to that class or any of its children.
class Member:
def loan_days(self):
return 15
class Student(Member):
pass
class Lecturer(Member):
def loan_days(self):
return 60
e = Student()
print(type(e) == Student, type(e) == Member) # EXACT type
print(isinstance(e, Student), isinstance(e, Member)) # respects inheritance
print(isinstance(e, object), issubclass(Lecturer, Member))
print([c.__name__ for c in Student.__mro__]) # lookup order
print('__str__' in dir(e), '__str__' in Member.__dict__) # object provides it
Output:
True False True True True True ['Student', 'Member', 'object'] True False
Line by line: type(e) == Member is False because e is exactly an Student; isinstance(e, Member) is True because a student is a member. issubclass asks the same question with classes instead of objects. And __mro__ (Method Resolution Order) is the exact list of classes Python goes through when looking up a method: Student → Member → object.
| I want to know… | Use | With e = Student() |
|---|---|---|
| Whether I can treat it as a Member | isinstance(e, Member) | True |
| Its exact class | type(e) | Student |
| Whether a class inherits from another | issubclass(Student, Member) | True |
| In what order methods are looked up | Student.__mro__ | Student, Member, object |
The rule for the exam and for life: isinstance almost always. Comparing with type breaks inheritance: if you filter “the members” with type(s) == Member, you lose every student and lecturer. In the practice you’ll see exactly that bug with the petrol cars and the hybrid.
Using isinstance to decide what to do with each type (if isinstance(s, Lecturer): … elif isinstance(s, Visitor): …). It works, but it’s the opposite of polymorphism: every new type forces you to edit that if. If you catch yourself writing that chain, that behaviour should be a method each class overrides.
—
The object class: the root of everything
The last line of the previous output has a twist: '__str__' in dir(e) is True, but '__str__' in Member.__dict__ is False. Where does that __str__ come from? From object: in Python 3 every class inherits from object even if you don’t write it (class Member: is the same as class Member(object):). That’s why any object has __str__, __repr__, __eq__… even if you didn’t define them, and why print(laura) without __str__ prints the famous <__main__.Student object at 0x…>: it’s the __str__ of object.
When you wrote __str__ or __repr__ in the classes topic, you were overriding the ones in object. And in the next topic, operator overloading, you’ll do the same with __eq__, __lt__ or __add__.
—
Multiple inheritance and the MRO
Python lets a class have several parents. A research fellow teaches (like a lecturer) and studies a master’s degree at the same time (like a student). What’s their loan limit?
class Student:
def limit(self):
return 3
def loan_days(self):
return 15
class Lecturer:
def limit(self):
return 10
def loan_days(self):
return 60
class Fellow(Lecturer, Student): # teaches AND studies a master's
def loan_days(self):
return 30 # its own
b = Fellow()
print([c.__name__ for c in Fellow.__mro__])
print('limit:', b.limit()) # doesn't have it: Lecturer wins (comes first)
print('days:', b.loan_days()) # it has its own: Fellow wins
Output:
['Fellow', 'Lecturer', 'Student', 'object'] limit: 10 days: 30
Python decides with the MRO: it looks in Fellow, then in the parents in the order you wrote them (Lecturer, then Student) and finally in object. limit isn’t in Fellow, so Lecturer‘s wins (10). loan_days is in Fellow, so its own wins (30). If you had written class Fellow(Student, Lecturer), the limit would be 3.
Multiple inheritance has good uses (small classes that add one ability, called mixins), but in FP2 what you’re asked is to understand the MRO and predict which method wins. If in doubt, print Class.__mro__: it’s the exact answer.
—
Subclass attributes
The child sees everything the parent stores in the object… with one exception that confuses a lot: attributes with two underscores.
class Member:
def __init__(self, name, code):
self.name = name # public
self._card = code[:3] # "protected": a convention, no renaming
self.__code = code # private: Python stores it as _Member__code
class Student(Member):
def summary(self):
return f'{self.name} ({self._card}) {self.__code}'
e = Student('Laura', 'EST-4471')
print([k for k in vars(e)])
print(e.summary())
Output:
['name', '_card', '_Member__code']
Traceback (most recent call last):
File "private.py", line 15, in <module>
print(e.summary())
~~~~~~~~~^^
File "private.py", line 10, in summary
return f'{self.name} ({self._card}) {self.__code}'
^^^^^^^^^^^
AttributeError: 'Student' object has no attribute '_Student__code'
vars(e) explains it: the __code attribute written by Member was stored as _Member__code (name mangling, which we saw in the classes topic). Inside Student, writing self.__code turns into self._Student__code, which doesn’t exist. That’s why the traceback names _Student__code.
| What it’s called | Example | Can the child use it with that name? |
|---|---|---|
| Public | self.name | Yes |
| “Protected” (one underscore) | self._card | Yes: it’s a convention, “internal use, children included” |
| Private (two underscores) | self.__code | No: it’s called _Member__code; use a property or a parent method |
The clean way out: if children need some data, the parent offers it through a property (@property) or stores it with a single underscore. What you shouldn’t do is write self._Member__code in the child: it works, but it bypasses the encapsulation you wanted.
—
Abstract classes: contracts that must be fulfilled
In the library there’s no such thing as “an item” on its own: there are books, magazines, DVDs. Item is an idea that gathers what they share, but creating a Item('What am I?') makes no sense. That’s what abstract classes are for: a class that inherits from ABC (Abstract Base Class, from the abc module) and marks with @abstractmethod the methods every child must write.
from abc import ABC, abstractmethod
class Item(ABC): # ABC: Abstract Base Class
def __init__(self, title):
self.title = title
@property
@abstractmethod
def kind(self): # abstract property (as in the notes)
pass
@abstractmethod
def loan_days(self):
pass
def card(self): # normal method that USES the abstract ones
return f'[{self.kind}] {self.title}: {self.loan_days()} days'
class Book(Item):
@property
def kind(self):
return 'Book'
def loan_days(self):
return 15
class Magazine(Item):
@property
def kind(self):
return 'Magazine'
def loan_days(self):
return 7
for m in [Book('Calculus'), Magazine('Scientific American')]:
print(m.card())
try:
Item('What am I?')
except TypeError as err:
print('TypeError:', err)
Output:
[Book] Calculus: 15 days [Magazine] Scientific American: 7 days TypeError: Can't instantiate abstract class Item without an implementation for abstract methods 'kind', 'loan_days'
Three things to look at:
kindis an abstract property:@propertyabove@abstractmethod, in that order (it’s what the notes show witharea). Children implement it as a property too, which is whycardwritesself.kindwithout brackets.cardis a normal method inside the abstract class, and it uses the abstract ones. The parent writes the recipe and the children provide the ingredients. This pattern is called the template method, and you’ll see it in all three programs of the practice.- Creating an
ItemraisesTypeError, and the message says exactly what’s missing:without an implementation for abstract methods 'kind', 'loan_days'.
And if a child fulfils only half of the contract?
from abc import ABC, abstractmethod
class Item(ABC):
def __init__(self, title):
self.title = title
@abstractmethod
def loan_days(self):
pass
@abstractmethod
def kind(self):
pass
class Dvd(Item):
def loan_days(self): # fulfils only half of the contract
return 3
d = Dvd('EII documentary')
Output:
Traceback (most recent call last):
File "incomplete.py", line 22, in <module>
d = Dvd('EII documentary')
TypeError: Can't instantiate abstract class Dvd without an implementation for abstract method 'kind'
Dvd wrote loan_days but not kind, so it’s abstract too and can’t be instantiated either. The good part is when the error appears: when creating the object, not days later when someone calls kind(). It’s the safety net Python lacks compared to Java’s @Override.
Inheriting from ABC but forgetting @abstractmethod. Then the “abstract” method with pass is a normal method that returns None, the class can be instantiated and the bug shows up late again. Both go together: class X(ABC) and @abstractmethod on every required method.
If a test question says “what happens when you run Shape() if Shape has an unimplemented abstract method?”, the answer is TypeError when the object is created, not when the method is called.
—
Abstract class vs normal class: when to use each
| Situation | Choose | Library example |
|---|---|---|
| The parent makes sense on its own and the children are variants | Normal class | Member with default values; Lecturer changes the period |
| The parent is just a shared idea; there are no “generic” objects | Abstract class (ABC) | Item: it’s always a book, a magazine or a DVD |
| You want a forgotten method to fail when the object is created | Abstract class with @abstractmethod | loan_days in every item |
| There’s a sensible default value that some children change | Normal method in the parent (hook) | fine_per_day() returns 0.20 and the lecturer overrides it with 0 |
The last row is the most useful in practice: not everything has to be abstract. A normal method with a default value that some children override (a hook) saves you writing the same thing in five classes.
—
Common mistakes with inheritance
Forgetting super().__init__ in a child with its own __init__: AttributeError in the first inherited method that uses a parent attribute.
Changing the signature when overriding a method (def loan_days(self, in_summer) in the child when the parent calls it with no arguments): TypeError: … missing 1 required positional argument. Children must accept the same as the parent.
Using the parent’s self.__attribute in the child: AttributeError with _Child__attribute. Use a property or a single underscore.
Filtering with type(x) == Parent: children slip through. Use isinstance.
Misspelling the name of the method you override (loans_days): you override nothing. With an abstract class the error appears when creating the object; without one, the parent silently keeps using its own.
—
Watch it step by step in Python Tutor
pythontutor.com runs the code line by line and draws the objects, their attributes and the call stack. Choose Python 3, paste this program and press Visualize Execution:
class Member:
def __init__(self, name):
self.name = name
def days(self):
return 15
def card(self):
return self.name + ': ' + str(self.days()) + ' days'
class Lecturer(Member):
def __init__(self, name, dept):
super().__init__(name)
self.dept = dept
def days(self):
return 60
members = [Member('Laura'), Lecturer('Ramos', 'Computing')]
for s in members:
print(s.card())
Output:
Laura: 15 days Ramos: 60 days
What to watch step by step:
- When
Lecturer('Ramos', 'Computing')is created, look at the stack on the right: two__init__frames appear at once, the one ofLecturerand, on top, the one ofMembercalled bysuper(). Both have the sameself(the arrow points to the same object). - When they finish, the object has
nameanddept: the parent set one and the child the other, but it’s a single object. - In the second iteration of the
for, it enterscard(which lives inMember) and from there callsself.days(). Look where it jumps: toLecturer‘s version, becauseselfis aLecturer. - In the first iteration, the same
self.days()jumps toMember‘s. Same line, two destinations: that’s polymorphism live.
—
Quick summary
| Concept | How you write it | What to remember |
|---|---|---|
| Inherit | class Child(Parent): | Methods aren’t copied: they’re looked up upwards |
| Initialise | super().__init__(…) | Required if the child writes its own __init__ |
| Override | same name in the child | Same signature as the parent |
| Extend | return super().method() + … | Don’t forget the return |
| Polymorphism | same calls, different objects | No chains of if type(…) |
| Check the type | isinstance(obj, Class) | type ignores inheritance |
| Lookup order | Class.__mro__ | With multiple inheritance, left to right |
| Private | __x → _Parent__x | The child doesn’t see it with that name |
| Abstract | class X(ABC) + @abstractmethod | TypeError on creation if something is missing |
Three ideas to keep:
- Python looks methods up from the object’s class upwards, and the first one it finds wins. Almost everything in this topic follows from that.
- If the child writes
__init__, it callssuper().__init__: otherwise the parent’s data never reaches the object. - An abstract class turns a slip into an early error: better a
TypeErrorwhen creating the object than a weirdNonethree days later.
In the inheritance practice we build three complete hierarchies (a payroll, a car fleet with a hybrid that inherits from a petrol car, and a shop with IGIC) and you’ll see the template method in action.
Next step: practicePython inheritance practice — a payroll, a fleet with a hybrid and a shop with IGIC (and the formula the hybrid copied instead of inheriting)Did this article help you? Here is how you can support SergioLearns →




The hardware that holds up the operating system — theory with my IC2 assignment on the printer, an interrupt that stops everything and a cache that hits (and the infinite loop that didn’t freeze my laptop)