You need Python installed, and to know how to run a
.py file. If python3 --version in a
terminal prints a version number, you are ready. If it does not — or
if you have never opened a terminal — do
Getting Started with
Python first; it installs Python and runs your first program, and
takes about fifteen minutes. Nothing else is needed: no account, no
payment, no extra software.
What is Inheritance?
Inheritance lets you create a new class based on an existing one. The new class (child) inherits all attributes and methods from the existing class (parent), and can add or override them.
# Parent class (base class)
class Animal:
def __init__(self, name, sound):
self.name = name
self.sound = sound
def speak(self):
print(f"{self.name} says: {self.sound}!")
def info(self):
print(f"I'm {self.name}, an animal.")
# Child class (inherits from Animal)
class Dog(Animal):
def fetch(self, item):
print(f"{self.name} fetches the {item}!")
# Dog inherits __init__, speak, and info from Animal
rex = Dog("Rex", "Woof")
rex.speak() # Rex says: Woof!
rex.info() # I'm Rex, an animal.
rex.fetch("ball") # Rex fetches the ball!
Overriding Methods
Child classes can replace parent methods with their own version:
class Animal:
def __init__(self, name):
self.name = name
def speak(self):
print(f"{self.name} makes a sound.")
def info(self):
return f"{self.name} (Animal)"
class Cat(Animal):
def speak(self): # Override parent method
print(f"{self.name} says: Meow!")
def info(self): # Override parent method
return f"{self.name} (Cat)"
cat = Cat("Whiskers")
cat.speak() # Whiskers says: Meow! (uses Cat's version)
print(cat.info()) # Whiskers (Cat)
Using super()
super() calls the parent class's method, letting you extend rather
than completely replace it:
class Vehicle:
def __init__(self, make, model, year):
self.make = make
self.model = model
self.year = year
def info(self):
return f"{self.year} {self.make} {self.model}"
class ElectricCar(Vehicle):
def __init__(self, make, model, year, battery_kwh):
super().__init__(make, model, year) # Call parent's __init__
self.battery_kwh = battery_kwh # Add new attribute
def info(self):
base = super().info() # Get parent's info
return f"{base} (Electric, {self.battery_kwh}kWh)"
tesla = ElectricCar("Tesla", "Model 3", 2024, 75)
print(tesla.info()) # 2024 Tesla Model 3 (Electric, 75kWh)
When your child class has its own __init__, always call
super().__init__(...) first to ensure the parent is properly
initialized. Forgetting this is a common source of bugs.
Polymorphism
Polymorphism means different classes can be used through the same interface. If multiple classes have the same method name, you can call it without knowing which class the object belongs to:
class Shape:
def area(self):
raise NotImplementedError("Subclasses must implement area()")
class Rectangle(Shape):
def __init__(self, width, height):
self.width = width
self.height = height
def area(self):
return self.width * self.height
class Circle(Shape):
def __init__(self, radius):
self.radius = radius
def area(self):
import math
return math.pi * self.radius ** 2
class Triangle(Shape):
def __init__(self, base, height):
self.base = base
self.height = height
def area(self):
return 0.5 * self.base * self.height
# Polymorphism in action — same method, different behavior
shapes = [
Rectangle(10, 5),
Circle(7),
Triangle(8, 6)
]
for shape in shapes:
print(f"{type(shape).__name__}: area = {shape.area():.2f}")
Checking Inheritance
class Animal: pass
class Dog(Animal): pass
class Cat(Animal): pass
rex = Dog()
print(isinstance(rex, Dog)) # True
print(isinstance(rex, Animal)) # True (Dog inherits from Animal)
print(isinstance(rex, Cat)) # False
print(issubclass(Dog, Animal)) # True
print(issubclass(Cat, Dog)) # False
Composition vs Inheritance
Not everything should use inheritance. Sometimes it's better for a class to contain another class (composition) rather than inherit from it:
# Inheritance: "is a" relationship
# A Dog IS an Animal — inheritance makes sense
class Dog(Animal): pass
# Composition: "has a" relationship
# A Car HAS an Engine — composition makes sense
class Engine:
def __init__(self, horsepower):
self.horsepower = horsepower
def start(self):
print(f"Engine ({self.horsepower}hp) started!")
class Car:
def __init__(self, make, engine):
self.make = make
self.engine = engine # Car HAS an Engine
def start(self):
print(f"Starting {self.make}...")
self.engine.start()
engine = Engine(200)
car = Car("Toyota", engine)
car.start()
# Starting Toyota...
# Engine (200hp) started!
Deep inheritance chains (3+ levels) become hard to understand and maintain. Ask yourself: "Is this an 'is-a' relationship?" If not, use composition.
Practical Example: Notification System
class Notification:
def __init__(self, recipient, message):
self.recipient = recipient
self.message = message
def send(self):
raise NotImplementedError
def __str__(self):
return f"To: {self.recipient} — {self.message}"
class EmailNotification(Notification):
def __init__(self, recipient, message, subject):
super().__init__(recipient, message)
self.subject = subject
def send(self):
print(f"Sending email to {self.recipient}")
print(f" Subject: {self.subject}")
print(f" Body: {self.message}")
class SMSNotification(Notification):
def send(self):
# SMS messages are limited to 160 chars
msg = self.message[:160]
print(f"Sending SMS to {self.recipient}: {msg}")
class PushNotification(Notification):
def __init__(self, recipient, message, app_name):
super().__init__(recipient, message)
self.app_name = app_name
def send(self):
print(f"Push [{self.app_name}] to {self.recipient}: {self.message}")
# Polymorphism — send all notifications the same way
notifications = [
EmailNotification("alice@example.com", "Your order shipped!", "Order Update"),
SMSNotification("+1234567890", "Your code is 482910"),
PushNotification("alice_device", "New message received", "ChatApp")
]
for notification in notifications:
notification.send()
print()
Now Do It Yourself: Five Steps
Inheritance lets one class build on another instead of repeating it. You will make a general class, a specialised one, and see exactly what each contributes. Every output below is exactly what Python printed.
Go: open a terminal, run cd ~, and create a file called animals.py.
Do: type these six lines and run python3 animals.py.
class Animal:
def __init__(self, name):
self.name = name
def speak(self):
return self.name + " makes a sound"
print(Animal("Generic").speak())
You should see: Generic makes a sound. Nothing new yet — this is an ordinary class, and it is the thing the next step will build on.
If not: if you get IndentationError, both methods must be indented four spaces inside the class. If NameError: name 'Animal' is not defined, the print is above the class instead of below it — Python reads a file top to bottom, so a class must be defined before it is used.
Go: same file, add to the bottom.
Do: add these four lines and run it.
class Dog(Animal):
def speak(self):
return self.name + " says woof"
print(Dog("Rex").speak())
You should see: Generic makes a sound then Rex says woof. Dog(Animal) means "a Dog is an Animal". You never wrote __init__ for Dog, yet Dog("Rex") worked — it was inherited. Only speak was replaced, and replacing an inherited method is called overriding.
If not: TypeError: Dog() takes no arguments means you wrote class Dog: without (Animal), so it inherited nothing and has no __init__ at all. The brackets after the class name are the entire mechanism.
Go: same file, add one line at the bottom.
Do: add this and run it.
print(isinstance(Dog("Rex"), Animal))
You should see: True. A Dog genuinely is an Animal as far as Python is concerned, which is what lets you write code that accepts any animal and pass it a dog.
If not: False means Dog is not actually inheriting — check for the (Animal) in the class line. This test is the quickest way to confirm a hierarchy is wired the way you think it is.
Go: same file, find the Dog class.
Do: add an __init__ to it, keeping the speak method you already wrote, so the whole class reads as below. Then print both values.
class Dog(Animal):
def __init__(self, name, breed):
super().__init__(name)
self.breed = breed
def speak(self):
return self.name + " says woof"
d = Dog("Rex", "Collie")
print(d.name, d.breed)
You should see: Rex Collie. super().__init__(name) runs the parent's setup, which is what stores name; then you add the part that is specific to a dog. Do the parent's work first, your own second.
If not: if Rex starts saying makes a sound again, you dropped the speak method while adding __init__ — a class definition is the whole class, so anything you leave out is gone. AttributeError: 'Dog' object has no attribute 'name' means the super() line is missing instead. Writing your own __init__ completely replaces the parent's — it is not added to it — so if you do not call super(), nothing the parent set up ever happens.
Go: same file, replace the bottom.
Do: add a Cat and loop over a mixed list.
class Cat(Animal):
def speak(self):
return self.name + " says meow"
for a in [Animal("Generic"), Dog("Rex", "Collie"), Cat("Tom")]:
print(a.speak())
You should see: three lines — Generic makes a sound, Rex says woof, Tom says meow. One loop, one method name, three different behaviours. The loop does not know or care which kind each one is; that is the reason inheritance is worth the effort.
If not: if every line says "makes a sound", the subclasses spell the method differently from the parent — Speak is not speak. An override only overrides when the name matches exactly, and Python will not warn you, because adding a new method is perfectly legal.
Without scrolling up: a subclass defines its own __init__ and then
self.name raises AttributeError. What is missing?
Answer: the super().__init__(name) call. Your __init__
replaces the parent's rather than adding to it.
Now do it without the page: add a Bird that overrides
speak and also takes a can_fly value in its own
__init__. It needs both step 4's super() and step 2's
override in the same class.
Summary
- Inheritance creates child classes that inherit from parent classes:
class Child(Parent) - Override methods by redefining them in the child class
super()calls the parent's version of a method- Polymorphism lets different classes respond to the same method call differently
- Use
isinstance()andissubclass()to check relationships - Prefer composition ("has-a") over inheritance ("is-a") when the relationship isn't clear
You now understand the core pillars of OOP in Python. Next up: working with JSON data — a critical skill for APIs, configs, and data exchange.