Lesson 9 of 16 · Level 3: Advanced
Python Classes and Objects: Object-Oriented Programming
- ⏱ 13 min read
- ❓ 4 review questions
- ✍️ Exercise with solution
In this lesson
- Understand what classes and objects are, and when they're worth using.
- Create your own classes with attributes and methods.
- Use inheritance and the special methods
__init__and__str__. - Write data classes in a few lines with
dataclass. - Model a real case: online orders at a Chicago pizzeria.
Welcome to the Advanced level. So far you've organized programs with functions and stored data in dictionaries. That works, but as a program grows, problems show up: misspelled dictionary keys, loose functions that all operate on the same data, repeated logic. Object-oriented programming (OOP) bundles data and the functions that manage it into a single unit: the object. Almost every library you'll use, from pandas to scikit-learn, is written this way.
Class and object
- A class is a blueprint: it defines what data something has and what it can do. For example, "Bank account."
- An object (or instance) is a specific thing built from that blueprint: Jane's account, Carlos's account.
You've already used objects without knowing it: every string is an object of the str class, which is why it has methods like .upper().
Your first class
class BankAccount:
def __init__(self, owner, balance=0):
self.owner = owner
self.balance = balance
def deposit(self, amount):
self.balance += amount
def withdraw(self, amount):
if amount > self.balance:
print("Insufficient funds")
return False
self.balance -= amount
return True
janes_account = BankAccount("Jane", 500)
carlos_account = BankAccount("Carlos")
janes_account.deposit(200)
janes_account.withdraw(1000) # Insufficient funds
print(janes_account.balance) # 700
print(carlos_account.balance) # 0
The key pieces:
class BankAccount:defines the class. By convention, class names use CamelCase (words joined, each capitalized).__init__is the constructor: it runs when the object is created and sets up its data.selfis the object itself.self.balancemeans "the balance of this account." Python passes it automatically: you writejanes_account.deposit(200), notdeposit(janes_account, 200).- Attributes (
owner,balance): each object's data. - Methods (
deposit,withdraw): the functions that belong to the class.
1. What is self for inside a method?
self.balance is the balance of that particular object; every account has its own.
Displaying objects nicely: __str__
If you run print(janes_account) you'll see something like <__main__.BankAccount object at 0x7f...>. To control how it's displayed, define __str__:
class BankAccount:
def __init__(self, owner, balance=0):
self.owner = owner
self.balance = balance
def __str__(self):
return f"{self.owner}'s account: ${self.balance:,.2f}"
print(BankAccount("Jane", 1250.5)) # Jane's account: $1,250.50
Methods with double underscores on each side ("dunder" methods) tell Python how your object should behave in special operations: being displayed, compared, added, measured with len() and more.
Encapsulation: protecting the data
What stops someone from writing janes_account.balance = -5000? Technically, nothing. Python relies on conventions: an attribute that starts with an underscore (_balance) means "internal, don't touch it from outside." And with @property you can expose a read-only value:
class BankAccount:
def __init__(self, owner, balance=0):
self.owner = owner
self._balance = balance
self._transactions = []
@property
def balance(self):
return self._balance
def deposit(self, amount):
if amount <= 0:
raise ValueError("Deposit must be positive")
self._balance += amount
self._transactions.append(("deposit", amount))
a = BankAccount("Jane")
a.deposit(100)
print(a.balance) # 100
# a.balance = 999 → AttributeError: property 'balance' ... has no setter
Notice raise ValueError(...): instead of printing a message, the method raises an exception that the caller can catch with try/except (lesson 8). That's the professional way to signal an error.
2. By convention, what does an attribute named _balance signal?
The leading underscore is a "private" convention. Python doesn't block access, but every developer gets the message.
Inheritance: reuse and specialize
A class can inherit from another: it gets all of its attributes and methods and can add or change some. A high-yield savings account is a bank account that also earns interest, quoted in the U.S. as an APY (annual percentage yield):
class SavingsAccount(BankAccount):
def __init__(self, owner, balance=0, apy=0.04):
super().__init__(owner, balance)
self.apy = apy
def add_monthly_interest(self):
interest = self._balance * ((1 + self.apy) ** (1 / 12) - 1)
self.deposit(round(interest, 2))
savings = SavingsAccount("Carlos", 10_000, apy=0.04)
savings.add_monthly_interest()
print(savings.balance) # 10032.74
class SavingsAccount(BankAccount): inherits fromBankAccount.super().__init__(...)calls the parent's constructor so you don't repeat code.- The savings account can use
depositandbalancewithout defining them.
Use inheritance when the relationship is "is a kind of": a savings account is a kind of bank account. If the relationship is "has a" (a customer has accounts), store one object inside another instead (composition).
3. What does super().__init__(owner, balance) do in the child class?
That way the child class reuses the parent's setup and only adds its own attribute (apy).
dataclass: data classes without the boilerplate
When a class mostly holds data, the @dataclass decorator automatically generates __init__, a readable display and comparisons between objects:
from dataclasses import dataclass
from datetime import date
@dataclass
class Expense:
date: date
category: str
description: str
amount: float
def is_large(self, limit=100):
return self.amount > limit
e = Expense(date(2026, 10, 1), "food", "Lunch", 14.25)
print(e) # Expense(date=datetime.date(2026, 10, 1), category='food', ...)
print(e.is_large()) # False
Compare that with the dictionaries in lesson 8's project: now a typo in a field name (e.categry) raises a clear error, AttributeError: 'Expense' object has no attribute 'categry'. Did you mean: 'category'?, instead of slipping through, and your editor autocompletes the names.
When should you use classes?
- When
- Several values that always travel together, plus operations on them: an account, an order, a product.
- Also
- When you need many objects of the same type, each with its own state, or variations of something (inheritance).
- When
- A short script that reads a file, calculates and prints a result.
- Why
- A class would add structure nobody needs. Not everything has to be a class.
Real case: online orders at a Chicago pizzeria
A pizzeria in Chicago takes orders online and needs to know what to charge and where each order stands. An order has data (customer, neighborhood, items) and behavior (calculate the total, move to the next status): it's the perfect candidate for a class.
from dataclasses import dataclass
@dataclass
class MenuItem:
name: str
price: float
class Order:
STATUSES = ["received", "in the oven", "on the way", "delivered"]
DELIVERY_FEE = 4.99
def __init__(self, customer, neighborhood, delivery=True):
self.customer = customer
self.neighborhood = neighborhood
self.delivery = delivery
self.items = [] # every order gets its own list
self.status = "received"
def add(self, item, quantity=1):
self.items.append((item, quantity))
def total(self):
subtotal = sum(i.price * q for i, q in self.items)
return subtotal + (self.DELIVERY_FEE if self.delivery else 0)
def advance(self):
i = self.STATUSES.index(self.status)
if i == len(self.STATUSES) - 1:
raise ValueError("The order was already delivered")
self.status = self.STATUSES[i + 1]
def __str__(self):
return f"{self.customer:7} {self.neighborhood:12} ${self.total():6.2f} [{self.status}]"
pizza = MenuItem("Large deep-dish pizza", 24.99)
soda = MenuItem("Soda 2 L", 3.99)
knots = MenuItem("Garlic knots", 6.50)
o1 = Order("Kayla", "Wicker Park")
o1.add(pizza, 2)
o1.add(soda)
o2 = Order("Marcus", "Lincoln Park")
o2.add(pizza, 4)
o2.add(knots, 2)
o2.add(soda, 2)
o3 = Order("Lucia", "Pilsen", delivery=False)
o3.add(pizza)
o1.advance()
o1.advance() # received → in the oven → on the way
o2.advance()
for o in [o1, o2, o3]:
print(o)
Output:
Kayla Wicker Park $ 58.96 [on the way]
Marcus Lincoln Park $125.93 [in the oven]
Lucia Pilsen $ 24.99 [received]
- The class 1 blueprintDefines what data an order has and what it can do. You write it once.
- The objects 3 ordersEach keeps its own data and status. Advancing o1 doesn't change o2.
delivery=False (a pickup order).Three details to notice. STATUSES and DELIVERY_FEE are class attributes: values shared by every order, written once. advance() protects the business rules: an order can't move past "delivered." If you add for _ in range(4): at the end of the program with o3.advance() indented on the next line, the fourth call raises a clear error instead of leaving the order in an impossible state:
Traceback (most recent call last):
File "pizzeria.py", line 58, in <module>
o3.advance()
~~~~~~~~~~^^
File "pizzeria.py", line 31, in advance
raise ValueError("The order was already delivered")
ValueError: The order was already delivered
This traceback has two levels, and you read it bottom up: the last line says what happened, the one above says which method raised it (advance, line 31), and the top one shows which line of your program called it (58).
4. If the Order class had items = [] as a class attribute (outside __init__) and you added a pizza to o1, what would o2.items contain?
A class attribute exists once for all objects. With a list, that means every order appends to the same list. That's why self.items = [] goes inside __init__: each object gets its own.
Common mistakes with classes
Forgetting self in a method: def total():
Every method receives self first. The message is TypeError: Order.total() takes 0 positional arguments but 1 was given.
Writing items = [] in __init__ without self.
Without self. it's a local variable that disappears. Later you'll see AttributeError: 'Order' object has no attribute 'items'.
Writing the constructor with single underscores: _init_
It's two on each side: __init__. Otherwise Python says TypeError: Order() takes no arguments.
Changing internal attributes from outside: o1.status = "delivered".
Use the class's methods (advance()), which enforce the business rules.
Practice exercise: a shopping cart
Create a Product class (with @dataclass: name and price) and a Cart class with these methods:
add(product, quantity=1)total(), which returns the totalapply_discount(percent), which raisesValueErrorif the percent isn't between 0 and 50__str__, which shows the cart's details
See solution
from dataclasses import dataclass
@dataclass
class Product:
name: str
price: float
class Cart:
def __init__(self):
self._items = [] # list of (product, quantity)
self._discount = 0
def add(self, product, quantity=1):
self._items.append((product, quantity))
def apply_discount(self, percent):
if not 0 <= percent <= 50:
raise ValueError("Invalid discount")
self._discount = percent
def total(self):
gross = sum(p.price * q for p, q in self._items)
return round(gross * (1 - self._discount / 100), 2)
def __str__(self):
lines = [f"{q} x {p.name}: ${p.price * q:.2f}" for p, q in self._items]
lines.append(f"TOTAL: ${self.total():.2f}")
return "\n".join(lines)
cart = Cart()
cart.add(Product("Keyboard", 25.0), 2)
cart.add(Product("Mouse", 12.5))
cart.apply_discount(10)
print(cart)
2 x Keyboard: $50.00
1 x Mouse: $12.50
TOTAL: $56.25
$62.50 minus 10% is $56.25. Calling cart.apply_discount(80) raises ValueError: Invalid discount.
Summary
- A class is a blueprint; an object is a specific instance with its own data.
__init__sets up the object,selfis the object itself and__str__controls how it's displayed.- Use
_attributeand@propertyto protect data, andraiseto signal errors. - Use inheritance for "is a kind of" relationships and
@dataclassfor classes that mostly hold data. - Create mutable attributes like lists inside
__init__so each object gets its own.
Next lesson: Python Modules, Packages, pip and Virtual Environments. You'll organize projects like a pro: your own modules, external libraries with pip and one virtual environment per project.
Create your account in 30 seconds, mark your lessons and download your certificate at the end.