Chapter 17: Classes and Objects — Introduction to OOP
Everything you've built so far uses Python's built-in types — strings, lists, dictionaries, numbers. They're powerful, but they're generic. A dictionary can represent a bank account, but it can't enforce that the balance never goes negative. A list can hold students, but it can't know how to calculate their average grade.
Object-oriented programming (OOP) lets you create your own types. You define what data a type holds and what it can do — bundled together into one unit called a class. Once you have a class, you create objects (also called instances) from it, each with its own data but sharing the same behavior.
This is how almost all serious Python programs are structured. By the end of this chapter you'll understand what OOP is, why it matters, and how to write your own classes with data, methods, and useful string representations.
What is Object-Oriented Programming?
OOP is a way of organizing code around things rather than steps.
Instead of thinking "first do this, then do that," you think "what are the things in this program, and what can they do?"
A bank account is a thing. It has data (a balance, an owner name) and it can do things (deposit, withdraw, show its balance). In OOP you design a BankAccount class that captures both.
The three core ideas:
- Encapsulation — bundle data and the code that works on it together. The account knows its own balance and its own rules.
- Inheritance — build new types from existing ones. A
SavingsAccountis aBankAccountwith extra behavior. - Polymorphism — different types respond to the same operation in their own way.
This chapter covers encapsulation. Chapters 18 and 19 cover inheritance and polymorphism.
In plain English: A class is a blueprint. An object is something built from that blueprint. The blueprint for a house describes how many rooms it has and what you can do in them. The actual house you live in is an object built from that blueprint.
Your First Class
Use the class keyword, then the class name (by convention: CapitalizedWords, also called PascalCase), then a colon.
class Dog:
pass # empty class for now
That's a valid class. Create an object from it:
my_dog = Dog()
print(type(my_dog)) # <class '__main__.Dog'>
Dog() calls the class like a function to create an instance. my_dog is now a Dog object.
__init__ — The Constructor
The __init__ method runs automatically every time you create a new object. It's where you set up the object's initial data.
class Dog:
def __init__(self, name, breed, age):
self.name = name
self.breed = breed
self.age = age
Now create some dogs:
dog1 = Dog("Rex", "German Shepherd", 3)
dog2 = Dog("Bella", "Labrador", 5)
dog3 = Dog("Max", "Poodle", 1)
Each object has its own data:
print(dog1.name) # Rex
print(dog2.breed) # Labrador
print(dog3.age) # 1
dog1, dog2, and dog3 are three separate objects. They were all built from the same Dog blueprint, but each has its own name, breed, and age.
self — What It Means and Why It's There
Every method in a class receives the object itself as the first argument. By convention this is named self. Python passes it automatically — you never provide it when calling a method.
class Dog:
def __init__(self, name, breed, age):
self.name = name # self.name is an instance attribute
self.breed = breed # stored on THIS specific object
self.age = age
When you write self.name = name, you're saying: "Store the value of name on this specific instance of Dog." Every Dog object has its own self.name, its own self.breed, its own self.age.
Think of self as "me" — the object that the method belongs to.
In plain English: self is how a method says "I'm talking about this specific object, not some other one." When dog1.bark() runs, self is dog1. When dog2.bark() runs, self is dog2.
Instance Attributes vs Class Attributes
Instance attributes are set on self — each object has its own copy.
Class attributes are set directly on the class — shared by all instances.
class Dog:
species = "Canis lupus familiaris" # class attribute — shared by all dogs
def __init__(self, name, breed, age):
self.name = name # instance attribute — unique to each dog
self.breed = breed
self.age = age
dog1 = Dog("Rex", "German Shepherd", 3)
dog2 = Dog("Bella", "Labrador", 5)
print(dog1.species) # Canis lupus familiaris
print(dog2.species) # Canis lupus familiaris (same — shared)
print(Dog.species) # Canis lupus familiaris (access via class directly)
print(dog1.name) # Rex
print(dog2.name) # Bella (different — each has its own)
If you set a class attribute via an instance (dog1.species = "something"), Python creates a new instance attribute that shadows the class attribute — the class attribute itself is unchanged.
Use class attributes for data that's the same for every instance. Use instance attributes for data that varies per instance.
Methods — What Objects Can Do
A method is a function defined inside a class. It always receives self as its first parameter.
class Dog:
def __init__(self, name, breed, age):
self.name = name
self.breed = breed
self.age = age
def bark(self):
print(f"{self.name} says: Woof!")
def describe(self):
print(f"{self.name} is a {self.age}-year-old {self.breed}.")
def birthday(self):
self.age += 1
print(f"Happy birthday {self.name}! Now {self.age} years old.")
def is_puppy(self):
return self.age < 2
Call methods on objects:
dog1 = Dog("Rex", "German Shepherd", 3)
dog2 = Dog("Max", "Poodle", 1)
dog1.bark() # Rex says: Woof!
dog1.describe() # Rex is a 3-year-old German Shepherd.
dog1.birthday() # Happy birthday Rex! Now 4 years old.
print(dog2.is_puppy()) # True (age is 1, under 2)
print(dog1.is_puppy()) # False (age is now 4)
Methods can read instance attributes (self.name), modify them (self.age += 1), return values (return self.age < 2), and call other methods on self.
Your turn: Add a method human_years() to the Dog class that returns the dog's age multiplied by 7. Call it on two different dogs and print the result.
__str__ and __repr__ — Representing Objects as Text
When you print an object without defining __str__, Python shows something ugly:
dog = Dog("Rex", "German Shepherd", 3)
print(dog) # <__main__.Dog object at 0x000001A2B3C4D5E6>
Define __str__ to control what print() and f-strings show:
class Dog:
def __init__(self, name, breed, age):
self.name = name
self.breed = breed
self.age = age
def __str__(self):
return f"Dog(name={self.name!r}, breed={self.breed!r}, age={self.age})"
Now:
dog = Dog("Rex", "German Shepherd", 3)
print(dog) # Dog(name='Rex', breed='German Shepherd', age=3)
print(f"{dog}") # Dog(name='Rex', breed='German Shepherd', age=3)
__str__ vs __repr__:
__str__is for humans — the friendly, readable version. Used byprint()and f-strings.__repr__is for developers — the technical version, ideally showing how to recreate the object. Used in the REPL and byrepr().
def __repr__(self):
return f"Dog({self.name!r}, {self.breed!r}, {self.age})"
The !r format spec inside f-strings calls repr() on the value — it adds quotes around strings, so the output is unambiguous.
If you only define one, define __repr__ — Python uses it as a fallback for __str__ too.
A Complete, Realistic Class
Let's build a BankAccount class that enforces real rules:
class BankAccount:
"""A simple bank account with deposit, withdrawal, and history."""
interest_rate = 0.02 # class attribute — 2% for all accounts
def __init__(self, owner, initial_balance=0.0):
if initial_balance < 0:
raise ValueError("Initial balance cannot be negative.")
self.owner = owner
self._balance = float(initial_balance) # _balance: "private by convention"
self._history = []
self._log(f"Account opened with ${initial_balance:.2f}")
# ── Properties ──────────────────────────────────────
@property
def balance(self):
"""Read-only access to the balance."""
return self._balance
# ── Public methods ───────────────────────────────────
def deposit(self, amount):
"""Deposit a positive amount."""
if amount <= 0:
raise ValueError("Deposit amount must be positive.")
self._balance += amount
self._log(f"Deposited ${amount:.2f} -> balance ${self._balance:.2f}")
def withdraw(self, amount):
"""Withdraw amount if sufficient funds exist."""
if amount <= 0:
raise ValueError("Withdrawal amount must be positive.")
if amount > self._balance:
raise ValueError(
f"Insufficient funds. Balance: ${self._balance:.2f}, "
f"Requested: ${amount:.2f}"
)
self._balance -= amount
self._log(f"Withdrew ${amount:.2f} -> balance ${self._balance:.2f}")
def apply_interest(self):
"""Add interest to the account."""
interest = self._balance * self.interest_rate
self._balance += interest
self._log(f"Interest ${interest:.2f} -> balance ${self._balance:.2f}")
def print_history(self):
"""Print full transaction history."""
print(f"\nAccount history for {self.owner}:")
for entry in self._history:
print(f" {entry}")
# ── Dunder methods ───────────────────────────────────
def __str__(self):
return f"BankAccount({self.owner!r}, balance=${self._balance:.2f})"
def __repr__(self):
return f"BankAccount({self.owner!r}, {self._balance:.2f})"
# ── Private helper ───────────────────────────────────
def _log(self, message):
"""Record a transaction (private — not for external use)."""
self._history.append(message)
Using it:
account = BankAccount("Alice", 500.00)
account.deposit(200.00)
account.withdraw(75.50)
account.apply_interest()
account.print_history()
print(account)
print(f"Balance: ${account.balance:.2f}")
Output:
Account history for Alice:
Account opened with $500.00
Deposited $200.00 -> balance $700.00
Withdrew $75.50 -> balance $624.50
Interest $12.49 -> balance $636.99
BankAccount('Alice', balance=$636.99)
Balance: $636.99
Notice several things:
_balanceand_historystart with_— the Python convention for "don't touch this from outside the class."@propertymakesbalancereadable asaccount.balancewithout parentheses, but prevents direct assignment.- The class enforces its own rules — you can't deposit a negative amount or overdraw.
_logis a private helper — it's an implementation detail, not part of the public interface.
Your turn: Add a transfer(self, other_account, amount) method to BankAccount that withdraws from self and deposits into other_account. Handle the case where there are insufficient funds. Test it with two accounts.
Multiple Objects, Same Class
Every object created from a class is independent. Changes to one don't affect the others.
alice_account = BankAccount("Alice", 1000.00)
bob_account = BankAccount("Bob", 500.00)
alice_account.deposit(250.00)
bob_account.withdraw(100.00)
print(alice_account) # BankAccount('Alice', balance=$1250.00)
print(bob_account) # BankAccount('Bob', balance=$400.00)
Alice's deposit didn't touch Bob's balance. They're completely separate objects sharing only the class definition (the blueprint) — not the data.
Objects in Collections
Objects work perfectly inside lists, dictionaries, and anywhere else you'd use a value:
accounts = [
BankAccount("Alice", 1000.00),
BankAccount("Bob", 500.00),
BankAccount("Carlos", 2500.00),
BankAccount("Diana", 750.00),
]
# Apply interest to all accounts
for acc in accounts:
acc.apply_interest()
# Find the richest account
richest = max(accounts, key=lambda acc: acc.balance)
print(f"Richest: {richest.owner} with ${richest.balance:.2f}")
# Total deposits across all accounts
total = sum(acc.balance for acc in accounts)
print(f"Total: ${total:.2f}")
Comparing Objects
By default, two objects are only equal if they're the same object in memory:
a = Dog("Rex", "German Shepherd", 3)
b = Dog("Rex", "German Shepherd", 3)
print(a == b) # False — different objects, even with same data
print(a is b) # False
print(a == a) # True — same object
To make == compare by value, define __eq__:
class Dog:
def __init__(self, name, breed, age):
self.name = name
self.breed = breed
self.age = age
def __eq__(self, other):
if not isinstance(other, Dog):
return NotImplemented
return (self.name, self.breed, self.age) == (other.name, other.breed, other.age)
def __repr__(self):
return f"Dog({self.name!r}, {self.breed!r}, {self.age})"
Now:
a = Dog("Rex", "German Shepherd", 3)
b = Dog("Rex", "German Shepherd", 3)
c = Dog("Bella", "Labrador", 5)
print(a == b) # True — same data
print(a == c) # False — different data
You'll learn all the comparison dunder methods (__lt__, __le__, __gt__, etc.) in Chapter 19.
Putting It All Together: A Student Grade Tracker
class Student:
"""Represents a student with a name and a list of grades."""
def __init__(self, name, student_id):
self.name = name
self.student_id = student_id
self._grades = []
def add_grade(self, subject, score):
if not 0 <= score <= 100:
raise ValueError(f"Score must be between 0 and 100, got {score}.")
self._grades.append({"subject": subject, "score": score})
@property
def average(self):
if not self._grades:
return 0.0
return sum(g["score"] for g in self._grades) / len(self._grades)
@property
def highest(self):
if not self._grades:
return None
return max(self._grades, key=lambda g: g["score"])
@property
def lowest(self):
if not self._grades:
return None
return min(self._grades, key=lambda g: g["score"])
@property
def letter_grade(self):
avg = self.average
if avg >= 90: return "A"
if avg >= 80: return "B"
if avg >= 70: return "C"
if avg >= 60: return "D"
return "F"
def report(self):
print(f"\nStudent: {self.name} (ID: {self.student_id})")
print(f"{'Subject':<20} {'Score':>5}")
print("-" * 27)
for g in self._grades:
print(f"{g['subject']:<20} {g['score']:>5}")
print("-" * 27)
print(f"{'Average':<20} {self.average:>5.1f} ({self.letter_grade})")
if self.highest:
print(f"Best: {self.highest['subject']} ({self.highest['score']})")
print(f"Worst: {self.lowest['subject']} ({self.lowest['score']})")
def __str__(self):
return f"{self.name} (avg: {self.average:.1f}, grade: {self.letter_grade})"
def __repr__(self):
return f"Student({self.name!r}, {self.student_id!r})"
# Create students
alice = Student("Alice Smith", "S001")
alice.add_grade("Math", 92)
alice.add_grade("Physics", 88)
alice.add_grade("Chemistry", 76)
alice.add_grade("English", 95)
alice.add_grade("History", 84)
bob = Student("Bob Jones", "S002")
bob.add_grade("Math", 65)
bob.add_grade("Physics", 72)
bob.add_grade("Chemistry", 58)
bob.add_grade("English", 80)
bob.add_grade("History", 61)
# Print reports
alice.report()
bob.report()
# Compare and sort
students = [alice, bob]
students.sort(key=lambda s: s.average, reverse=True)
print("\nRankings:")
for rank, student in enumerate(students, start=1):
print(f" {rank}. {student}")
Output:
Student: Alice Smith (ID: S001)
Subject Score
Math 92
Physics 88
Chemistry 76
English 95
History 84
Average 87.0 (B)
Best: English (95)
Worst: Chemistry (76)
Student: Bob Jones (ID: S002)
Subject Score
Math 65
Physics 72
Chemistry 58
English 80
History 61
Average 67.2 (D)
Best: English (80)
Worst: Chemistry (58)
Rankings:
1. Alice Smith (avg: 87.0, grade: B)
2. Bob Jones (avg: 67.2, grade: D)
What You Learned in This Chapter
- A class is a blueprint. An object (instance) is built from that blueprint.
- Define classes with
class ClassName:— use PascalCase for names. __init__is the constructor — it runs automatically when an object is created.selfrefers to the current instance — Python passes it automatically.- Instance attributes (
self.x) are unique to each object. Class attributes are shared by all. - Methods are functions inside a class — always receive
selfas the first parameter. __str__controlsprint()output.__repr__controls the developer representation._name(single underscore prefix) signals "private by convention" — don't use from outside.@propertymakes a method behave like an attribute — readable without().__eq__lets you define what==means for your objects.- Objects work inside lists, dicts, and any other collection.
- Classes enforce their own rules through validation in methods.
What's Next?
You've built your first classes. The real power of OOP comes when you build classes from other classes.
In Chapter 18 you'll learn inheritance — how a SavingsAccount can be a BankAccount with extra behavior, how a GuideDog can be a Dog with extra capabilities, and how super() lets child classes build on their parent's work without repeating it. You'll also learn polymorphism — how different classes can respond to the same method call in their own way.
Your turn: Design a Rectangle class with width and height attributes. Add methods: area(), perimeter(), is_square(), and scale(factor) (which multiplies both dimensions by a factor). Add __str__ and __repr__. Then create a Square class (not using inheritance yet — just a class) that only takes one side argument and sets width = height = side. Compare a Rectangle(4, 4) and a Square(4) — do they have the same area and perimeter?