ποΈ 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
selfconnects 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
(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.
βοΈ 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:
- Creates a new, empty
Dogobject in memory - Passes that new object as the first argument (
self) to__init__ __init__attaches attributes toself, giving the object its data- The finished object is returned and assigned to
rex
You never pass self explicitly β Python handles it for you.
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
πΈ 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
π§ 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:
- Create a
Bookclass with attributes:title,author,pages, andcurrent_page(starts at 1) - Add a
read(num_pages)method that advancescurrent_pagebut doesn't go past the last page - 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:
- Create a
TodoListclass with anameand an empty list oftasks - Each task is a dictionary:
{"text": "...", "done": False} - Add methods:
add_task(text),complete_task(index), andshow() 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 asNoneor empty. This makes it clear what data every object will have. - Use PascalCase for classes, snake_case for everything else β
BankAccount, notbank_accountorbankAccount. - Keep classes focused β a class should represent one concept. A
Dogclass 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. UseNoneand 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 stateselfrefers 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
selfas 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
- Python Docs β Classes Tutorial
- Real Python β OOP in Python 3
- Python Tutor β paste any class example and step through it visually
π 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.