Lesson 10: Object-Oriented Programming — Classes and Objects
🎧 Listen to this lesson
Your programs so far keep data in loose variables and dictionaries, and pass them to functions. That works — until a program has fifty students, two hundred books or a thousand game characters, each with its own data and its own rules. Object-oriented programming (OOP) bundles the data and the functions that work on it into objects: a BankAccount object carries its balance and refuses invalid withdrawals itself, instead of relying on every other part of the program to be careful. This lesson covers classes, __init__, self, methods, and the class-attribute trap that bites nearly every beginner.
Classes and objects
A class is a blueprint; an object (or instance) is a thing built from it. The class defines what data each object holds and what it can do. Methods are functions defined inside the class, and their first parameter is always self — the object the method was called on. The special method __init__ runs automatically when an object is created and sets up its starting data:
class Dog:
"""A simple dog with a name and an energy level."""
def __init__(self, name):
self.name = name
self.energy = 5
def bark(self):
return f"{self.name} says woof!"
def play(self):
self.energy -= 1
return f"{self.name} plays fetch. Energy now {self.energy}."
d = Dog("Rex")
print(d.bark())
print(d.play())
print(d.play())
Output:
Rex says woof!
Rex plays fetch. Energy now 4.
Rex plays fetch. Energy now 3.
Unpack the machinery: Dog("Rex") creates an object and calls __init__ with self bound to the new dog and name = "Rex"; the lines self.name = name store the data on the object. When you call d.play(), Python passes d as self automatically — you never write it in the call. Each object keeps its own state: a second Dog("Fido") would have its own name and its own energy, untouched by Rex's games. That independence is the entire point: ten lines of class can stamp out ten thousand self-managing objects.
Why objects beat loose dictionaries
Lesson 9 stored a question as a dictionary and relied on separate functions to handle it correctly. Nothing stopped other code from writing item["answer"] = 99 or deleting a key. An object can guard its own rules — data goes in and out through methods that validate. A bank account is the canonical demonstration: the balance must never go negative, and the only doors are deposit and withdraw, both of which check.
Worked example 1 — a BankAccount that guards itself
class BankAccount:
"""A bank account that guards its own balance."""
def __init__(self, owner):
self.owner = owner
self.balance = 0
def deposit(self, amount):
if amount > 0:
self.balance += amount
return True
return False
def withdraw(self, amount):
if 0 < amount <= self.balance:
self.balance -= amount
return True
return False
acct = BankAccount("Ada")
print(acct.deposit(100))
print(acct.withdraw(30))
print("Balance:", acct.balance)
print(acct.withdraw(1000))
print("Balance:", acct.balance)
Output:
True
True
Balance: 70
False
Balance: 70
Trace it: the deposit of 100 succeeds; the withdrawal of 30 succeeds, leaving 70; the withdrawal of 1000 is refused — it returns False and, crucially, the balance stays 70. The condition 0 < amount <= self.balance (a chained comparison, both halves must hold) also refuses negative withdrawals, which would otherwise be a sneaky way to *add* money. This pattern — state plus guarding methods — is called encapsulation, and it scales: the object's rules are written once, in the class, and enforced everywhere the object is used.
Worked example 2 — a Student gradebook
Objects shine in collections: a list of objects, each managing itself, with the program asking each one for a summary.
class Student:
"""A student with a list of quiz scores."""
def __init__(self, name):
self.name = name
self.scores = []
def add_score(self, score):
self.scores.append(score)
def average(self):
return sum(self.scores) / len(self.scores)
def report(self):
return f"{self.name}: average {self.average():.1f} over {len(self.scores)} quizzes"
ada = Student("Ada")
for s in [88, 92, 75]:
ada.add_score(s)
ben = Student("Ben")
for s in [64, 71]:
ben.add_score(s)
print(ada.report())
print(ben.report())
Output:
Ada: average 85.0 over 3 quizzes
Ben: average 67.5 over 2 quizzes
Notice self.scores = [] sits inside __init__, so each student gets a fresh list — Ada's scores can never leak into Ben's average. Hold that detail; the Common mistakes section shows the disaster when the list lives on the class instead. Also notice report() calls self.average() — methods can use sibling methods through self, which keeps formulas in exactly one place.
Common mistakes
- Forgetting
self. Writingdef bark():inside a class, then callingd.bark(), raisesTypeError: Dog.bark() takes 0 positional arguments but 1 was given— Python always passes the object. Every instance method needsselfas its first parameter. - The shared class attribute. This looks innocent and is not:
class DogTeam:
tricks = [] # class attribute: ONE list shared by every dog
def __init__(self, name):
self.name = name
def learn(self, trick):
self.tricks.append(trick)
rex = DogTeam("Rex")
fido = DogTeam("Fido")
rex.learn("roll over")
print("Rex knows:", rex.tricks)
print("Fido knows:", fido.tricks)
Output: Rex knows: ['roll over'] and Fido knows: ['roll over'] — Fido never learned anything, but he shares the class-level list, so Rex's trick appears in his record too. Class attributes are for constants shared on purpose (a tax rate, a species name). Anything each object should own separately — especially a mutable list or dictionary — must be created in __init__.
- Calling a method without parentheses.
print(acct.balance)shows the number, butprint(acct.withdraw)shows something like<bound method ...>— you printed the method itself. Methods are called:acct.withdraw(30). - Reaching inside instead of asking. Code like
acct.balance -= 50scattered through a program bypasses every guard. If a class offers a method for an operation, use it — that is what the method is for.
Real-World Challenge
The task. Model a small library. A Book knows its title, author and whether it is checked out, and refuses a second checkout while it is out. A Library holds a collection of books (objects built from other objects — a preview of composition in Lesson 16), can find a book by title, and can list what is currently available.
Worked solution.
class Book:
"""A library book that knows whether it is on the shelf."""
def __init__(self, title, author):
self.title = title
self.author = author
self.checked_out = False
def checkout(self):
if self.checked_out:
return False
self.checked_out = True
return True
def give_back(self):
self.checked_out = False
def status(self):
state = "checked out" if self.checked_out else "on shelf"
return f"'{self.title}' by {self.author} - {state}"
class Library:
"""A small library: a collection of Book objects."""
def __init__(self):
self.books = []
def add(self, book):
self.books.append(book)
def find(self, title):
for book in self.books:
if book.title == title:
return book
return None
def available(self):
return [b.title for b in self.books if not b.checked_out]
lib = Library()
lib.add(Book("Dune", "Frank Herbert"))
lib.add(Book("Emma", "Jane Austen"))
lib.add(Book("The Hobbit", "J. R. R. Tolkien"))
print(lib.find("Emma").checkout())
print(lib.find("Emma").checkout())
for book in lib.books:
print(book.status())
print("Available:", lib.available())
Output:
True
False
'Dune' by Frank Herbert - on shelf
'Emma' by Jane Austen - checked out
'The Hobbit' by J. R. R. Tolkien - on shelf
Available: ['Dune', 'The Hobbit']
How it works. Book encapsulates the checkout rules: the first checkout() on Emma returns True and flips the flag; the second returns False because the flag is already set — the book itself enforces "one loan at a time", so no librarian code anywhere can double-loan it by accident. Library composes books: find is a linear search (Lesson 8) returning the object itself, so lib.find("Emma").checkout() chains a search and an action in one readable line, and available is a filtering comprehension over the collection. Notice the division of knowledge: Library never touches checked_out arithmetic beyond reading it in the filter; Book knows nothing about the library. Systems built this way — small objects, clear jobs, narrow interfaces — stay understandable as they grow.
Extension. Add due dates: store a due date string when a book is checked out (pass it to checkout(due)), clear it in give_back, and add a Library.overdue(today) method returning the titles of books whose due date is before today and still out — ISO date strings compare correctly with <, exactly as in Lesson 11. Test with three books, two due dates in the past and one in the future.
Going further — objects as state machines, and identity versus equality
A class like this Counter looks trivial, and it teaches the deepest habit in OOP: an object's methods are the *only* doors to its state, so the object can guarantee rules about how that state may change:
class Counter:
"""A labelled counter that only moves through its methods."""
def __init__(self, label):
self.label = label
self.value = 0
def click(self):
self.value += 1
return self.value
def reset(self):
self.value = 0
c = Counter("Downloads")
print(c.click(), c.click(), c.click())
c.reset()
print(c.value)
Output: 1 2 3 then 0. Nothing outside the class can set the value to -50 or "banana" unless it deliberately reaches past the interface — encapsulation as a social contract backed by convenience. The companion idea is the difference between equality and identity: == asks whether two objects have equal value (by whatever rule the class defines — Lesson 16's __eq__), while is asks whether they are literally the same object in memory:
a = [1, 2]
b = [1, 2]
print(a == b, a is b)
Output: True False — twins, not the same list; appending to a leaves b untouched, a fact that matters enormously when objects are passed into functions (Lesson 4's aliasing lesson in object clothing). Reach for is only for singletons like None (x is None is the idiom); for values, == is the question you almost always mean.
Practice corner — defaults in __init__ and friendly printing
Constructors accept defaults like any function, which makes partial specification natural — an alarm needs an hour, and minutes can default to the hour:
class Alarm:
"""A 24-hour alarm with zero-padded display."""
def __init__(self, hour, minute=0):
self.hour = hour
self.minute = minute
def __str__(self):
return f"{self.hour:02d}:{self.minute:02d}"
print(Alarm(7), Alarm(21, 45))
Output: 07:00 21:45. Two crafts meet here: the default keeps the common call short (Alarm(7) reads exactly like the real-world instruction "alarm at 7"), while the full form stays available; and __str__ — the dunder Lesson 16 develops properly — makes the object print as users think of it, zero-padded with the :02d format spec from Lesson 3's practice corner. A class whose instances print readably is dramatically easier to debug, because every exploratory print during development tells the truth in the domain's own language.
🎬 Watch & Learn
Prefer video? These searches surface top tutorials on this lesson's topic:
- Python OOP — classes & objects explained (Corey Schafer style) — pick a recent, well-reviewed video
- freeCodeCamp: object-oriented programming in Python — pick a recent, well-reviewed video
- Tech With Tim: Python classes and objects beginner tutorial — pick a recent, well-reviewed video
💪 Practice Zone
Try these yourself — hints are included, answers are not. Run your code in any Python environment.
- Bank account — Write a
BankAccountclass withdeposit(amount),withdraw(amount)(refuse overdrafts), and abalanceattribute.Hint
Methods takeselffirst and modifyself.balance. Withdraw should checkif amount <= self.balance. - Readable objects — Add a
__str__method to yourBankAccountsoprint(account)shows something likeBalance: $120.Hint
def __str__(self): return f"Balance: ${self.balance}". - Student roster — Make a
Studentclass (name, grade). Create 3 students in a list, loop through, and print each.Hint
Store objects in a normal list:roster = [Student("Ava", 9), ...].
🌍 Real World
Video games model every character, weapon, and level as objects; banking systems model accounts and transactions as objects. When a game adds a new enemy type, programmers write a new class — the same pattern as your BankAccount, just with more dragons.