> "Design is more the art of preserving changeability than it is the act of achieving perfection."
Prerequisites
- 6
- 9
- 12
Learning Objectives
- Define classes with attributes and methods
- Explain the relationship between classes and objects (instances)
- Use __init__ to initialize object state
- Implement special methods (__str__, __repr__, __eq__)
- Distinguish between instance attributes, class attributes, and methods
- Apply encapsulation using naming conventions (public vs. private)
- Recognize when OOP is the right approach (and when it isn't)
In This Chapter
- Chapter Overview
- 14.1 Why OOP? Managing Complexity
- 14.2 Classes and Objects
- 14.3 Defining a Class
- 14.4 Attributes: What Objects Know
- 14.5 Methods: What Objects Can Do
- 14.6 Special Methods (Dunders)
- 14.7 Encapsulation
- 14.8 When to Use OOP
- 14.9 Common OOP Pitfalls
- 14.10 Project Checkpoint: TaskFlow v1.3 — The OOP Refactor
- Spaced Review
- Chapter Summary
Chapter 14: Object-Oriented Programming: Thinking in Objects
"Design is more the art of preserving changeability than it is the act of achieving perfection." — Sandi Metz, Practical Object-Oriented Design
Chapter Overview
Up to this point, you've built programs by writing functions that operate on data stored in variables, lists, and dictionaries. That approach works — you've built a working task manager, a grade calculator, and the beginnings of a text adventure. But as programs grow, something uncomfortable happens: the data lives in one place, the functions that operate on it live in another, and you spend increasing amounts of time making sure the right functions get called with the right data. Miss a parameter, pass the wrong dictionary, and suddenly your task manager is trying to delete a student's GPA.
Object-oriented programming (OOP) solves this problem by bundling data and the functions that operate on it into a single unit called an object. Instead of a dictionary holding task data and a separate function called complete_task(task_dict), you have a Task object that knows its own title, priority, and due date — and knows how to complete itself: my_task.complete().
This chapter is a paradigm shift. Not a small one. It will change how you think about organizing programs, and once it clicks, you won't want to go back.
In this chapter, you will learn to:
- Define classes and create objects (instances) from them
- Give objects state (attributes) and behavior (methods)
- Use __init__ to set up new objects properly
- Implement special methods so objects work with print(), ==, and sorted()
- Protect internal state with encapsulation and @property
- Decide when OOP is the right tool and when it isn't
🏃 Fast Track: If you have experience with classes in another language, skim 14.1-14.3 and focus on 14.6 (Python's special methods), 14.7 (encapsulation the Python way), and 14.10 (TaskFlow refactor).
🔬 Deep Dive: After this chapter, Chapter 15 covers inheritance and polymorphism, and Chapter 16 dives into design patterns and principles. The OOP trilogy builds on itself.
14.1 Why OOP? Managing Complexity
Let's start with why OOP exists, because nobody invents a new programming paradigm for fun.
The Procedural Pain Point
Here's a simplified version of what our grade calculator might look like using just functions and dictionaries (the procedural approach we've used so far):
# Procedural approach: data and functions are separate
def create_student(name, student_id):
return {"name": name, "id": student_id, "grades": []}
def add_grade(student, course, score):
student["grades"].append({"course": course, "score": score})
def calculate_gpa(student):
if not student["grades"]:
return 0.0
total = sum(g["score"] for g in student["grades"])
return total / len(student["grades"])
def display_student(student):
gpa = calculate_gpa(student)
print(f"{student['name']} (ID: {student['id']}) — GPA: {gpa:.2f}")
# Using it
alice = create_student("Alice Chen", "S001")
add_grade(alice, "CS101", 92)
add_grade(alice, "MATH201", 88)
display_student(alice)
This works. But notice the problems lurking:
- Every function needs
studentas the first argument. You're constantly passing the same dictionary around. - Nothing owns the rules. Any code anywhere can reach into the dictionary and write
alice["grades"] = None, or swap the arguments inadd_grade(alice, 150, "CS101"). Python won't complain at that line. The problem shows up much later, far from where it started, as a crash or a silently wrong GPA. There's no single place where "what counts as a valid student" lives. - The data structure is fragile. If you change the dictionary keys (say, rename
"grades"to"scores"), you have to find and update every function that accesses that key. - Related code is scattered. The functions that work with students live wherever you put them. As your program grows to handle courses, semesters, and transcripts, things get tangled fast.
The OOP Solution: Bundle State and Behavior
Object-oriented programming says: stop treating data and behavior as separate things. Bundle them together. A student isn't a dictionary that gets passed to functions — a student is an object that knows its own name and grades and can calculate its own GPA.
# OOP approach: data and behavior live together
class Student:
def __init__(self, name, student_id):
self.name = name
self.student_id = student_id
self.grades = []
def add_grade(self, course, score):
self.grades.append({"course": course, "score": score})
def calculate_gpa(self):
if not self.grades:
return 0.0
total = sum(g["score"] for g in self.grades)
return total / len(self.grades)
def display(self):
print(f"{self.name} (ID: {self.student_id}) — GPA: {self.calculate_gpa():.2f}")
# Using it
alice = Student("Alice Chen", "S001")
alice.add_grade("CS101", 92)
alice.add_grade("MATH201", 88)
alice.display()
Output:
Alice Chen (ID: S001) — GPA: 90.00
Look at what changed. The data (name, student_id, grades) and the functions that operate on it (add_grade, calculate_gpa, display) all live inside the same Student class. You don't pass the student dictionary around — the object carries its own data with it. When you call alice.add_grade("CS101", 92), the object already knows who "alice" is. And because every change to Alice's grades now goes through her own methods, the class is the one place where rules belong: if you later want add_grade to reject a score of 150, you add that check once, inside the class, and every caller gets it. (Section 14.7 shows how to go further and discourage outside code from bypassing the methods.)
(About the name: calculate_gpa() really returns the average score on a 0–100 scale, not a 4.0-scale GPA. We're keeping the grade calculator's familiar name here so you can focus on the structure, not the arithmetic.)
🔗 Connection to Chapter 9: In Chapter 9, a dictionary worked like the book kind of dictionary: you look up a key and get a value. A class goes one step further. It's a lookup structure that comes with its own assistant who knows how to organize the entries, check them, and generate reports. Dictionaries store data. Objects store data and know what to do with it.
Three Reasons to Use OOP
-
Modeling real-world things. Students, bank accounts, game characters, tasks, rooms, inventory items — these are naturally described as objects with properties and behaviors.
-
Managing complexity. When your program has 50 functions and 20 data structures, OOP helps you organize them into coherent units. Each class handles its own responsibilities.
-
Reusability. Once you define a
Studentclass, you can create thousands of student objects from it. If you need to change how GPAs are calculated, you change it in one place.
14.2 Classes and Objects
Time for precise terminology.
A class is a blueprint — a template that describes what kind of data an object will hold and what operations it can perform. It's like an architectural plan for a house: the plan isn't a house, but you can build many houses from the same plan.
An object (also called an instance) is a specific thing created from that blueprint. If Student is the class, then alice is an object — one particular student with a particular name, ID, and list of grades.
You can create as many objects from a single class as you want, and each one is independent:
alice = Student("Alice Chen", "S001")
bob = Student("Bob Martinez", "S002")
carol = Student("Carol Washington", "S003")
Three objects, one class. Each has its own name, student_id, and grades list. Changing Alice's grades doesn't affect Bob's.
Analogy: Think of a cookie cutter (the class) and cookies (the objects). The cookie cutter defines the shape, but each cookie is its own separate thing. You can decorate them differently, eat one without affecting the others, and make as many as you want from the same cutter.
🔄 Check Your Understanding #1: If you have a
Carclass and create three car objects — a red sedan, a blue truck, and a white SUV — which is the class and which are the instances? How many classes are there? How many objects?
Answer
Caris the single class (the blueprint). The red sedan, blue truck, and white SUV are three separate instances (objects). One class, three objects.
14.3 Defining a Class
Let's build a class from scratch. We'll create a Room for the Crypts of Pythonia text adventure.
Basic Syntax
class Room:
"""A room in a text adventure game."""
def __init__(self, name, description):
self.name = name
self.description = description
self.items = []
self.exits = {}
def describe(self):
print(f"\n--- {self.name} ---")
print(self.description)
if self.items:
print(f"You see: {', '.join(self.items)}")
if self.exits:
print(f"Exits: {', '.join(self.exits.keys())}")
def add_item(self, item):
self.items.append(item)
def add_exit(self, direction, room):
self.exits[direction] = room
Let's break this down piece by piece.
The class Statement
class Room:
This creates a new class called Room. By convention (PEP 8 calls it "CapWords"), class names use CamelCase (capitalize each word, no underscores) — Room, Student, BankAccount, TaskList. This is different from function names, which use snake_case. The distinction helps you immediately see whether something is a class or a function.
The __init__ Method (Constructor)
def __init__(self, name, description):
self.name = name
self.description = description
self.items = []
self.exits = {}
The __init__ method is the constructor — it runs automatically every time you create a new object from this class. Its job is to set up the object's initial state.
The name __init__ (pronounced "dunder init") is special — Python looks for this exact name. The double underscores signal "this is a Python-internal special method." You'll see more of these in Section 14.6.
The self parameter is the single most important thing to understand in this chapter. When you write room1 = Room("Entrance Hall", "A dusty foyer..."), Python does two things behind the scenes:
- Creates a new, empty
Roomobject in memory. - Calls
Room.__init__(that_new_object, "Entrance Hall", "A dusty foyer...").
The self parameter receives a reference to the object being created. When you write self.name = name, you're saying: "on this particular object, create an attribute called name and store the value." Every method in a class gets self as its first parameter — it's how the method knows which object it's working with.
The same thing happens with every method call, not just creation. These two lines do exactly the same thing:
room1.describe() # what you normally write
Room.describe(room1) # what Python effectively does
Python finds describe on the Room class and passes room1 in as self. That's the whole trick.
(A note on vocabulary: strictly speaking, step 1 above, creating the empty object, is what constructs it, and __init__ only initializes it. Most Python programmers still call __init__ "the constructor," and so will we.)
💡 Key Insight:
selfisn't a keyword — it's a convention. You could technically call itthisormeorpotato. But don't. Every Python programmer usesself, and breaking that convention will confuse everyone, including you in six months. Useself. Always.
Creating Objects
entrance = Room("Entrance Hall", "A dusty foyer with cobwebs.")
dungeon = Room("Dungeon", "A damp, dark cell. Water drips from the ceiling.")
entrance.add_item("rusty key")
entrance.add_item("torch")
entrance.add_exit("north", dungeon)
dungeon.add_exit("south", entrance)
entrance.describe()
dungeon.describe()
Output:
--- Entrance Hall ---
A dusty foyer with cobwebs.
You see: rusty key, torch
Exits: north
--- Dungeon ---
A damp, dark cell. Water drips from the ceiling.
Exits: south
Each Room() call creates a new instance with its own name, description, items, and exits.
📊 Memory Model: Remember from Chapter 3 that variables are name tags, not boxes. Here there are two
Roomobjects in memory, and the namesentranceanddungeonare tags stuck on them. The first object holdsname: "Entrance Hall",description: "A dusty foyer...",items: ["rusty key", "torch"], andexits: {"north": <the dungeon object>}. The second object has its own completely separate set of attributes. Changingentrance.itemshas no effect ondungeon.items. Notice, though, thatentrance.exits["north"]isn't a copy of the dungeon. It's another tag on the same dungeon object, exactly the aliasing you saw with lists in Chapter 8.
14.4 Attributes: What Objects Know
Attributes are the data that an object carries with it. There are two kinds, and the distinction matters.
Instance Attributes
Instance attributes belong to a specific object. They're created inside __init__ using self.attribute_name = value. Each object gets its own copy.
class Dog:
def __init__(self, name, breed):
self.name = name # instance attribute
self.breed = breed # instance attribute
self.tricks = [] # instance attribute (starts empty)
rex = Dog("Rex", "German Shepherd")
bella = Dog("Bella", "Golden Retriever")
rex.tricks.append("shake")
print(rex.tricks) # ['shake']
print(bella.tricks) # [] — Bella has her own list
Output:
['shake']
[]
Each dog has its own tricks list. Teaching Rex a trick doesn't teach Bella.
Class Attributes
Class attributes are shared by all instances of a class. They're defined directly in the class body, outside any method.
class Dog:
species = "Canis familiaris" # class attribute — same for all dogs
def __init__(self, name, breed):
self.name = name # instance attribute
self.breed = breed # instance attribute
rex = Dog("Rex", "German Shepherd")
bella = Dog("Bella", "Golden Retriever")
print(rex.species) # Canis familiaris
print(bella.species) # Canis familiaris
print(Dog.species) # Canis familiaris — accessible on the class too
Output:
Canis familiaris
Canis familiaris
Canis familiaris
Class attributes are useful for constants shared by all instances, or for tracking information about the class as a whole:
class Student:
total_students = 0 # class attribute — shared counter
def __init__(self, name):
self.name = name
Student.total_students += 1 # increment the shared counter
alice = Student("Alice")
bob = Student("Bob")
print(Student.total_students) # 2
Output:
2
Accessing Attributes with Dot Notation
You access both kinds of attributes with dot notation: object.attribute.
print(rex.name) # Rex — instance attribute
print(rex.species) # Canis familiaris — class attribute
Python looks for the attribute on the instance first. If it doesn't find it there, it looks on the class. This is why rex.species works even though species is defined on Dog, not on rex.
That lookup order has a consequence for assignment. Writing rex.species = "Canis lupus" doesn't change the class attribute. It creates a new instance attribute on rex that hides the class one:
rex.species = "Canis lupus"
print(rex.species) # Canis lupus — rex's own instance attribute
print(bella.species) # Canis familiaris — still reading the class attribute
print(Dog.species) # Canis familiaris — the class attribute is unchanged
Reading falls back to the class; assigning through an instance writes to the instance. (Properties, in Section 14.7, are the one exception you'll meet in this chapter.)
🔄 Check Your Understanding #2: In the
Studentclass above, why do we writeStudent.total_students += 1inside__init__rather thanself.total_students += 1? What would happen if we usedself?
Answer
Student.total_students += 1modifies the class attribute directly. If we wroteself.total_students += 1, Python would expand it toself.total_students = self.total_students + 1: it reads the class attribute (0), adds 1, then assigns the result to a new instance attribute on that specific object. The class attribute would stay at 0 no matter how many students you create, soStudent.total_studentswould print0instead of2. Always modify class attributes through the class name, not throughself.
14.5 Methods: What Objects Can Do
Methods are functions defined inside a class. They describe the behaviors an object can perform.
Defining Methods
Every method takes self as its first parameter. When you call the method on an object, Python automatically passes the object as self:
class Player:
"""A player character in a text adventure."""
def __init__(self, name):
self.name = name
self.inventory = []
self.health = 100
self.location = None
def pick_up(self, item):
"""Add an item to the player's inventory."""
self.inventory.append(item)
print(f"{self.name} picks up the {item}.")
def drop(self, item):
"""Remove an item from the player's inventory."""
if item in self.inventory:
self.inventory.remove(item)
print(f"{self.name} drops the {item}.")
else:
print(f"{self.name} doesn't have a {item}.")
def show_inventory(self):
"""Display what the player is carrying."""
if not self.inventory:
print(f"{self.name} is carrying nothing.")
else:
print(f"{self.name}'s inventory: {', '.join(self.inventory)}")
def take_damage(self, amount):
"""Reduce health by the given amount."""
self.health = max(0, self.health - amount)
print(f"{self.name} takes {amount} damage! Health: {self.health}/100")
if self.health == 0:
print(f"{self.name} has been defeated!")
# Using it
hero = Player("Aria")
hero.pick_up("rusty key")
hero.pick_up("torch")
hero.show_inventory()
hero.take_damage(30)
hero.take_damage(80)
Output:
Aria picks up the rusty key.
Aria picks up the torch.
Aria's inventory: rusty key, torch
Aria takes 30 damage! Health: 70/100
Aria takes 80 damage! Health: 0/100
Aria has been defeated!
Methods Can Call Other Methods
Methods within the same class can call each other using self:
class GradeBook:
"""A gradebook that stores and analyzes student grades."""
def __init__(self, course_name):
self.course_name = course_name
self.grades = {} # {student_name: [scores]}
def add_grade(self, student, score):
if student not in self.grades:
self.grades[student] = []
self.grades[student].append(score)
def get_average(self, student):
if student not in self.grades or not self.grades[student]:
return 0.0
scores = self.grades[student]
return sum(scores) / len(scores)
def get_class_average(self):
if not self.grades:
return 0.0
# This method calls self.get_average() for each student
averages = [self.get_average(s) for s in self.grades]
return sum(averages) / len(averages)
def report(self):
print(f"\n=== {self.course_name} Grade Report ===")
for student in sorted(self.grades):
avg = self.get_average(student)
print(f" {student}: {avg:.1f}")
print(f" Class average: {self.get_class_average():.1f}")
# Using it
cs101 = GradeBook("CS 101")
cs101.add_grade("Alice", 92)
cs101.add_grade("Alice", 88)
cs101.add_grade("Bob", 78)
cs101.add_grade("Bob", 85)
cs101.add_grade("Carol", 95)
cs101.add_grade("Carol", 91)
cs101.report()
Output:
=== CS 101 Grade Report ===
Alice: 90.0
Bob: 81.5
Carol: 93.0
Class average: 88.2
Notice how get_class_average() calls self.get_average() — methods can use self to access both attributes and other methods.
🔗 Connection to Chapter 6: Methods are just functions that live inside a class and receive
selfas their first parameter. Everything you learned about functions — parameters, return values, docstrings, default arguments — still applies. OOP doesn't replace functions. It gives them a home.
14.6 Special Methods (Dunders)
Python has a set of special methods — also called dunder methods (short for "double underscore") — that let your objects integrate with Python's built-in operations. You've already seen __init__. Here are the most important ones.
__str__: Human-Readable Display
When you call print() on an object or use str(), Python calls the object's __str__ method:
class Task:
def __init__(self, title, priority="medium"):
self.title = title
self.priority = priority
self.completed = False
def __str__(self):
status = "done" if self.completed else "todo"
return f"[{status}] {self.title} ({self.priority})"
task = Task("Write chapter 14", "high")
print(task) # Calls task.__str__() automatically
Output:
[todo] Write chapter 14 (high)
Without __str__, print(task) would show something ugly like <__main__.Task object at 0x7f3b2c4d5e90>. Always define __str__ for classes you'll want to display.
__repr__: Developer-Friendly Representation
__repr__ is for developers and debugging. The convention is to return a string that, ideally, could recreate the object:
class Task:
def __init__(self, title, priority="medium"):
self.title = title
self.priority = priority
self.completed = False
def __str__(self):
status = "done" if self.completed else "todo"
return f"[{status}] {self.title} ({self.priority})"
def __repr__(self):
return f"Task(title={self.title!r}, priority={self.priority!r})"
task = Task("Write chapter 14", "high")
print(str(task)) # [todo] Write chapter 14 (high)
print(repr(task)) # Task(title='Write chapter 14', priority='high')
Output:
[todo] Write chapter 14 (high)
Task(title='Write chapter 14', priority='high')
When you inspect an object in the REPL or see it in a list, Python uses __repr__. When you explicitly print() it, Python uses __str__ (falling back to __repr__ if __str__ isn't defined).
__eq__: Equality Comparison
By default, == checks whether two variables refer to the same object in memory (identity), not whether two objects have the same data (equality). To make == compare values, define __eq__:
class Task:
def __init__(self, title, priority="medium"):
self.title = title
self.priority = priority
self.completed = False
def __eq__(self, other):
if not isinstance(other, Task):
return NotImplemented
return self.title == other.title and self.priority == other.priority
def __str__(self):
status = "done" if self.completed else "todo"
return f"[{status}] {self.title} ({self.priority})"
task1 = Task("Buy groceries", "high")
task2 = Task("Buy groceries", "high")
task3 = Task("Buy groceries", "low")
print(task1 == task2) # True — same title and priority
print(task1 == task3) # False — different priority
Output:
True
False
The isinstance check and NotImplemented return are important. Without them, task1 == 42 would crash with AttributeError: 'int' object has no attribute 'title'. Returning NotImplemented (a special built-in value; note that you return it, you don't raise it) tells Python "I don't know how to compare with that." Python then gives the other object a chance, and if neither side knows how, == falls back to an identity check and simply returns False.
⚠️ Side effect: As soon as a class defines
__eq__without also defining__hash__, Python makes its instances unhashable:{task1}or{task1: "note"}raisesTypeError: unhashable type: 'Task'. Python does this on purpose, because objects that compare equal must have equal hashes, and a mutable object's data can change after it's been stored. For now, keep objects with__eq__in lists rather than sets or dictionary keys. (The frozen dataclasses you'll meet in Chapter 16 are one tidy way around this.)
__lt__: Sorting Support
Define __lt__ (less than) to make your objects sortable with sorted() and .sort():
class Task:
PRIORITY_ORDER = {"high": 0, "medium": 1, "low": 2}
def __init__(self, title, priority="medium"):
self.title = title
self.priority = priority
def __lt__(self, other):
if not isinstance(other, Task):
return NotImplemented
return self.PRIORITY_ORDER[self.priority] < self.PRIORITY_ORDER[other.priority]
def __str__(self):
return f"{self.title} ({self.priority})"
tasks = [
Task("Laundry", "low"),
Task("Study for exam", "high"),
Task("Reply to email", "medium"),
]
for task in sorted(tasks):
print(task)
Output:
Study for exam (high)
Reply to email (medium)
Laundry (low)
sorted() only ever asks "is this one less than that one?", so __lt__ is all it needs. Tasks with the same priority aren't less than each other in either direction, so they stay in their original order (Python's sort is stable).
🔄 Check Your Understanding #3: If you define
__str__but not__repr__, what happens when you type the object's name in the REPL? What about the reverse — if you define__repr__but not__str__?
Answer
If you define only
__str__, the REPL shows the default<ClassName object at 0x...>because the REPL uses__repr__. If you define only__repr__, both the REPL display andprint()use__repr__, because__str__falls back to__repr__when__str__isn't defined. Best practice: always define both, or at minimum define__repr__.
14.7 Encapsulation
Encapsulation means hiding the internal details of how an object works and exposing only what users of the object need. Think of a car: you interact with the steering wheel, pedals, and gear shift. You don't directly manipulate the fuel injectors, the transmission gears, or the brake hydraulics. The car encapsulates its internal mechanisms behind a clean interface.
Python's Naming Conventions
Python doesn't have private or protected keywords like Java or C++. Instead, it uses naming conventions that signal intent:
| Convention | Meaning | Example |
|---|---|---|
name |
Public — use freely | self.title |
_name |
Internal (often called "protected") — "don't touch unless you know what you're doing" | self._cache |
__name |
Name-mangled — strongly discourages external access | self.__balance |
class BankAccount:
"""A bank account with protected balance."""
def __init__(self, owner, initial_balance=0):
self.owner = owner # public
self._transaction_log = [] # protected (internal use)
self.__balance = initial_balance # name-mangled (don't touch)
def deposit(self, amount):
if amount <= 0:
raise ValueError("Deposit amount must be positive")
self.__balance += amount
self._transaction_log.append(f"+{amount:.2f}")
def withdraw(self, amount):
if amount <= 0:
raise ValueError("Withdrawal amount must be positive")
if amount > self.__balance:
raise ValueError("Insufficient funds")
self.__balance -= amount
self._transaction_log.append(f"-{amount:.2f}")
def get_balance(self):
return self.__balance
def get_statement(self):
print(f"\n=== Statement for {self.owner} ===")
for entry in self._transaction_log:
print(f" {entry}")
print(f" Balance: ${self.__balance:.2f}")
account = BankAccount("Alice", 1000)
account.deposit(500)
account.withdraw(200)
account.get_statement()
print(f"Balance: ${account.get_balance():.2f}")
Output:
=== Statement for Alice ===
+500.00
-200.00
Balance: $1300.00
Balance: $1300.00
The __balance attribute uses name mangling — Python internally renames it to _BankAccount__balance. You can still access it via account._BankAccount__balance, but the ugly name screams "you shouldn't be doing this." The purpose is to prevent accidental access, not to enforce a security boundary.
⚠️ Important: Python's philosophy is "we're all consenting adults here." Encapsulation in Python is about communication, not enforcement. The underscore conventions tell other programmers "this is internal — use at your own risk."
The @property Decorator
What if you want attribute-like access (account.balance) but with validation or computation behind the scenes? That's what @property does:
class Temperature:
"""Temperature with Celsius storage and Fahrenheit conversion."""
def __init__(self, celsius=0):
self.celsius = celsius # goes through the setter, so it's validated too
@property
def celsius(self):
"""Get temperature in Celsius."""
return self._celsius
@celsius.setter
def celsius(self, value):
"""Set temperature in Celsius with validation."""
if value < -273.15:
raise ValueError("Temperature below absolute zero is impossible")
self._celsius = value
@property
def fahrenheit(self):
"""Get temperature in Fahrenheit (read-only computed property)."""
return self._celsius * 9 / 5 + 32
temp = Temperature(100)
print(f"{temp.celsius}°C = {temp.fahrenheit}°F")
temp.celsius = -10
print(f"{temp.celsius}°C = {temp.fahrenheit}°F")
try:
temp.celsius = -300 # Below absolute zero
except ValueError as e:
print(f"Error: {e}")
Output:
100°C = 212.0°F
-10°C = 14.0°F
Error: Temperature below absolute zero is impossible
With @property, temp.celsius looks like a simple attribute access, but it actually calls a method. The setter validates the input. The fahrenheit property is read-only — it computes its value from _celsius every time you access it, and because it has no setter, temp.fahrenheit = 50 raises an AttributeError.
Two details are easy to miss. First, the real data lives in self._celsius (with an underscore); celsius without the underscore is the property. If the setter wrote self.celsius = value, it would call itself forever. Second, __init__ assigns self.celsius, not self._celsius, so even Temperature(-500) is rejected. Validation that __init__ skips isn't really validation.
That second point is the exception promised in Section 14.4. Normally self.celsius = value would just create an instance attribute, but when the class defines a property called celsius, Python runs the property's getter or setter on every read and write of that name instead.
14.8 When to Use OOP
🚪 Threshold Concept: Objects = Bundled State + Behavior
Here's the mental shift that separates procedural thinking from object-oriented thinking:
Before (procedural): "I have data in variables and functions that operate on that data — separately. I pass the data to the functions."
After (OOP): "I have objects that KNOW things (attributes) and can DO things (methods). The data and behavior travel together."
When you look at a problem and think "this thing has properties and actions" — that's a natural fit for a class. A bank account has a balance and can process deposits. A player has health and can take damage. A task has a title and can mark itself complete.
This isn't just a code organization trick. It's a different way of modeling problems. Once you start thinking in objects, you'll see them everywhere.
OOP vs. Procedural: A Decision Framework
Not everything needs to be a class. Here's how to decide:
| Signal | Use OOP | Stay Procedural |
|---|---|---|
| Multiple related pieces of data | A class bundles them naturally | A single variable or simple tuple suffices |
| Data + behavior belong together | Methods keep them organized | Pure functions with no shared state |
| Multiple instances needed | Each object tracks its own state | One-off computation, no instances |
| Complex interactions between entities | Objects communicate through methods | Linear script, sequential steps |
| State changes over time | Object maintains its own state | Input-output transformation (no state) |
Use OOP when: You're modeling "things" — entities with identity, state, and behavior. Think nouns: Student, BankAccount, Room, Task, Dog.
Stay procedural when: You're writing utility functions, one-off scripts, data transformations, or mathematical computations. Think verbs: calculate_average(), parse_csv(), convert_temperature().
The hybrid approach: Most real Python programs use both. You define classes for the core domain objects and use standalone functions for utility operations. This is perfectly normal and encouraged.
🧩 Productive Struggle: Before reading Section 14.9, try this exercise: Design a bank account class that's more complete than the small one in Section 14.7 (think transfers, frozen accounts, account numbers). Don't write code yet — just answer these questions on paper:
- What should a bank account know? (List the attributes.)
- What should a bank account do? (List the methods.)
- What should be public and what should be hidden?
- What could go wrong if the balance were directly accessible?
Spend 5-10 minutes on this before continuing. There's no single right answer — the point is to practice thinking in objects. When you're done, compare your design with one possible answer in
code/example-04-bank-account.py.
14.9 Common OOP Pitfalls
Pitfall 1: Forgetting self
The most common beginner mistake. If you forget self as the first parameter, or forget to use self. when accessing attributes, things break in confusing ways.
🐛 Debugging Walkthrough: The Missing
self```python class Counter: def init(self): self.count = 0
def increment(): # BUG: missing self parameter count += 1 # BUG: should be self.countc = Counter() c.increment() ```
What you see:
TypeError: Counter.increment() takes 0 positional arguments but 1 was givenWhy it happens: When you call
c.increment(), Python automatically passescas the first argument. Butincrement()has no parameters to receive it.Fixing only the signature isn't enough. With
def increment(self):but the body still sayingcount += 1, you'd getUnboundLocalError: cannot access local variable 'count' where it is not associated with a value. Inside a method, a barecountis just a local variable, exactly as in any function from Chapter 6. The object's attribute is reachable only throughself.The fix:
python def increment(self): # Add self parameter self.count += 1 # Use self.count, not just countRule of thumb: Every method in a class takes
selfas its first parameter. Every attribute access inside a method usesself.attribute_name. No exceptions (well, except methods marked@staticmethodor@classmethod; you'll meet@classmethodin Section 14.10).
Pitfall 2: Mutable Class Attributes
This one bites even experienced programmers.
🐛 Debugging Walkthrough: The Shared List
```python class Student: grades = [] # Class attribute — shared by ALL students!
def __init__(self, name): self.name = name def add_grade(self, score): self.grades.append(score)alice = Student("Alice") bob = Student("Bob")
alice.add_grade(95) bob.add_grade(72)
print(f"Alice's grades: {alice.grades}") print(f"Bob's grades: {bob.grades}") ```
What you expect:
Alice's grades: [95] Bob's grades: [72]What you get:
Alice's grades: [95, 72] Bob's grades: [95, 72]Why it happens:
grades = []is a class attribute. There's one list shared by allStudentinstances. When Alice appends to it, Bob sees the change too — because it's the same list.The fix: Initialize mutable attributes (lists, dicts, sets) inside
__init__:
python class Student: def __init__(self, name): self.name = name self.grades = [] # Instance attribute — each student gets their ownRule of thumb: If each object needs its own list, dict, or set, create it in
__init__. Keep class attributes to things that never change: numbers, strings, tuples, or a lookup table that nothing ever modifies, like thePRIORITY_ORDERdictionary in Section 14.6. The bug only bites when code changes the shared object.
Pitfall 3: God Classes
A "god class" tries to do everything. It has 50 methods, 30 attributes, and handles database access, user input, business logic, and email sending all at once.
# BAD: God class
class TaskApp:
def __init__(self):
self.tasks = []
self.db_connection = None
self.user_preferences = {}
# ... 20 more attributes
def add_task(self, title): ...
def delete_task(self, index): ...
def save_to_database(self): ...
def load_from_database(self): ...
def display_menu(self): ...
def get_user_input(self): ...
def send_email_notification(self): ...
def format_report(self): ...
def export_to_csv(self): ...
# ... 40 more methods
# BETTER: Split into focused classes
class Task:
"""Represents a single task."""
...
class TaskList:
"""Manages a collection of tasks."""
...
class TaskStorage:
"""Handles saving/loading tasks."""
...
class TaskCLI:
"""Handles user interaction."""
...
Each class has a single clear responsibility. This makes code easier to understand, test, and modify. You'll explore this principle in depth in Chapter 16.
🔗 Connection to Chapter 12: This mirrors the module decomposition you did in Chapter 12. There, you split a large script into
models.py,storage.py, andcli.py. Now you're splitting a large class into focused classes. The principle is the same: each unit should have one clear job.
14.10 Project Checkpoint: TaskFlow v1.3 — The OOP Refactor
Time to put it all together. We're going to refactor TaskFlow from its procedural architecture (functions operating on dictionaries) into an object-oriented design with three classes: Task, TaskList, and TaskStorage.
This is the same functionality from previous versions, reorganized using the OOP concepts from this chapter. The listings below are the three classes from code/project-checkpoint.py, with their docstrings shortened. To keep the spotlight on the classes, this checkpoint drives them from a short script (see "Using the OOP TaskFlow" below) rather than through the v1.1 menu. In your multi-module project, Task and TaskList replace the functions in models.py, TaskStorage replaces the ones in storage.py, and main.py and cli.py keep the same seven-option menu, now calling methods such as task_list.add() and storage.save().
The Task Class
import json
from datetime import datetime
from pathlib import Path
class Task:
"""A single task in the TaskFlow system."""
VALID_PRIORITIES = ("high", "medium", "low")
PRIORITY_ORDER = {"high": 0, "medium": 1, "low": 2}
def __init__(self, title, priority="medium", category="general",
completed=False, created_at=None):
if not title.strip():
raise ValueError("Task title cannot be empty")
if priority not in self.VALID_PRIORITIES:
raise ValueError(
f"Priority must be one of: {', '.join(self.VALID_PRIORITIES)}"
)
self.title = title.strip()
self.priority = priority
self.category = category
self.completed = completed
self.created_at = created_at or datetime.now().isoformat()
def complete(self):
"""Mark the task as completed."""
self.completed = True
def reopen(self):
"""Mark a completed task as not completed."""
self.completed = False
def to_dict(self):
"""Convert to a dictionary for JSON serialization."""
return {
"title": self.title,
"priority": self.priority,
"category": self.category,
"completed": self.completed,
"created_at": self.created_at,
}
@classmethod
def from_dict(cls, data):
"""Create a Task from a dictionary (e.g., loaded from JSON).
Also accepts the older "done"/"created" keys from v1.2 files.
"""
return cls(
title=data["title"],
priority=data.get("priority", "medium"),
category=data.get("category", "general"),
completed=data.get("completed", data.get("done", False)),
created_at=data.get("created_at", data.get("created")),
)
def __str__(self):
status = "x" if self.completed else " "
return f"[{status}] {self.title} ({self.priority})"
def __repr__(self):
return (f"Task(title={self.title!r}, priority={self.priority!r}, "
f"completed={self.completed!r})")
def __eq__(self, other):
if not isinstance(other, Task):
return NotImplemented
return self.title == other.title and self.priority == other.priority
def __lt__(self, other):
"""Sort by priority (high first), then by title."""
if not isinstance(other, Task):
return NotImplemented
if self.PRIORITY_ORDER[self.priority] != self.PRIORITY_ORDER[other.priority]:
return self.PRIORITY_ORDER[self.priority] < self.PRIORITY_ORDER[other.priority]
return self.title.lower() < other.title.lower()
One new piece of syntax: @classmethod. A normal method receives the object as self, which means you need an object before you can call it. But from_dict exists to make a Task out of a dictionary, so there's no task yet. Marking it @classmethod makes Python pass the class itself as the first parameter, named cls by convention. You call it on the class, Task.from_dict(data), and cls(...) inside works exactly like Task(...). A class method used this way is often called an alternative constructor.
Notice also that from_dict is the one place that knows what the saved file looks like. The v1.2 files stored "done" and "created"; v1.3 calls them completed and created_at. Because from_dict checks for the old names too, your existing taskflow_data.json still loads, and no other code has to care. (TaskStorage reads and writes that same file by default, in the same folder as the code; the demo below passes it a different file name so your real tasks stay untouched.)
The TaskList Class
class TaskList:
"""A managed collection of Task objects."""
def __init__(self):
self._tasks = []
def add(self, task):
"""Add a Task to the list."""
if not isinstance(task, Task):
raise TypeError("Can only add Task objects")
self._tasks.append(task)
def delete(self, index):
"""Delete and return the task at the given index (0-based)."""
if not 0 <= index < len(self._tasks):
raise IndexError(
f"Invalid index {index}. "
f"Valid range: 0 to {len(self._tasks) - 1}"
)
return self._tasks.pop(index)
def search(self, keyword):
"""Find tasks whose title contains the keyword (case-insensitive)."""
keyword_lower = keyword.lower()
return [t for t in self._tasks if keyword_lower in t.title.lower()]
def get_by_priority(self, priority):
"""Get all tasks with the given priority level."""
return [t for t in self._tasks if t.priority == priority]
def get_by_category(self, category):
"""Get all tasks in the given category."""
return [t for t in self._tasks if t.category == category]
def pending(self):
"""Get all incomplete tasks."""
return [t for t in self._tasks if not t.completed]
def completed_tasks(self):
"""Get all completed tasks."""
return [t for t in self._tasks if t.completed]
def sorted_by_priority(self):
"""Return tasks sorted by priority (high first)."""
return sorted(self._tasks)
def display(self, show_numbers=True):
"""Print all tasks, optionally with numbers."""
if not self._tasks:
print(" (no tasks)")
return
for i, task in enumerate(self._tasks):
if show_numbers:
print(f" {i + 1}. {task}")
else:
print(f" {task}")
@property
def tasks(self):
"""Read-only access to the task list."""
return list(self._tasks) # Return a copy
def __len__(self):
return len(self._tasks)
def __getitem__(self, index):
return self._tasks[index]
def __iter__(self):
return iter(self._tasks)
def __contains__(self, task):
return task in self._tasks
def __str__(self):
total = len(self._tasks)
done = len(self.completed_tasks())
return f"TaskList: {total} tasks ({done} completed, {total - done} pending)"
The TaskStorage Class
# The same data file every TaskFlow version since v0.9 has used,
# in the same folder as the code
DATA_FILE = Path(__file__).parent / "taskflow_data.json"
class TaskStorage:
"""Handles saving and loading TaskList data to/from JSON files."""
def __init__(self, filepath=DATA_FILE):
self.filepath = filepath
def save(self, task_list):
"""Save a TaskList to a JSON file."""
data = [task.to_dict() for task in task_list]
with open(self.filepath, "w", encoding="utf-8") as f:
json.dump(data, f, indent=2)
return len(data)
def load(self):
"""Load tasks from a JSON file and return a TaskList."""
task_list = TaskList()
try:
with open(self.filepath, "r", encoding="utf-8") as f:
data = json.load(f)
for item in data:
task_list.add(Task.from_dict(item))
except FileNotFoundError:
pass # No file yet — return empty list
except json.JSONDecodeError:
print(f" Warning: {self.filepath} is corrupted. Starting fresh.")
return task_list
def __str__(self):
return f"TaskStorage(filepath={self.filepath!r})"
Using the OOP TaskFlow
# Create storage and load existing tasks
storage = TaskStorage("my_tasks.json")
task_list = storage.load()
# Add some tasks
task_list.add(Task("Read chapter 14", "high"))
task_list.add(Task("Do exercises", "medium"))
task_list.add(Task("Start project", "low"))
# Display all tasks
print("All tasks:")
task_list.display()
# Complete a task
task_list[0].complete()
print("\nAfter completing first task:")
task_list.display()
# Search
print("\nSearch for 'chapter':")
results = task_list.search("chapter")
for task in results:
print(f" Found: {task}")
# Show pending tasks
print(f"\nPending tasks: {len(task_list.pending())}")
# Save
storage.save(task_list)
print(f"\nSaved {len(task_list)} tasks to {storage.filepath}")
Output (on the first run, before my_tasks.json exists; run it again and the three saved tasks load first, so you'll see six):
All tasks:
1. [ ] Read chapter 14 (high)
2. [ ] Do exercises (medium)
3. [ ] Start project (low)
After completing first task:
1. [x] Read chapter 14 (high)
2. [ ] Do exercises (medium)
3. [ ] Start project (low)
Search for 'chapter':
Found: [x] Read chapter 14 (high)
Pending tasks: 2
Saved 3 tasks to my_tasks.json
What Changed from v1.2?
Compare the procedural version (v1.2) to the OOP version (v1.3):
| Aspect | v1.2 (Procedural) | v1.3 (OOP) |
|---|---|---|
| Task data | Dictionary {"title": ..., "priority": ...} |
Task object with attributes |
| Task operations | Standalone functions: complete_task(task) |
Methods: task.complete() |
| Task display | print_task(task, index) function |
print(task) via __str__ |
| Task comparison | == compares every key, timestamps included |
task1 == task2 via __eq__, on the fields you choose |
| Task collection | Plain list + helper functions | TaskList with search, pending, etc. |
| File I/O | load_tasks()/save_tasks() functions in storage.py |
TaskStorage class with save/load |
| File format | Every function that touches a task knows the dict keys | Only to_dict() and from_dict() know the file format |
The OOP version isn't shorter — it's actually about the same length. But it's organized differently. Each class has a clear responsibility, and the code that uses these classes reads almost like English: "task list, add a task. Storage, save the task list."
🔗 Connection to Chapter 13: In Chapter 13 you wrote tests for TaskFlow v1.2. Those tests still work conceptually, but they'd need updating for the new class-based API. In Chapter 15, you'll add
DeadlineTask,RecurringTask, andChecklistTaskas subclasses ofTask. In Chapter 16, you'll refine the design further with design patterns.
Spaced Review
🔁 Spaced Review: Chapter 9 (Dicts) — Classes Are "Smart Dicts"
In Chapter 9, you stored task data in dictionaries:
{"title": "Buy milk", "priority": "high", "done": False}. That worked, but dictionaries don't do anything — they just hold data. ATaskclass is like a dictionary that comes with built-in functions: it knows how to display itself (__str__), compare itself (__eq__), and serialize itself (to_dict). When you find yourself writing lots of functions that all take the same dictionary as their first argument, that's a sign you should probably make it a class instead.🔁 Spaced Review: Chapter 12 (Modules) — Same Principle, Different Level
In Chapter 12, you split a large file into modules (models.py, storage.py, cli.py) to organize related code together. Classes do the same thing at a finer level: instead of grouping related functions in a file, you group related functions and data in an object. Modules organize files. Classes organize concepts.
🔁 Spaced Review: Chapter 13 (Testing) — Objects Are Easier to Test
In Chapter 13, you learned to test functions in isolation. Classes make this even cleaner: you create an object, call methods on it, and assert the resulting state.
task = Task("Test"); task.complete(); assert task.completed is True. Each test creates its own fresh object, so tests never interfere with each other.
Chapter Summary
Object-oriented programming organizes code around objects — entities that bundle data (attributes) and behavior (methods) into a single unit. A class is the blueprint; an object is a specific instance created from that blueprint.
Key concepts from this chapter:
__init__sets up a new object's initial state. Every attribute an object needs should be initialized here.selfrefers to the current object. Use it to access attributes and call methods within the class.- Instance attributes belong to a specific object. Class attributes are shared by all instances.
- Special methods (dunders) let objects work with Python's built-in operations:
__str__for display,__repr__for debugging,__eq__for equality,__lt__for sorting. - Encapsulation hides internal details using naming conventions:
_protectedand__name_mangled. The@propertydecorator gives attribute-like access with method-like control. - OOP is a tool, not a religion. Use it when you're modeling things with state and behavior. Stay procedural when you're writing utility functions or simple scripts.
In Chapter 15, you'll learn inheritance and polymorphism — how to build new classes on top of existing ones, and how different objects can respond to the same method call in different ways. That's where OOP really starts to show its power.
Next up: Chapter 15 — Inheritance and Polymorphism: Building on What Exists
Related Reading
Explore this topic in other books
Intro CS Python Getting Started with Python Intro CS Python Functions Python for Business Introduction to pandas AI Engineering Python for AI Engineering Intro CS Python Functions Learning COBOL Coding Standards