Python Programming — Alpha School

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.

Key idea. Key idea: A class bundles state (attributes set in __init__) with behaviour (methods). Objects guard their own rules, and each instance's state is independent — that independence is what you are buying with OOP.

Common mistakes

  • Forgetting self. Writing def bark(): inside a class, then calling d.bark(), raises TypeError: Dog.bark() takes 0 positional arguments but 1 was given — Python always passes the object. Every instance method needs self as 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, but print(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 -= 50 scattered through a program bypasses every guard. If a class offers a method for an operation, use it — that is what the method is for.
Try it. Try it: Write a Rectangle class with __init__(width, height), an area() method, and a perimeter() method. Rectangle(3, 4).area() should return 12 and .perimeter() should return 14. Then create two rectangles and confirm their attributes are independent.

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:

💪 Practice Zone

Try these yourself — hints are included, answers are not. Run your code in any Python environment.

  1. Bank account — Write a BankAccount class with deposit(amount), withdraw(amount) (refuse overdrafts), and a balance attribute.
    HintMethods take self first and modify self.balance. Withdraw should check if amount <= self.balance.
  2. Readable objects — Add a __str__ method to your BankAccount so print(account) shows something like Balance: $120.
    Hintdef __str__(self): return f"Balance: ${self.balance}".
  3. Student roster — Make a Student class (name, grade). Create 3 students in a list, loop through, and print each.
    HintStore 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.

Interactive — hover or explore the chart above.

Interactive — hover or explore the chart above.

Interactive — hover or explore the chart above.

Interactive — hover or explore the chart above.

Interactive — hover or explore the chart above.

Keep reading to complete this lesson