Lesson 21: Python OOP — Class Properties, Methods, Inheritance & Polymorphism
Lesson 21: Python OOP — Class Properties, Methods, Inheritance & Polymorphism
Lesson Introduction
Welcome to one of the most powerful lessons in your Python journey.
So far you have learned how to write functions, use loops, work with lists, and perhaps even create your first simple class. Now you are about to discover four core ideas that professional Python developers use every single day:
- Class Properties — giving every object its own personal data
- Class Methods — giving every object its own personal actions
- Inheritance — letting new classes borrow and extend the work of existing classes
- Polymorphism — letting different objects respond to the same instruction in their own unique ways
Together, these four ideas form the heart of Object-Oriented Programming (OOP) — the way most real-world Python software is designed.
By the end of this lesson, you will be able to build a small but realistic multi-class Python program from scratch, understand how professional libraries and frameworks are structured, and feel genuinely confident reading and writing OOP code.
Let’s start from the very beginning.
Prerequisite Concepts
Before diving in, let’s make sure you have the foundations you need. If you already know these, great — skim and move on. If they are new, read carefully.
What Is a Class?
Think of a class as a blueprint or a template.
Imagine you work in a car factory. Before building a car, engineers draw a detailed blueprint showing what every car should look like — how many wheels it has, what colour options exist, what the engine size is, what buttons are on the dashboard.
That blueprint is the class.
Each actual car that rolls off the production line using that blueprint is an object (also called an instance).
class Car:
pass # An empty blueprint — we will fill it in very soon
What Is an Object?
An object is a specific thing created from a class blueprint.
my_car = Car() # Create one Car object
your_car = Car() # Create another Car object
Both my_car and your_car are objects of the Car class. They are two separate things built from the same blueprint.
What Is __init__?
The __init__ method is a special function that runs automatically the moment you create an object. It lets you set up the object’s starting data.
class Car:
def __init__(self, brand, colour):
self.brand = brand
self.colour = colour
my_car = Car("Toyota", "Red")
print(my_car.brand) # Output: Toyota
print(my_car.colour) # Output: Red
Don’t worry if self looks strange. We will explain it thoroughly very soon.
Part 1: Class Properties
What Are Class Properties?
A property (also called an attribute) is a piece of data that belongs to an object.
Think of it as a variable that lives inside an object and describes it.
Real-world analogy:
| Object | Properties |
|---|---|
| A person | name, age, height, eye colour |
| A bank account | account number, balance, owner name |
| A book | title, author, number of pages |
| A dog | name, breed, age |
In Python, we store these properties on objects using the keyword self.
What Is self?
This is the single biggest question beginners have about OOP. Let’s answer it completely.
When Python creates an object, it needs a way for that object to refer to itself. That is what self is — it is the object’s own reference to itself.
Think of it like this: if you are in a room full of people and someone says “raise your own hand,” every person raises their own hand. self is how each object knows which “hand” is theirs.
class Dog:
def __init__(self, name, age):
self.name = name # This dog's name
self.age = age # This dog's age
dog1 = Dog("Rex", 3)
dog2 = Dog("Buddy", 7)
print(dog1.name) # Output: Rex
print(dog2.name) # Output: Buddy
dog1.name and dog2.name are completely separate values — each dog object has its own copy.
Two Types of Properties
There are two places you can define properties on a class:
1. Instance Properties (unique to each object)
These are created inside __init__ using self. Every object gets its own copy.
class Student:
def __init__(self, name, grade):
self.name = name # Each student has their own name
self.grade = grade # Each student has their own grade
student1 = Student("Alice", "A")
student2 = Student("Bob", "B")
print(student1.name) # Output: Alice
print(student2.name) # Output: Bob
Why this matters:
student1.nameandstudent2.nameare completely independent. Changing one does not affect the other.
2. Class Properties (shared by ALL objects)
These are created directly inside the class body, outside any method. Every object of that class shares the same value.
class Student:
school_name = "Sunrise Academy" # Shared by all students
def __init__(self, name):
self.name = name
student1 = Student("Alice")
student2 = Student("Bob")
print(student1.school_name) # Output: Sunrise Academy
print(student2.school_name) # Output: Sunrise Academy
print(Student.school_name) # Output: Sunrise Academy
Real-world analogy: Think of instance properties as each student’s own locker. Think of class properties as the school name printed on the building — it belongs to the school, not any one student.
Creating, Reading, and Changing Properties
Once an object exists, you can interact with its properties at any time.
Reading a property:
class Person:
def __init__(self, name, age):
self.name = name
self.age = age
p = Person("Maria", 28)
print(p.name) # Output: Maria
print(p.age) # Output: 28
Changing a property:
p.age = 29
print(p.age) # Output: 29
Adding a new property after creation:
p.city = "London"
print(p.city) # Output: London
Deleting a property:
del p.city
# print(p.city) # This would now cause an AttributeError
The __str__ Property: Making Objects Readable
By default, printing an object gives an ugly technical output like <__main__.Dog object at 0x7f...>. You can fix this with __str__.
class Dog:
def __init__(self, name, age):
self.name = name
self.age = age
def __str__(self):
return f"{self.name} is {self.age} years old"
my_dog = Dog("Rex", 3)
print(my_dog) # Output: Rex is 3 years old
Tip: Always add
__str__to your classes. It makes debugging and displaying objects much easier.
Worked Example: A Bank Account Class
class BankAccount:
bank_name = "PythonBank" # Class property — shared by all accounts
def __init__(self, owner, balance):
self.owner = owner # Instance property
self.balance = balance # Instance property
def __str__(self):
return f"{self.owner}'s account at {BankAccount.bank_name} — Balance: £{self.balance}"
account1 = BankAccount("Alice", 1500)
account2 = BankAccount("Bob", 3200)
print(account1)
# Output: Alice's account at PythonBank — Balance: £1500
print(account2)
# Output: Bob's account at PythonBank — Balance: £3200
account1.balance = 1800
print(account1.balance)
# Output: 1800
💡 Thinking Prompt
What would happen if you changed BankAccount.bank_name = "NewBank" after creating the accounts? Try it — what do you observe?
Common Beginner Mistake 1: Forgetting self when setting properties
# WRONG
class Car:
def __init__(self, brand):
brand = brand # This creates a local variable, NOT a property!
car = Car("Toyota")
# print(car.brand) # AttributeError: 'Car' object has no attribute 'brand'
# CORRECT
class Car:
def __init__(self, brand):
self.brand = brand # self.brand attaches it to the object
car = Car("Toyota")
print(car.brand) # Output: Toyota
Part 2: Class Methods
What Are Class Methods?
If properties are the data that describe an object, then methods are the actions that an object can perform.
A method is simply a function defined inside a class.
Real-world analogy:
| Object | Properties | Methods |
|---|---|---|
| A bank account | balance, owner | deposit, withdraw, check_balance |
| A dog | name, age | bark, sit, fetch |
| A car | brand, speed | accelerate, brake, honk |
Defining and Calling a Method
class Dog:
def __init__(self, name):
self.name = name
def bark(self): # A method
print(f"{self.name} says: Woof!")
my_dog = Dog("Rex")
my_dog.bark() # Output: Rex says: Woof!
Every method receives self as its first parameter. This gives the method access to the object’s own data.
Methods That Work With Properties
Methods become powerful when they read or modify the object’s properties.
class BankAccount:
def __init__(self, owner, balance):
self.owner = owner
self.balance = balance
def deposit(self, amount):
self.balance += amount
print(f"Deposited £{amount}. New balance: £{self.balance}")
def withdraw(self, amount):
if amount > self.balance:
print("Insufficient funds!")
else:
self.balance -= amount
print(f"Withdrew £{amount}. New balance: £{self.balance}")
def show_balance(self):
print(f"{self.owner}'s balance: £{self.balance}")
account = BankAccount("Alice", 1000)
account.show_balance() # Output: Alice's balance: £1000
account.deposit(500) # Output: Deposited £500. New balance: £1500
account.withdraw(200) # Output: Withdrew £200. New balance: £1300
account.withdraw(2000) # Output: Insufficient funds!
Methods That Return Values
Methods can also calculate and return something, just like regular functions.
class Rectangle:
def __init__(self, width, height):
self.width = width
self.height = height
def area(self):
return self.width * self.height
def perimeter(self):
return 2 * (self.width + self.height)
def is_square(self):
return self.width == self.height
rect = Rectangle(5, 3)
print(rect.area()) # Output: 15
print(rect.perimeter()) # Output: 16
print(rect.is_square()) # Output: False
square = Rectangle(4, 4)
print(square.is_square()) # Output: True
The __str__ Method (Revisited as a Method)
We introduced __str__ earlier as a property tool, but it is actually a special method. Python calls it automatically whenever you use print() on an object.
class Product:
def __init__(self, name, price):
self.name = name
self.price = price
def __str__(self):
return f"Product: {self.name} | Price: £{self.price:.2f}"
p = Product("Laptop", 799.99)
print(p) # Output: Product: Laptop | Price: £799.99
Modifying Properties Through Methods
Using methods to change properties (rather than changing them directly from outside) is a core OOP best practice. It lets you add rules and validation.
class Temperature:
def __init__(self, celsius):
self.celsius = celsius
def set_temperature(self, new_temp):
if new_temp < -273.15:
print("Temperature below absolute zero is not possible!")
else:
self.celsius = new_temp
print(f"Temperature set to {self.celsius}°C")
def to_fahrenheit(self):
return (self.celsius * 9/5) + 32
t = Temperature(25)
print(t.to_fahrenheit()) # Output: 77.0
t.set_temperature(-300) # Output: Temperature below absolute zero is not possible!
t.set_temperature(100) # Output: Temperature set to 100°C
print(t.to_fahrenheit()) # Output: 212.0
Real-world use: Databases use this idea constantly. Instead of changing values directly, you call a method that validates the data first.
Common Beginner Mistake 2: Forgetting self in the method signature
# WRONG
class Dog:
def __init__(self, name):
self.name = name
def bark(): # Missing self!
print("Woof!")
d = Dog("Rex")
# d.bark() # TypeError: bark() takes 0 positional arguments but 1 was given
# CORRECT
class Dog:
def __init__(self, name):
self.name = name
def bark(self): # Always include self
print(f"{self.name} says: Woof!")
d = Dog("Rex")
d.bark() # Output: Rex says: Woof!
Common Beginner Mistake 3: Calling self.method() without self inside the class
# WRONG
class Circle:
def __init__(self, radius):
self.radius = radius
def area(self):
return 3.14159 * self.radius ** 2
def describe(self):
print(f"Area is: {area()}") # Missing self.area()
# CORRECT
class Circle:
def __init__(self, radius):
self.radius = radius
def area(self):
return 3.14159 * self.radius ** 2
def describe(self):
print(f"Area is: {self.area()}") # self.area() is correct
c = Circle(5)
c.describe() # Output: Area is: 78.53975
💡 Thinking Prompt
What would happen if withdraw() allowed you to make the balance go negative? Could you modify the method to prevent that? Try writing the fix.
Part 3: Inheritance
What Is Inheritance?
Inheritance is the ability of one class to receive all the properties and methods of another class automatically.
This is one of the most powerful ideas in all of programming.
Real-world analogy:
Think about animals. All animals share certain characteristics — they breathe, they move, they eat. But a Dog also barks, a Cat meows, and a Bird flies.
In OOP:
Animalis the parent class (also called the base class or superclass)Dog,Cat,Birdare child classes (also called subclasses or derived classes)
The child classes inherit everything from the parent, and then add their own unique stuff on top.
Why Does Inheritance Exist?
Without inheritance, you would have to copy-paste the same code into every class. That is messy, hard to maintain, and a source of bugs.
With inheritance, you write shared behaviour once in the parent, and all children get it for free.
Developer principle: DRY — Don’t Repeat Yourself. Inheritance is one of the main tools for following this rule.
Creating a Child Class
The syntax is simple — put the parent class name inside parentheses:
class Animal:
def __init__(self, name, sound):
self.name = name
self.sound = sound
def speak(self):
print(f"{self.name} says {self.sound}")
class Dog(Animal): # Dog inherits from Animal
pass
class Cat(Animal): # Cat inherits from Animal
pass
dog = Dog("Rex", "Woof")
cat = Cat("Whiskers", "Meow")
dog.speak() # Output: Rex says Woof
cat.speak() # Output: Whiskers says Meow
Even though Dog and Cat are completely empty (pass), they already have the speak() method — because they inherited it from Animal.
Adding New Properties and Methods to a Child Class
Child classes can add their own unique properties and methods.
class Animal:
def __init__(self, name):
self.name = name
def breathe(self):
print(f"{self.name} breathes air")
class Dog(Animal):
def __init__(self, name, breed):
self.name = name # Must set name manually (we'll fix this with super() shortly)
self.breed = breed # New property unique to Dog
def fetch(self): # New method unique to Dog
print(f"{self.name} the {self.breed} fetches the ball!")
my_dog = Dog("Rex", "Labrador")
my_dog.breathe() # Output: Rex breathes air (inherited from Animal)
my_dog.fetch() # Output: Rex the Labrador fetches the ball! (Dog's own method)
The super() Function
Notice above we had to manually set self.name = name in the child class even though the parent already does this. That is repetitive.
super() solves this. It calls the parent class’s __init__ so you don’t duplicate the setup code.
class Animal:
def __init__(self, name, age):
self.name = name
self.age = age
print(f"Animal created: {self.name}")
class Dog(Animal):
def __init__(self, name, age, breed):
super().__init__(name, age) # Run Animal's __init__ first
self.breed = breed # Then add Dog-specific property
print(f"Dog created: {self.breed}")
my_dog = Dog("Rex", 3, "Labrador")
# Output:
# Animal created: Rex
# Dog created: Labrador
print(my_dog.name) # Output: Rex (set by Animal's __init__)
print(my_dog.age) # Output: 3 (set by Animal's __init__)
print(my_dog.breed) # Output: Labrador (set by Dog's __init__)
Rule of thumb: Always call
super().__init__(...)at the start of a child’s__init__. It keeps the parent properly set up.
Method Overriding
A child class can redefine a method it inherited from the parent. This is called method overriding.
The child’s version replaces the parent’s version for that specific class.
class Animal:
def speak(self):
print("Some generic animal sound")
class Dog(Animal):
def speak(self): # Overrides Animal's speak
print("Woof! Woof!")
class Cat(Animal):
def speak(self): # Overrides Animal's speak
print("Meow!")
class Fish(Animal):
pass # No override — uses Animal's speak
generic_animal = Animal()
generic_animal.speak() # Output: Some generic animal sound
dog = Dog()
dog.speak() # Output: Woof! Woof!
cat = Cat()
cat.speak() # Output: Meow!
fish = Fish()
fish.speak() # Output: Some generic animal sound (no override)
Calling the Parent’s Method in an Override
Sometimes you want to override a method but also include the parent’s original behaviour. Use super() again.
class Vehicle:
def __init__(self, brand):
self.brand = brand
def describe(self):
print(f"Brand: {self.brand}")
class ElectricCar(Vehicle):
def __init__(self, brand, battery_capacity):
super().__init__(brand)
self.battery_capacity = battery_capacity
def describe(self):
super().describe() # Call parent's describe first
print(f"Battery: {self.battery_capacity} kWh") # Then add extra info
ev = ElectricCar("Tesla", 100)
ev.describe()
# Output:
# Brand: Tesla
# Battery: 100 kWh
Checking Inheritance with isinstance() and issubclass()
Python gives you tools to check relationships between classes and objects.
class Animal:
pass
class Dog(Animal):
pass
my_dog = Dog()
print(isinstance(my_dog, Dog)) # Output: True (my_dog IS a Dog)
print(isinstance(my_dog, Animal)) # Output: True (my_dog IS ALSO an Animal!)
print(issubclass(Dog, Animal)) # Output: True (Dog IS a subclass of Animal)
print(issubclass(Animal, Dog)) # Output: False (Animal is NOT a subclass of Dog)
Key insight: An instance of
Dogis also an instance ofAnimal. This is becauseDoginherits fromAnimal, so every Dog is a type of Animal.
Multi-Level Inheritance
Classes can inherit from classes that themselves inherit from other classes. This creates a chain.
class LivingThing:
def breathe(self):
print("Breathing...")
class Animal(LivingThing):
def move(self):
print("Moving...")
class Dog(Animal):
def bark(self):
print("Woof!")
rex = Dog()
rex.breathe() # Output: Breathing... (from LivingThing)
rex.move() # Output: Moving... (from Animal)
rex.bark() # Output: Woof! (from Dog)
Analogy: Think of a family tree. A grandchild inherits traits from both parent and grandparent.
A Realistic Inheritance Example: Employees
class Employee:
def __init__(self, name, salary):
self.name = name
self.salary = salary
def display_info(self):
print(f"Name: {self.name} | Salary: £{self.salary}/yr")
def give_raise(self, amount):
self.salary += amount
print(f"{self.name} received a raise. New salary: £{self.salary}/yr")
class Manager(Employee):
def __init__(self, name, salary, department):
super().__init__(name, salary)
self.department = department
def display_info(self):
super().display_info()
print(f"Department: {self.department}")
def hold_meeting(self):
print(f"{self.name} is holding a meeting for the {self.department} team.")
class Developer(Employee):
def __init__(self, name, salary, language):
super().__init__(name, salary)
self.language = language
def write_code(self):
print(f"{self.name} is writing {self.language} code.")
emp = Employee("Carol", 30000)
mgr = Manager("Dave", 60000, "Engineering")
dev = Developer("Eve", 55000, "Python")
emp.display_info()
# Output:
# Name: Carol | Salary: £30000/yr
mgr.display_info()
# Output:
# Name: Dave | Salary: £60000/yr
# Department: Engineering
dev.write_code() # Output: Eve is writing Python code.
mgr.hold_meeting() # Output: Dave is holding a meeting for the Engineering team.
dev.give_raise(5000) # Output: Eve received a raise. New salary: £60000/yr
Common Beginner Mistake 4: Forgetting super().__init__() in the child
# WRONG
class Animal:
def __init__(self, name):
self.name = name
class Dog(Animal):
def __init__(self, name, breed):
# Forgot to call super().__init__(name)
self.breed = breed
d = Dog("Rex", "Labrador")
# print(d.name) # AttributeError: 'Dog' object has no attribute 'name'
# CORRECT
class Dog(Animal):
def __init__(self, name, breed):
super().__init__(name) # Always call super first
self.breed = breed
d = Dog("Rex", "Labrador")
print(d.name) # Output: Rex
print(d.breed) # Output: Labrador
💡 Thinking Prompt
If Cat and Dog both inherit from Animal, and Animal has a feed() method, what happens when you call my_cat.feed()? What about if Cat overrides feed()?
Part 4: Polymorphism
What Is Polymorphism?
The word polymorphism comes from Greek and means “many forms.”
In Python, polymorphism means that different classes can respond to the same method name in their own unique ways.
Real-world analogy:
Think about the instruction “make a sound.” You could give that instruction to:
- A dog → it barks
- A cat → it meows
- A car horn → it honks
- A phone → it rings
Every one of them responds to “make a sound” differently. That is polymorphism.
In Python, you write the same method name across different classes, and when you call it, each class does its own version.
Why Does Polymorphism Matter?
Without polymorphism, you would need to write separate code for every type of object:
# Without polymorphism — messy and not scalable
if type(animal) == Dog:
animal.bark()
elif type(animal) == Cat:
animal.meow()
elif type(animal) == Bird:
animal.chirp()
With polymorphism, you write it once:
# With polymorphism — clean and scalable
animal.speak() # Each animal handles it in its own way
Polymorphism Through Method Overriding (Inherited Classes)
This is the most common form. Each child class overrides a parent method differently.
class Animal:
def speak(self):
return "..."
class Dog(Animal):
def speak(self):
return "Woof!"
class Cat(Animal):
def speak(self):
return "Meow!"
class Duck(Animal):
def speak(self):
return "Quack!"
animals = [Dog(), Cat(), Duck(), Dog()] # A list of different animals
for animal in animals:
print(animal.speak())
# Output:
# Woof!
# Meow!
# Quack!
# Woof!
Notice: the for loop code is identical for all animals. Python automatically calls the correct speak() for each object. That is polymorphism in action.
Polymorphism With Functions That Accept Different Objects
You can write a single function that works with many different types of objects, as long as they share a method name.
class Dog:
def speak(self):
return "Woof!"
class Cat:
def speak(self):
return "Meow!"
class Person:
def speak(self):
return "Hello!"
def make_it_speak(thing): # Works with ANY object that has speak()
print(thing.speak())
make_it_speak(Dog()) # Output: Woof!
make_it_speak(Cat()) # Output: Meow!
make_it_speak(Person()) # Output: Hello!
Notice that Dog, Cat, and Person are not even related by inheritance here. They just all happen to have a method called speak(). Python does not care about the relationship — only that the method exists. This is called duck typing.
Duck typing principle: “If it walks like a duck and quacks like a duck, then it must be a duck.” Python simply tries to call the method — if it exists, great; if not, an error is raised.
Polymorphism With a Loop — Practical Example
class Shape:
def area(self):
return 0
class Circle(Shape):
def __init__(self, radius):
self.radius = radius
def area(self):
return 3.14159 * self.radius ** 2
class Rectangle(Shape):
def __init__(self, width, height):
self.width = width
self.height = height
def area(self):
return self.width * self.height
class Triangle(Shape):
def __init__(self, base, height):
self.base = base
self.height = height
def area(self):
return 0.5 * self.base * self.height
shapes = [
Circle(5),
Rectangle(4, 6),
Triangle(3, 8),
Circle(2)
]
print("Areas of all shapes:")
for shape in shapes:
print(f" {type(shape).__name__}: {shape.area():.2f}")
# Output:
# Areas of all shapes:
# Circle: 78.54
# Rectangle: 24.00
# Triangle: 12.00
# Circle: 12.57
With polymorphism, adding a new shape (e.g. Pentagon) requires zero changes to the loop — you just create the new class with its own area() method.
Polymorphism With Built-In Python Features
Python’s built-in functions also use polymorphism. The len() function works on strings, lists, and dictionaries because they all have their own internal __len__ method.
print(len("hello")) # Output: 5 (string's __len__)
print(len([1, 2, 3, 4])) # Output: 4 (list's __len__)
print(len({"a": 1, "b": 2})) # Output: 2 (dict's __len__)
You can add this to your own classes too:
class Bookshelf:
def __init__(self, books):
self.books = books
def __len__(self):
return len(self.books)
shelf = Bookshelf(["Python Crash Course", "Clean Code", "The Pragmatic Programmer"])
print(len(shelf)) # Output: 3
Polymorphism: A Realistic Payment System Example
class Payment:
def process(self, amount):
raise NotImplementedError("Subclass must implement process()")
class CreditCard(Payment):
def __init__(self, card_number):
self.card_number = card_number
def process(self, amount):
print(f"Processing £{amount:.2f} via Credit Card ending in {self.card_number[-4:]}")
class PayPal(Payment):
def __init__(self, email):
self.email = email
def process(self, amount):
print(f"Processing £{amount:.2f} via PayPal account: {self.email}")
class BankTransfer(Payment):
def __init__(self, account_number):
self.account_number = account_number
def process(self, amount):
print(f"Processing £{amount:.2f} via Bank Transfer to account: {self.account_number}")
def checkout(payment_method, amount):
payment_method.process(amount) # Polymorphic call
card = CreditCard("4111111111111111")
paypal = PayPal("alice@example.com")
bank = BankTransfer("GB12BARC20201530093459")
checkout(card, 49.99)
# Output: Processing £49.99 via Credit Card ending in 1111
checkout(paypal, 129.00)
# Output: Processing £129.00 via PayPal account: alice@example.com
checkout(bank, 500.00)
# Output: Processing £500.00 via Bank Transfer to account: GB12BARC20201530093459
Professional use: This is exactly how payment gateways work in real e-commerce systems like Django or Flask web apps. The
checkout()function never needs to know which payment method is being used — it just calls.process().
Common Beginner Mistake 5: Thinking polymorphism requires inheritance
Polymorphism does NOT require inheritance in Python. Any two classes with the same method name work.
class Printer:
def output(self):
print("Printing to paper...")
class Screen:
def output(self):
print("Displaying on screen...")
class Speaker:
def output(self):
print("Playing through speakers...")
devices = [Printer(), Screen(), Speaker()]
for device in devices:
device.output()
# Output:
# Printing to paper...
# Displaying on screen...
# Playing through speakers...
💡 Thinking Prompt
If you add a new class Projector with its own output() method and add it to the devices list, what changes do you need to make to the loop? (Hint: nothing at all!)
Guided Practice Exercises
Exercise 1: Student Report System (Properties & Methods)
Objective: Build a Student class that stores data and provides useful behaviours.
Scenario: You are building a simple student management system for a school.
Steps:
- Create a
Studentclass with properties:name,age,grades(a list of numbers) - Add a method
add_grade(grade)that appends a grade to the list - Add a method
average_grade()that returns the average of all grades - Add a method
highest_grade()that returns the highest grade - Add a method
report()that prints a formatted summary - Add
__str__to give a simple one-line description
Expected Output:
Student: Aisha | Age: 16
Grades: [78, 92, 85, 90, 88]
Average: 86.6
Highest: 92
Starter Code:
class Student:
school = "Riverside High" # Class property
def __init__(self, name, age):
self.name = name
self.age = age
self.grades = []
def add_grade(self, grade):
self.grades.append(grade)
def average_grade(self):
if len(self.grades) == 0:
return 0
return sum(self.grades) / len(self.grades)
def highest_grade(self):
if len(self.grades) == 0:
return None
return max(self.grades)
def report(self):
print(f"Student: {self.name} | Age: {self.age}")
print(f"Grades: {self.grades}")
print(f"Average: {self.average_grade():.1f}")
print(f"Highest: {self.highest_grade()}")
def __str__(self):
return f"{self.name} (Age {self.age}) at {Student.school}"
student = Student("Aisha", 16)
student.add_grade(78)
student.add_grade(92)
student.add_grade(85)
student.add_grade(90)
student.add_grade(88)
student.report()
print(student)
Self-check Questions:
- What happens if
gradesis empty and you callaverage_grade()? - How would you add a method
pass_or_fail()that returns"Pass"if the average is 50+ and"Fail"otherwise?
What-if Challenge: Add a lowest_grade() method and a method grade_range() that returns the difference between the highest and lowest grade.
Exercise 2: Animal Kingdom (Inheritance)
Objective: Build a small class hierarchy using inheritance.
Scenario: A zoo management system needs to track different animals.
Steps:
- Create a base class
Animalwithname,age, anddietproperties - Add methods
eat(),sleep(), anddescribe() - Create
Lion(Animal)— add aroar()method and overridedescribe() - Create
Eagle(Animal)— add afly()method and overridedescribe() - Create
Dolphin(Animal)— add aswim()method and overridedescribe()
Expected Output:
Lion: Simba | Age: 4 | Diet: Carnivore | Habitat: Savanna
Simba roars: ROAAAAR!
Eagle: Sky | Age: 2 | Diet: Carnivore | Habitat: Mountains
Sky soars through the sky!
Simba is eating...
Sky is sleeping...
Solution:
class Animal:
def __init__(self, name, age, diet):
self.name = name
self.age = age
self.diet = diet
def eat(self):
print(f"{self.name} is eating...")
def sleep(self):
print(f"{self.name} is sleeping...")
def describe(self):
print(f"Animal: {self.name} | Age: {self.age} | Diet: {self.diet}")
class Lion(Animal):
def __init__(self, name, age):
super().__init__(name, age, "Carnivore")
self.habitat = "Savanna"
def roar(self):
print(f"{self.name} roars: ROAAAAR!")
def describe(self):
print(f"Lion: {self.name} | Age: {self.age} | Diet: {self.diet} | Habitat: {self.habitat}")
class Eagle(Animal):
def __init__(self, name, age):
super().__init__(name, age, "Carnivore")
self.habitat = "Mountains"
def fly(self):
print(f"{self.name} soars through the sky!")
def describe(self):
print(f"Eagle: {self.name} | Age: {self.age} | Diet: {self.diet} | Habitat: {self.habitat}")
class Dolphin(Animal):
def __init__(self, name, age):
super().__init__(name, age, "Carnivore")
self.habitat = "Ocean"
def swim(self):
print(f"{self.name} leaps through the waves!")
def describe(self):
print(f"Dolphin: {self.name} | Age: {self.age} | Diet: {self.diet} | Habitat: {self.habitat}")
lion = Lion("Simba", 4)
eagle = Eagle("Sky", 2)
dolphin = Dolphin("Splash", 6)
lion.describe()
lion.roar()
eagle.describe()
eagle.fly()
lion.eat()
eagle.sleep()
Exercise 3: Shape Calculator (Polymorphism)
Objective: Use polymorphism to calculate areas and perimeters of different shapes through a single interface.
Steps:
- Create a base
Shapeclass witharea()andperimeter()methods that return 0 - Create
Circle,Rectangle,Triangle, andSquareclasses - Each overrides
area()andperimeter()with the correct formula - Write a function
print_shape_info(shape)that prints the type, area, and perimeter - Test with a list of different shapes
Expected Output:
Shape: Circle
Area: 78.54
Perimeter: 31.42
Shape: Rectangle
Area: 24.00
Perimeter: 20.00
Shape: Triangle
Area: 12.00
Perimeter: 12.00
Solution:
import math
class Shape:
def area(self):
return 0
def perimeter(self):
return 0
class Circle(Shape):
def __init__(self, radius):
self.radius = radius
def area(self):
return math.pi * self.radius ** 2
def perimeter(self):
return 2 * math.pi * self.radius
class Rectangle(Shape):
def __init__(self, width, height):
self.width = width
self.height = height
def area(self):
return self.width * self.height
def perimeter(self):
return 2 * (self.width + self.height)
class Triangle(Shape):
def __init__(self, a, b, c): # Three sides
self.a = a
self.b = b
self.c = c
def area(self):
# Heron's formula
s = (self.a + self.b + self.c) / 2
return math.sqrt(s * (s - self.a) * (s - self.b) * (s - self.c))
def perimeter(self):
return self.a + self.b + self.c
class Square(Rectangle):
def __init__(self, side):
super().__init__(side, side) # A square is a rectangle where width == height
def print_shape_info(shape):
print(f"Shape: {type(shape).__name__}")
print(f" Area: {shape.area():.2f}")
print(f" Perimeter: {shape.perimeter():.2f}")
print()
shapes = [Circle(5), Rectangle(4, 6), Triangle(3, 4, 5), Square(7)]
for shape in shapes:
print_shape_info(shape)
Mini Project: Library Management System
Let’s combine everything you have learned — properties, methods, inheritance, and polymorphism — into one realistic mini-project.
Project Overview
You will build a simplified library system that manages different types of items: books, magazines, and DVDs. Each item type has unique properties and behaviours, but they all share a common base.
Stage 1: The Base Class
class LibraryItem:
library_name = "Central City Library"
def __init__(self, title, item_id, available=True):
self.title = title
self.item_id = item_id
self.available = available
def checkout(self):
if self.available:
self.available = False
print(f'"{self.title}" has been checked out.')
else:
print(f'"{self.title}" is already checked out.')
def return_item(self):
self.available = True
print(f'"{self.title}" has been returned.')
def status(self):
state = "Available" if self.available else "Checked Out"
print(f"[{self.item_id}] {self.title} — {state}")
def __str__(self):
return f"{type(self).__name__}: {self.title} (ID: {self.item_id})"
Milestone Output (Stage 1):
item = LibraryItem("Mystery Item", "LIB001")
item.status() # Output: [LIB001] Mystery Item — Available
item.checkout() # Output: "Mystery Item" has been checked out.
item.status() # Output: [LIB001] Mystery Item — Checked Out
item.return_item() # Output: "Mystery Item" has been returned.
Stage 2: Child Classes
class Book(LibraryItem):
def __init__(self, title, item_id, author, pages):
super().__init__(title, item_id)
self.author = author
self.pages = pages
def status(self):
super().status()
print(f" Author: {self.author} | Pages: {self.pages}")
class Magazine(LibraryItem):
def __init__(self, title, item_id, issue_number, month):
super().__init__(title, item_id)
self.issue_number = issue_number
self.month = month
def status(self):
super().status()
print(f" Issue #{self.issue_number} | {self.month}")
class DVD(LibraryItem):
def __init__(self, title, item_id, director, duration_mins):
super().__init__(title, item_id)
self.director = director
self.duration_mins = duration_mins
def status(self):
super().status()
print(f" Director: {self.director} | Duration: {self.duration_mins} mins")
def play(self):
if self.available:
print(f'Now playing: "{self.title}" directed by {self.director}')
else:
print(f'"{self.title}" is checked out and cannot be played.')
Milestone Output (Stage 2):
book = Book("The Pragmatic Programmer", "B001", "Andrew Hunt", 352)
mag = Magazine("Tech Weekly", "M001", 47, "November 2024")
dvd = DVD("Inception", "D001", "Christopher Nolan", 148)
book.status()
# Output:
# [B001] The Pragmatic Programmer — Available
# Author: Andrew Hunt | Pages: 352
dvd.play()
# Output: Now playing: "Inception" directed by Christopher Nolan
Stage 3: The Library Class (Bringing It All Together)
class Library:
def __init__(self):
self.items = []
def add_item(self, item):
self.items.append(item)
print(f'Added: {item}')
def show_all(self):
print(f"\n=== {LibraryItem.library_name} — Catalogue ===")
for item in self.items:
item.status() # Polymorphic call — each item does its own version
print()
def find_by_id(self, item_id):
for item in self.items:
if item.item_id == item_id:
return item
print(f"Item ID {item_id} not found.")
return None
def available_items(self):
print("\n=== Available Items ===")
available = [item for item in self.items if item.available]
if not available:
print("No items currently available.")
for item in available:
print(f" - {item}")
Stage 4: Final Full Demo
library = Library()
library.add_item(Book("Python Crash Course", "B001", "Eric Matthes", 544))
library.add_item(Book("Clean Code", "B002", "Robert C. Martin", 464))
library.add_item(Magazine("Nature", "M001", 612, "December 2024"))
library.add_item(DVD("Interstellar", "D001", "Christopher Nolan", 169))
library.add_item(DVD("The Martian", "D002", "Ridley Scott", 141))
library.show_all()
found = library.find_by_id("B001")
if found:
found.checkout()
found2 = library.find_by_id("D001")
if found2:
found2.checkout()
found2.play()
library.available_items()
Final Expected Output:
Added: Book: Python Crash Course (ID: B001)
Added: Book: Clean Code (ID: B002)
Added: Magazine: Nature (ID: M001)
Added: DVD: Interstellar (ID: D001)
Added: DVD: The Martian (ID: D002)
=== Central City Library — Catalogue ===
[B001] Python Crash Course — Available
Author: Eric Matthes | Pages: 544
[B002] Clean Code — Available
Author: Robert C. Martin | Pages: 464
[M001] Nature — Available
Issue #612 | December 2024
[D001] Interstellar — Available
Director: Christopher Nolan | Duration: 169 mins
[D002] The Martian — Available
Director: Ridley Scott | Duration: 141 mins
"Python Crash Course" has been checked out.
"Interstellar" has been checked out.
"Interstellar" is checked out and cannot be played.
=== Available Items ===
- Book: Clean Code (ID: B002)
- Magazine: Nature (ID: M001)
- DVD: The Martian (ID: D002)
Reflection Questions
- Why does
show_all()work correctly for Books, Magazines, and DVDs even though it only callsitem.status()? - What would you need to add to support an
AudioBookclass? - Why is it better to have
checkout()defined inLibraryItemrather than in each child class? - What would break if you removed the
super().__init__()call fromBook.__init__? - How does
isinstance(book, LibraryItem)behave? Why?
Optional Extension Challenges:
- Add a due-date system to
checkout(days=14)that stores when an item is due back - Add a
late_fee(days_overdue)method - Add a search function that finds items by author name
- Track how many times each item has been checked out with a
checkout_countproperty
Common Beginner Mistakes — Complete Summary
| Mistake | Problem | Fix |
|---|---|---|
Missing self in __init__ assignment |
name = name creates a local variable, not a property |
Use self.name = name |
Missing self in method definition |
def bark(): instead of def bark(self): |
Always include self as the first parameter |
Calling internal method without self |
area() instead of self.area() |
Always use self.method() inside the class |
Forgetting super().__init__() in child |
Parent’s properties never get created | Always call super().__init__(...) first |
| Modifying class property on an instance | dog.species = "Cat" creates an instance property shadow |
Modify class properties via ClassName.property = value |
| Thinking polymorphism requires inheritance | Limiting design unnecessarily | Any two classes with the same method name work polymorphically |
Reflection Questions
-
What is the difference between a class property and an instance property? Give an example of when you would use each.
-
Why does every method inside a class need
selfas its first parameter? -
What does
super()do, and when is it essential to use it? -
Explain method overriding in your own words. Give a real-world scenario where it would be useful.
-
What is polymorphism? Why does it make code more flexible and easier to extend?
-
In what way does a
forloop that callsanimal.speak()on many different animal objects demonstrate polymorphism? -
What is the difference between overriding a method and extending a method (calling
super()then adding extra behaviour)? -
Think of a real-world system (e.g. a school, hospital, restaurant, or game). Identify at least one parent class and two child classes you would create, and list what properties and methods each would have.
Completion Checklist
Before moving to the next lesson, confirm you can do all of the following:
- Create a class with both instance properties and class properties
- Write methods that read and modify object properties
- Use
__str__to make objects print nicely - Create a child class that inherits from a parent class
- Use
super().__init__()correctly in a child class - Override a parent method in a child class
- Call a parent’s method from inside an overridden version using
super() - Explain what
isinstance()andissubclass()return - Write at least two classes with the same method name and demonstrate polymorphism
- Write a loop that calls the same method on different types of objects
- Build the Library mini-project (or a similar multi-class system) from scratch
- Identify and fix all six common beginner mistakes from this lesson
Lesson Summary
In this lesson you covered four of the most important pillars of Object-Oriented Programming in Python:
Class Properties store data inside objects. Instance properties (set with self in __init__) are unique to each object. Class properties are shared across all objects of that class.
Class Methods give objects behaviour. Every method takes self as its first parameter. Methods can read, modify, and return property values. The __str__ method controls how an object looks when printed.
Inheritance allows child classes to automatically receive all the properties and methods of a parent class. super() lets child classes call the parent’s __init__ and other methods without duplicating code. Method overriding lets child classes replace inherited behaviour with their own version.
Polymorphism allows different classes to respond to the same method call in their own unique ways. This means you can write one loop or one function that works with many different types of objects — and adding new types later requires zero changes to the existing code.
These four ideas together allow you to write programs that are:
- Organised — related data and behaviour live together in classes
- Reusable — inheritance eliminates repetition
- Extensible — polymorphism means adding new types is safe and easy
- Maintainable — changing shared behaviour in one place (the parent) updates all children
You are now writing code the way professional software engineers write it every day.
Next Lesson: Python Iterators and Generators — understanding how Python loops really work under the hood.