Inheritance and super()
Inheritance lets one class build on another. The child (subclass) gets every attribute and method of the parent (base class) for free, and can add new ones or replace existing ones. You declare it by naming the parent in brackets: class Dog(Animal):.
Overriding means defining a method in the child with the same name as one in the parent — the child's version wins for instances of the child. Sometimes you want to *extend* rather than fully replace; super() calls the parent's version so you can wrap it: super().describe() runs Animal.describe(), then you add to the result. super().__init__(...) is the common case — let the parent set up its attributes, then add the child's.
Inheritance shines for genuine "is-a" relationships (a Dog *is an* Animal). When you just want to *use* another object's features, prefer composition — hold the other object as an attribute (a Car *has an* Engine). A good rule: reach for inheritance only when the child truly is a specialised kind of the parent; otherwise compose.
Syntax
class Animal:
def __init__(self, name):
self.name = name
def speak(self):
raise NotImplementedError
def describe(self):
return f'{self.name} says {self.speak()}'
class Dog(Animal): # Dog inherits from Animal
def speak(self): # override
return 'Woof!'
def describe(self): # extend with super()
return super().describe() + ' — a good dog'Worked examples
Override a method; extend another with super()
class Animal:
def __init__(self, name):
self.name = name
def speak(self):
raise NotImplementedError('Subclass must implement speak()')
def describe(self):
return f'{self.name} says {self.speak()}'
class Dog(Animal):
def speak(self):
return 'Woof!'
def describe(self):
return super().describe() + ' — a good dog'
class Cat(Animal):
def speak(self):
return 'Meow!'
# describe() inherited unchanged
print(Dog('Rex').describe()) # Rex says Woof! — a good dog
print(Cat('Whiskers').describe()) # Whiskers says Meow!
print(issubclass(Dog, Animal)) # Truesuper().__init__ — let the parent set up first
class Account:
def __init__(self, owner):
self.owner = owner
self.balance = 0
class Savings(Account):
def __init__(self, owner, rate):
super().__init__(owner) # parent sets owner + balance
self.rate = rate # child adds its own
s = Savings('Sam', 0.05)
print(s.owner, s.balance, s.rate) # Sam 0 0.05The inherited method just works on the child
# Cat never defines __init__ or describe — it borrows Animal's.
c = Cat('Whiskers')
print(c.name) # Whiskers (Animal.__init__ ran)
print(c.describe()) # Whiskers says Meow! (Animal.describe + Cat.speak)Common mistakes & gotchas
Forgetting super().__init__()
If a child defines its own __init__ and forgets super().__init__(...), the parent's setup never runs — so attributes like self.owner are never created, and you get an AttributeError later. When you override __init__, almost always call super().__init__(...).
Overriding when you meant to extend
Redefining a method completely *replaces* the parent's — its logic no longer runs. If you wanted to add to it, not throw it away, call super().method() inside your override and build on the result.
Deep inheritance trees get tangled
Stacking many layers of subclassing makes behaviour hard to trace (which class defined this method?). For 'has-a' relationships use composition instead of inheritance — favour shallow hierarchies and only inherit on a true 'is-a'.
Why it matters
Inheritance is how frameworks let you plug in: you subclass a base View, Model, or Exception and override the bits you care about, getting everything else for free. Knowing when to inherit (a real 'is-a') versus compose (a 'has-a') is a core design judgement that keeps codebases flexible rather than brittle.