Skip to main content

πŸ—οΈ Lesson 1: Classes & Objects

Stop scattering related data across loose variables β€” learn to bundle state and behavior into classes, the building blocks of object-oriented Python.

🎯 Learning Objectives

By the end of this lesson, you will be able to:

  • Explain what object-oriented programming is and why it matters
  • Define a class with attributes and methods
  • Use __init__ to initialize object state
  • Understand how self connects methods to their instance
  • Create multiple independent objects from a single class

Estimated Time: 60 minutes

Project: Build a BankAccount class with deposits, withdrawals, and a transaction log

In This Lesson

πŸ€” Why Object-Oriented Programming?

In the intro course, you stored data in variables and operated on it with functions. That works fine for small scripts, but imagine managing a game with 50 enemies β€” each with its own health, position, speed, and attack pattern. Juggling dozens of separate lists and dictionaries gets messy fast.

Object-oriented programming (OOP) solves this by letting you bundle related data and the functions that operate on that data into a single unit called an object. The blueprint for creating objects is called a class.

πŸ“– Key Terms

Class: A blueprint or template that defines what data (attributes) and behavior (methods) objects of that type will have.

Object (Instance): A specific thing created from a class, with its own copy of the data.

Attribute: A variable that belongs to an object β€” its state.

Method: A function that belongs to an object β€” its behavior.

πŸ“¦ Without OOP vs. With OOP

Here's how you might track a player character without classes:

# Without OOP β€” scattered variables
player_name = "Kara"
player_health = 100
player_x = 0
player_y = 0

def move_player(x, y, dx, dy):
    return x + dx, y + dy

def take_damage(health, amount):
    return max(0, health - amount)

# Every function needs the data passed in manually
player_x, player_y = move_player(player_x, player_y, 3, 5)
player_health = take_damage(player_health, 20)

Now imagine two players, three enemies, and ten items. The variable soup becomes unmanageable. With a class, all the data and behavior travels together:

# With OOP β€” everything bundled together
class Player:
    def __init__(self, name):
        self.name = name
        self.health = 100
        self.x = 0
        self.y = 0

    def move(self, dx, dy):
        self.x += dx
        self.y += dy

    def take_damage(self, amount):
        self.health = max(0, self.health - amount)

# Clean and self-contained
kara = Player("Kara")
kara.move(3, 5)
kara.take_damage(20)

# Need another player? Easy.
leo = Player("Leo")
leo.move(-2, 1)

πŸ’‘ Notice

Each player carries its own name, health, x, and y. You call methods on the object instead of passing everything as arguments.

πŸ—ΊοΈ The OOP Mental Model

graph LR A["πŸ—οΈ Class
(Blueprint)"] -->|creates| B["πŸ“¦ Object 1
(Instance)"] A -->|creates| C["πŸ“¦ Object 2
(Instance)"] A -->|creates| D["πŸ“¦ Object 3
(Instance)"] B --- E["πŸ”Ή Attributes
(Data)"] B --- F["πŸ”Έ Methods
(Behavior)"]

✏️ Defining Your First Class

A class is defined with the class keyword followed by a name in PascalCase (each word capitalized, no underscores):

class Dog:
    pass  # placeholder β€” we'll fill this in shortly

That's a valid (if empty) class. You can already create objects from it:

my_dog = Dog()
your_dog = Dog()

print(type(my_dog))   # <class '__main__.Dog'>
print(my_dog is your_dog)  # False β€” two separate objects

Output:

<class '__main__.Dog'>
False

⚠️ Naming Convention

Classes use PascalCase: BankAccount, PlayerCharacter, HttpResponse.

Instances (objects) and regular variables use snake_case: my_account, active_player, response.

This convention is universal in Python β€” follow it so your code reads naturally to other developers.

class Dog name breed bark() Blueprint πŸ• rex name = "Rex" breed = "Husky" πŸ• bella name = "Bella" breed = "Poodle" πŸ• max name = "Max" breed = "Beagle" Instances (Objects)

βš™οΈ The __init__ Method & self

An empty class isn't very useful. To give objects their initial state, you define a special method called __init__ (short for "initialize"). Python calls it automatically every time you create a new object.

class Dog:
    def __init__(self, name, breed):
        self.name = name
        self.breed = breed

rex = Dog("Rex", "Husky")
print(rex.name)   # Rex
print(rex.breed)  # Husky

Output:

Rex
Husky

πŸ”‘ What is self?

self is a reference to the specific object being created or acted upon. When you write self.name = name, you're saying: "store the value of name as an attribute on this particular object."

🧠 How self Works Behind the Scenes

When you call Dog("Rex", "Husky"), Python:

  1. Creates a new, empty Dog object in memory
  2. Passes that new object as the first argument (self) to __init__
  3. __init__ attaches attributes to self, giving the object its data
  4. The finished object is returned and assigned to rex

You never pass self explicitly β€” Python handles it for you.

sequenceDiagram participant You as πŸ‘€ Your Code participant Py as 🐍 Python participant Obj as πŸ“¦ New Object You->>Py: Dog("Rex", "Husky") Py->>Obj: Create empty Dog object Py->>Obj: __init__(self, "Rex", "Husky") Obj->>Obj: self.name = "Rex" Obj->>Obj: self.breed = "Husky" Py-->>You: Return initialized object β†’ rex

Default Values in __init__

Just like regular functions, __init__ parameters can have defaults:

class Dog:
    def __init__(self, name, breed="Mixed"):
        self.name = name
        self.breed = breed
        self.energy = 100  # always starts at 100

buddy = Dog("Buddy")           # breed defaults to "Mixed"
luna = Dog("Luna", "Labrador")  # breed explicitly set

print(f"{buddy.name} is a {buddy.breed}")
print(f"{luna.name} is a {luna.breed}")

Output:

Buddy is a Mixed
Luna is a Labrador

⚠️ Common Mistake: Forgetting self

If you write name = name instead of self.name = name inside __init__, the value is just a local variable β€” it disappears when __init__ finishes. Always assign to self.attribute to keep data on the object.

# ❌ Wrong β€” local variable, lost after __init__
class Dog:
    def __init__(self, name):
        name = name  # does nothing useful!

d = Dog("Rex")
print(d.name)  # AttributeError: 'Dog' object has no attribute 'name'

πŸ”Ή Instance Attributes

Instance attributes are variables attached to a specific object via self. Each object gets its own independent copy.

class Player:
    def __init__(self, name, role):
        self.name = name       # from parameter
        self.role = role       # from parameter
        self.level = 1         # hardcoded default
        self.inventory = []    # each player gets their OWN list

p1 = Player("Kara", "Warrior")
p2 = Player("Leo", "Mage")

p1.inventory.append("Sword")
p2.inventory.append("Staff")

print(p1.inventory)  # ['Sword']
print(p2.inventory)  # ['Staff'] β€” separate lists!

Output:

['Sword']
['Staff']

βœ… Key Insight

Because self.inventory = [] runs inside __init__, a new empty list is created each time an object is instantiated. The two players don't share a list β€” they each own their own.

Reading and Modifying Attributes

Access attributes with dot notation. You can read them, modify them, or even add new ones after creation (though adding attributes outside __init__ is generally discouraged).

# Read
print(p1.name)       # Kara
print(p1.level)      # 1

# Modify
p1.level = 5
print(p1.level)      # 5

# Add (works but not recommended)
p1.title = "Champion"
print(p1.title)      # Champion
# p2 does NOT have .title β€” it was only added to p1
p1 (Player) .name = "Kara" .role = "Warrior" .level = 1 .inventory = ["Sword"] p2 (Player) .name = "Leo" .role = "Mage" .level = 1 .inventory = ["Staff"]

πŸ”Έ Instance Methods

Methods are functions defined inside a class. Like __init__, they take self as their first parameter, giving them access to the object's attributes.

class Dog:
    def __init__(self, name, breed):
        self.name = name
        self.breed = breed
        self.energy = 100

    def bark(self):
        """Make the dog bark."""
        if self.energy > 0:
            self.energy -= 10
            return f"{self.name} says: Woof! πŸ• (Energy: {self.energy})"
        return f"{self.name} is too tired to bark."

    def rest(self, hours):
        """Restore energy based on hours of rest."""
        restored = min(hours * 20, 100 - self.energy)
        self.energy += restored
        return f"{self.name} rested for {hours}h. Energy: {self.energy}"

    def describe(self):
        """Return a description string."""
        return f"{self.name} the {self.breed} β€” Energy: {self.energy}/100"
rex = Dog("Rex", "Husky")
print(rex.bark())
print(rex.bark())
print(rex.rest(2))
print(rex.describe())

Output:

Rex says: Woof! πŸ• (Energy: 90)
Rex says: Woof! πŸ• (Energy: 80)
Rex rested for 2h. Energy: 100
Rex the Husky β€” Energy: 100/100

Methods That Call Other Methods

Methods can call other methods on the same object using self:

class Dog:
    def __init__(self, name, breed):
        self.name = name
        self.breed = breed
        self.energy = 100
        self.tricks = []

    def learn_trick(self, trick):
        self.tricks.append(trick)
        return f"{self.name} learned '{trick}'!"

    def perform_tricks(self):
        if not self.tricks:
            return f"{self.name} doesn't know any tricks yet."
        results = []
        for trick in self.tricks:
            self.energy -= 5  # each trick costs energy
            results.append(f"  ✨ {trick}")
        return f"{self.name} performs:\n" + "\n".join(results)

rex = Dog("Rex", "Husky")
print(rex.learn_trick("Sit"))
print(rex.learn_trick("Shake"))
print(rex.perform_tricks())

Output:

Rex learned 'Sit'!
Rex learned 'Shake'!
Rex performs:
  ✨ Sit
  ✨ Shake
graph TD A["πŸ”Έ bark()"] -->|reads/modifies| D["πŸ”Ή self.energy"] B["πŸ”Έ rest(hours)"] -->|modifies| D C["πŸ”Έ describe()"] -->|reads| D C -->|reads| E["πŸ”Ή self.name"] C -->|reads| F["πŸ”Ή self.breed"] A -->|reads| E B -->|reads| E

🧠 Methods vs. Functions

A function is standalone: len(my_list). A method is called on an object: my_list.append(x). You've been using methods all along β€” .append(), .upper(), .items() β€” you just didn't know they were methods of list, str, and dict objects.

🧩 Putting It Together β€” Multiple Objects

One of the biggest wins of OOP: you can create as many objects as you want from the same class, and each one tracks its own state independently.

class BankAccount:
    def __init__(self, owner, balance=0):
        self.owner = owner
        self.balance = balance
        self.transactions = []

    def deposit(self, amount):
        if amount <= 0:
            return "Deposit must be positive."
        self.balance += amount
        self.transactions.append(f"+${amount:.2f}")
        return f"Deposited ${amount:.2f}. Balance: ${self.balance:.2f}"

    def withdraw(self, amount):
        if amount <= 0:
            return "Withdrawal must be positive."
        if amount > self.balance:
            return f"Insufficient funds. Balance: ${self.balance:.2f}"
        self.balance -= amount
        self.transactions.append(f"-${amount:.2f}")
        return f"Withdrew ${amount:.2f}. Balance: ${self.balance:.2f}"

    def get_statement(self):
        header = f"--- Statement for {self.owner} ---"
        if not self.transactions:
            return header + "\nNo transactions yet."
        body = "\n".join(f"  {t}" for t in self.transactions)
        footer = f"  Current balance: ${self.balance:.2f}"
        return f"{header}\n{body}\n{footer}"
# Create two separate accounts
alice = BankAccount("Alice", 500)
bob = BankAccount("Bob")

# Alice's transactions
print(alice.deposit(200))
print(alice.withdraw(50))

# Bob's transactions
print(bob.deposit(1000))
print(bob.withdraw(9999))  # should fail

# Statements are independent
print()
print(alice.get_statement())
print()
print(bob.get_statement())

Output:

Deposited $200.00. Balance: $700.00
Withdrew $50.00. Balance: $650.00
Deposited $1000.00. Balance: $1000.00
Insufficient funds. Balance: $1000.00

--- Statement for Alice ---
  +$200.00
  -$50.00
  Current balance: $650.00

--- Statement for Bob ---
  +$1000.00
  Current balance: $1000.00

Objects in Collections

Since objects are just values, you can store them in lists, dictionaries, or anywhere else:

kennel = [
    Dog("Rex", "Husky"),
    Dog("Bella", "Poodle"),
    Dog("Max", "Beagle"),
]

for dog in kennel:
    print(dog.describe())

Output:

Rex the Husky β€” Energy: 100/100
Bella the Poodle β€” Energy: 100/100
Max the Beagle β€” Energy: 100/100

πŸ‹οΈ Hands-on Exercises

πŸ‹οΈ Exercise 1: Build a Book Class

Objective: Practice defining a class with __init__, attributes, and a method.

Requirements:

  1. Create a Book class with attributes: title, author, pages, and current_page (starts at 1)
  2. Add a read(num_pages) method that advances current_page but doesn't go past the last page
  3. Add a progress() method that returns a string like "The Hobbit: 45% complete"

Starter Code:

class Book:
    def __init__(self, title, author, pages):
        # TODO: set attributes
        pass

    def read(self, num_pages):
        # TODO: advance current_page (don't exceed total pages)
        pass

    def progress(self):
        # TODO: return "Title: X% complete"
        pass

# Test it
hobbit = Book("The Hobbit", "J.R.R. Tolkien", 310)
hobbit.read(100)
print(hobbit.progress())  # The Hobbit: 32% complete
hobbit.read(500)          # should cap at page 310
print(hobbit.progress())  # The Hobbit: 100% complete
πŸ’‘ Hint

Use min() to cap current_page at self.pages. For the percentage, calculate (current_page / pages) * 100 and use int() or round().

βœ… Solution
class Book:
    def __init__(self, title, author, pages):
        self.title = title
        self.author = author
        self.pages = pages
        self.current_page = 1

    def read(self, num_pages):
        self.current_page = min(self.current_page + num_pages, self.pages)

    def progress(self):
        pct = int((self.current_page / self.pages) * 100)
        return f"{self.title}: {pct}% complete"

hobbit = Book("The Hobbit", "J.R.R. Tolkien", 310)
hobbit.read(100)
print(hobbit.progress())  # The Hobbit: 32% complete
hobbit.read(500)
print(hobbit.progress())  # The Hobbit: 100% complete

πŸ‹οΈ Exercise 2: Build a TodoList Class

Objective: Practice using lists as attributes and methods that modify state.

Requirements:

  1. Create a TodoList class with a name and an empty list of tasks
  2. Each task is a dictionary: {"text": "...", "done": False}
  3. Add methods: add_task(text), complete_task(index), and show()
  4. show() prints each task with a βœ… or ⬜ indicator
πŸ’‘ Hint

In complete_task, check the index is valid before setting self.tasks[index]["done"] = True. In show(), loop through tasks and use an f-string with a conditional: "βœ…" if task["done"] else "⬜".

βœ… Solution
class TodoList:
    def __init__(self, name):
        self.name = name
        self.tasks = []

    def add_task(self, text):
        self.tasks.append({"text": text, "done": False})

    def complete_task(self, index):
        if 0 <= index < len(self.tasks):
            self.tasks[index]["done"] = True
        else:
            print(f"Invalid task index: {index}")

    def show(self):
        print(f"πŸ“‹ {self.name}")
        if not self.tasks:
            print("  (no tasks)")
            return
        for i, task in enumerate(self.tasks):
            icon = "βœ…" if task["done"] else "⬜"
            print(f"  {i}. {icon} {task['text']}")

# Test it
my_list = TodoList("Weekend Chores")
my_list.add_task("Grocery shopping")
my_list.add_task("Clean kitchen")
my_list.add_task("Walk the dog")
my_list.complete_task(0)
my_list.show()

🎯 Quick Quiz

Question 1: What does self refer to inside a method?

Question 2: What happens if you write name = name instead of self.name = name inside __init__?

Question 3: If two objects are created from the same class, which statement is true?

πŸ“ Best Practices

βœ… Do's

  • Initialize all attributes in __init__ β€” even if they start as None or empty. This makes it clear what data every object will have.
  • Use PascalCase for classes, snake_case for everything else β€” BankAccount, not bank_account or bankAccount.
  • Keep classes focused β€” a class should represent one concept. A Dog class shouldn't also manage the dog park schedule.
  • Add docstrings β€” describe what the class represents and what each method does.

❌ Don'ts

  • Don't forget self β€” it must be the first parameter of every instance method, including __init__.
  • Don't add attributes outside __init__ β€” obj.new_attr = "surprise" works but makes your class hard to understand.
  • Don't use mutable default arguments in __init__ signatures β€” def __init__(self, items=[]) is a classic Python trap. Use None and create inside the method instead.

⚠️ The Mutable Default Trap

# ❌ WRONG β€” all instances share the SAME list
class Bad:
    def __init__(self, items=[]):
        self.items = items

# βœ… CORRECT β€” each instance gets a fresh list
class Good:
    def __init__(self, items=None):
        self.items = items if items is not None else []

Default arguments are evaluated once when the function is defined, not each time it's called. Using None as the sentinel avoids shared state.

πŸ“ Summary

πŸŽ‰ Key Takeaways

  • A class is a blueprint; an object is a specific instance created from it
  • __init__ runs automatically when you create an object β€” use it to set up initial state
  • self refers to the current instance and connects attributes/methods to their object
  • Instance attributes (via self.x) give each object its own data
  • Instance methods (with self as first param) define what an object can do
  • Each object is independent β€” modifying one doesn't affect others
Concept Syntax Purpose
Define a class class MyClass: Create a blueprint
Constructor def __init__(self, ...): Initialize object state
Instance attribute self.name = value Store data on the object
Instance method def method(self, ...): Define behavior
Create an object obj = MyClass(args) Instantiate from blueprint
Call a method obj.method(args) Trigger behavior on object

πŸ“š Additional Resources

πŸš€ What's Next?

In the next lesson, we'll explore Inheritance & Polymorphism β€” how to create specialized classes that build on existing ones, reuse code, and override behavior. You'll see how a SavingsAccount can inherit from BankAccount and add interest calculations.

πŸŽ‰ Great Start!

You've unlocked the foundation of object-oriented Python. Every class, library, and framework you'll use builds on these concepts.