19 min read

> "The key to making programs that are easy to change is to make them easy to understand. The key to making programs easy to understand is to organize them so they mirror the world they represent."

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)

Chapter 14: Object-Oriented Programming: Thinking in Objects

"The key to making programs that are easy to change is to make them easy to understand. The key to making programs easy to understand is to organize them so they mirror the world they represent." — Adapted from 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:

  1. Every function needs student as the first argument. You're constantly passing the same dictionary around.
  2. Nothing prevents misuse. You could accidentally write add_grade(alice, 150, "CS101") — swapping the arguments — and Python wouldn't complain until something crashes later.
  3. 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.
  4. 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.

🔗 Connection to Chapter 9: Remember when we said dictionaries are like "labeled filing cabinets"? A class is like a filing cabinet that comes with a built-in assistant who knows how to organize the files, look things up, and generate reports. Dictionaries store data. Objects store data and know what to do with it.

Three Reasons to Use OOP

  1. Modeling real-world things. Students, bank accounts, game characters, tasks, rooms, inventory items — these are naturally described as objects with properties and behaviors.

  2. 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.

  3. Reusability. Once you define a Student class, 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 Car class 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

Car is 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, 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:

  1. Creates a new, empty Room object in memory.
  2. 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.

💡 Key Insight: self isn't a keyword — it's a convention. You could technically call it this or me or potato. But don't. Every Python programmer uses self, and breaking that convention will confuse everyone, including you in six months. Use self. 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: Imagine two boxes in memory, each labeled with its variable name. The entrance box contains name: "Entrance Hall", description: "A dusty foyer...", items: ["rusty key", "torch"], and exits: {"north": <reference to dungeon>}. The dungeon box has its own completely separate set of attributes. They don't share anything — changing entrance.items has no effect on dungeon.items.


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.

🔄 Check Your Understanding #2: In the Student class above, why do we write Student.total_students += 1 inside __init__ rather than self.total_students += 1? What would happen if we used self?

Answer

Student.total_students += 1 modifies the class attribute directly. If we wrote self.total_students += 1, Python would create a new instance attribute on that specific object (because += on an integer reads the class attribute and assigns a new value to the instance). The class attribute would remain unchanged, and the count would be wrong. Always modify class attributes through the class name, not through self.


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 self as 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 — they prevent crashes when comparing a Task to a non-Task value.

__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)

🔄 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 and print() 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 Protected — "internal, 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  # protected — use the property instead

    @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.


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 class for a bank account. Don't write code yet — just answer these questions on paper:

  1. What should a bank account know? (List the attributes.)
  2. What should a bank account do? (List the methods.)
  3. What should be public and what should be hidden?
  4. 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.


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.count

c = Counter() c.increment() ```

What you see: TypeError: Counter.increment() takes 0 positional arguments but 1 was given

Why it happens: When you call c.increment(), Python automatically passes c as the first argument. But increment() has no parameters to receive it.

The fix: python def increment(self): # Add self parameter self.count += 1 # Use self.count, not just count

Rule of thumb: Every method in a class takes self as its first parameter. Every attribute access inside a method uses self.attribute_name. No exceptions (well, except @staticmethod and @classmethod, which you'll learn about in Chapter 16).

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 all Student instances. 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 own

Rule of thumb: Class attributes should be immutable (numbers, strings, tuples). If it's a list, dict, or set, put it in __init__.

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, and cli.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 Task Class

import json
from datetime import datetime


class Task:
    """A single task in the TaskFlow system."""

    def __init__(self, title, priority="medium", completed=False,
                 created_at=None):
        self.title = title
        self.priority = priority
        self.completed = completed
        self.created_at = created_at or datetime.now().isoformat()

    def complete(self):
        """Mark the task as completed."""
        self.completed = True

    def to_dict(self):
        """Convert to dictionary for JSON serialization."""
        return {
            "title": self.title,
            "priority": self.priority,
            "completed": self.completed,
            "created_at": self.created_at,
        }

    @classmethod
    def from_dict(cls, data):
        """Create a Task from a dictionary (loaded from JSON)."""
        return cls(
            title=data["title"],
            priority=data.get("priority", "medium"),
            completed=data.get("completed", False),
            created_at=data.get("created_at"),
        )

    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)

The TaskList Class

class TaskList:
    """A managed collection of tasks."""

    def __init__(self):
        self.tasks = []

    def add(self, task):
        """Add a task to the list."""
        self.tasks.append(task)

    def delete(self, index):
        """Delete a task by index. Returns the deleted task."""
        if 0 <= index < len(self.tasks):
            return self.tasks.pop(index)
        raise IndexError(f"No task at index {index}")

    def search(self, keyword):
        """Find tasks whose title contains the keyword."""
        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 a given priority."""
        return [t for t in self.tasks if t.priority == priority]

    def pending(self):
        """Get all incomplete tasks."""
        return [t for t in self.tasks if not t.completed]

    def display(self):
        """Print all tasks with numbering."""
        if not self.tasks:
            print("No tasks yet.")
            return
        for i, task in enumerate(self.tasks):
            print(f"  {i + 1}. {task}")

    def __len__(self):
        return len(self.tasks)

    def __getitem__(self, index):
        return self.tasks[index]

The TaskStorage Class

class TaskStorage:
    """Handles saving and loading tasks to/from JSON files."""

    def __init__(self, filepath="tasks.json"):
        self.filepath = filepath

    def save(self, task_list):
        """Save a TaskList to a JSON file."""
        data = [task.to_dict() for task in task_list.tasks]
        with open(self.filepath, "w") as f:
            json.dump(data, f, indent=2)

    def load(self):
        """Load tasks from a JSON file. Returns a TaskList."""
        task_list = TaskList()
        try:
            with open(self.filepath, "r") as f:
                data = json.load(f)
            for item in data:
                task_list.add(Task.from_dict(item))
        except FileNotFoundError:
            pass  # No saved file yet — return empty list
        return task_list

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:

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_dict) Methods: task.complete()
Task display display_task(task_dict) function print(task) via __str__
Task comparison Manual dict comparison task1 == task2 via __eq__
Task collection Plain list + helper functions TaskList with search, pending, etc.
File I/O Functions scattered in storage.py TaskStorage class with save/load
Serialization Manual dict conversion everywhere to_dict() and from_dict() methods

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, and ChecklistTask as subclasses of Task. 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", "completed": False}. That worked, but dictionaries don't do anything — they just hold data. A Task class 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.
  • self refers 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: _protected and __name_mangled. The @property decorator 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