Object-Oriented Programming — Classes, Inheritance & Polymorphism
Object-Oriented Programming (OOP) is the dominant way large programs are organized. Instead of a collection of loose functions and variables, you group related data and behavior together into objects. This week you'll learn to think in objects.
.py file and run it. OOP
concepts build on each other — read the sections in order.
A class is a blueprint that describes what data an object holds and what it can do. An object (or instance) is a specific thing created from that blueprint.
class Dog:
pass # empty class — valid Python, does nothing yet
fido = Dog() # create an instance
buddy = Dog() # a different instance
print(type(fido)) #
print(fido is buddy) # False — they are two separate objects
__init__ is a special method that runs automatically when an object is created. It's where
you set up the object's initial data. self refers to the specific instance being created.
class Dog:
def __init__(self, name, breed, age):
self.name = name # instance attribute — unique to this object
self.breed = breed
self.age = age
def describe(self):
print(f"{self.name} is a {self.age}-year-old {self.breed}.")
def bark(self):
print(f"{self.name} says: Woof!")
# Create two independent Dog objects
fido = Dog("Fido", "Labrador", 3)
buddy = Dog("Buddy", "Poodle", 7)
fido.describe() # Fido is a 3-year-old Labrador.
buddy.describe() # Buddy is a 7-year-old Poodle.
fido.bark() # Fido says: Woof!
# Attributes are unique per instance
print(fido.name) # Fido
print(buddy.name) # Buddy
fido.age = 4 # only changes fido, not buddy
print(buddy.age) # still 7
self as its first
parameter. Python passes the instance automatically — you never pass it yourself when calling the
method. Forgetting self is a very common beginner mistake.
Encapsulation means keeping an object's internal data hidden and only accessible
through its methods. In Python, prefix attribute names with an underscore (_balance) to
signal "don't access this directly."
deposit() method can modify it.
class Thermostat:
def __init__(self, initial_temp):
self._temp = initial_temp # underscore = private by convention
def get_temp(self):
return self._temp
def set_temp(self, new_temp):
if new_temp < 50 or new_temp > 90:
raise ValueError(f"Temperature {new_temp} is out of safe range (50–90°F)")
self._temp = new_temp
print(f"Temperature set to {self._temp}°F")
def __str__(self):
return f"Thermostat({self._temp}°F)"
t = Thermostat(72)
t.set_temp(68) # Temperature set to 68°F
print(t.get_temp()) # 68
try:
t.set_temp(200) # raises ValueError
except ValueError as e:
print(f"Error: {e}") # Error: Temperature 200 is out of safe range
print(t) # Thermostat(68°F) — __str__ controls how it prints
Inheritance lets a new class (child) automatically get all the attributes and methods of an existing class (parent), then add or change what it needs. This eliminates rewriting shared behavior.
ElectricCar is a Vehicle. A SavingsAccount is an
Account. The child gets everything the parent has, plus its own extras.
class Vehicle:
def __init__(self, make, model, year):
self._make = make
self._model = model
self._year = year
def describe(self):
return f"{self._year} {self._make} {self._model}"
def start(self):
print(f"{self.describe()} is starting...")
class ElectricCar(Vehicle): # ElectricCar inherits from Vehicle
def __init__(self, make, model, year, range_miles):
super().__init__(make, model, year) # run Vehicle's __init__ first
self._range = range_miles # add the extra attribute
def charge_status(self):
print(f"Range: {self._range} miles per charge")
# ElectricCar has BOTH Vehicle's methods AND its own
tesla = ElectricCar("Tesla", "Model 3", 2025, 358)
tesla.start() # 2025 Tesla Model 3 is starting... (from Vehicle)
tesla.charge_status() # Range: 358 miles per charge (from ElectricCar)
print(tesla.describe()) # 2025 Tesla Model 3 (from Vehicle)
super() gives you access to the parent class so you can extend its behavior instead of
completely replacing it.
class Animal:
def __init__(self, name):
self._name = name
def speak(self):
return f"{self._name} makes a sound"
class Cat(Animal):
def speak(self):
# Call the parent version, then add to it
base = super().speak()
return base + " — specifically: Meow!"
class Dog(Animal):
def speak(self):
return f"{self._name} says: Woof!" # completely override, no super()
cat = Cat("Whiskers")
dog = Dog("Rex")
print(cat.speak()) # Whiskers makes a sound — specifically: Meow!
print(dog.speak()) # Rex says: Woof!
Polymorphism means you can call the same method on different object types and each one responds in its own way. This is powerful because the calling code doesn't need to know what type of object it has.
class Shape:
def area(self):
raise NotImplementedError("Every shape must implement area()")
def describe(self):
print(f"{self.__class__.__name__}: area = {self.area():.2f}")
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
# One loop works for ALL shapes — this is polymorphism
shapes = [Circle(5), Rectangle(4, 6), Triangle(3, 8)]
for shape in shapes:
shape.describe()
# Output:
# Circle: area = 78.54
# Rectangle: area = 24.00
# Triangle: area = 12.00
isinstance(obj, ClassName) returns True if obj is an instance of
that class (or any of its subclasses). This lets you branch behavior based on what type of object you're
working with.
class Animal:
def __init__(self, name):
self._name = name
@property
def name(self):
return self._name
def __str__(self):
return self._name
class Dog(Animal):
def fetch(self):
print(f"{self._name} fetches the ball!")
class Cat(Animal):
def purr(self):
print(f"{self._name} purrs...")
pets = [Dog("Rex"), Cat("Whiskers"), Dog("Buddy"), Cat("Luna")]
for pet in pets:
print(f"{pet.name} is a Dog: {isinstance(pet, Dog)}") # use the property
if isinstance(pet, Dog):
pet.fetch()
elif isinstance(pet, Cat):
pet.purr()
# isinstance also works with parent classes:
print(isinstance(pets[0], Animal)) # True — Dog IS an Animal
__str__ is the method Python calls automatically when you print() an object
or convert it to a string with str(). Without it, you get an unhelpful memory address.
class Player:
def __init__(self, name, level, score):
self._name = name
self._level = level
self._score = score
def __str__(self):
return f"Player('{self._name}' | Level {self._level} | Score: {self._score:,})"
p = Player("Alice", 12, 48750)
print(p) # Player('Alice' | Level 12 | Score: 48,750)
print(str(p)) # same thing
players = [Player("Alice", 12, 48750), Player("Bob", 7, 12300)]
for player in players:
print(player) # __str__ is called automatically each time
A small library catalog that uses a base class, two subclasses, encapsulation, polymorphism, and a simple manager class — all with no project overlap.
class LibraryItem:
def __init__(self, title, item_id):
self._title = title
self._item_id = item_id
self._checked_out = False
def checkout(self):
if self._checked_out:
print(f"'{self._title}' is already checked out.")
else:
self._checked_out = True
print(f"Checked out: '{self._title}'")
def return_item(self):
self._checked_out = False
print(f"Returned: '{self._title}'")
def is_available(self):
return not self._checked_out
@property
def item_id(self):
return self._item_id
def summary(self):
raise NotImplementedError
class Book(LibraryItem):
def __init__(self, title, item_id, author, pages):
super().__init__(title, item_id)
self._author = author
self._pages = pages
def summary(self):
status = "available" if self.is_available() else "checked out"
return f"[Book] '{self._title}' by {self._author} ({self._pages} pages) — {status}"
class DVD(LibraryItem):
def __init__(self, title, item_id, runtime_mins):
super().__init__(title, item_id)
self._runtime = runtime_mins
def summary(self):
status = "available" if self.is_available() else "checked out"
return f"[DVD] '{self._title}' ({self._runtime} min) — {status}"
class Library:
def __init__(self):
self._items = {}
def add_item(self, item):
self._items[item.item_id] = item
def list_all(self):
for item in self._items.values():
print(item.summary())
def find(self, item_id):
return self._items.get(item_id)
# --- Run it ---
lib = Library()
lib.add_item(Book("The Martian", "B001", "Andy Weir", 369))
lib.add_item(Book("Clean Code", "B002", "Robert Martin", 431))
lib.add_item(DVD("2001: A Space Odyssey", "D001", 149))
lib.list_all()
# [Book] 'The Martian' by Andy Weir (369 pages) — available
# [Book] 'Clean Code' by Robert Martin (431 pages) — available
# [DVD] '2001: A Space Odyssey' (149 min) — available
lib.find("B001").checkout() # Checked out: 'The Martian'
lib.find("B001").checkout() # 'The Martian' is already checked out.
print()
lib.list_all()
# [Book] 'The Martian' by Andy Weir (369 pages) — checked out
# ...
lib.find("B001").return_item() # Returned: 'The Martian'
Library.list_all() calls summary() on every item —
it doesn't know or care whether the item is a Book or DVD. That's
polymorphism at work. Adding a Magazine class later requires zero changes to
Library.