Skip to content

Solutions 16: Object-Oriented Programming

Overview

Chapter 16 exercises cover creating classes, using instance and class attributes, implementing special methods, inheritance, super(), and designing class hierarchies. This guide explains the reasoning behind each solution and highlights when OOP is the right tool.


Notes Before Checking Solutions

OOP is a tool for organizing code, not a requirement. Use classes when you have data and behavior that naturally belong together, or when you need multiple instances of the same structure. For simple scripts and utility functions, plain functions are often cleaner.


Warm-up Exercise Solutions

Exercise 1: Create a Simple Class

class Book:
    """Represents a book."""

    def __init__(self, title, author, pages):
        self.title = title
        self.author = author
        self.pages = pages

    def __str__(self):
        return f"{self.title} by {self.author}"

    def __repr__(self):
        return f"Book('{self.title}', '{self.author}', {self.pages})"

    def summary(self):
        return f"{self.title} ({self.pages} pages)"

book1 = Book("Fluent Python", "Luciano Ramalho", 792)
print(book1)           # Fluent Python by Luciano Ramalho
print(repr(book1))     # Book('Fluent Python', 'Luciano Ramalho', 792)
print(book1.summary()) # Fluent Python (792 pages)

__str__ vs. __repr__: - __str__ is for human-readable output. It is called by print() and str(). - __repr__ is for developer-readable output. It should ideally be a valid Python expression that recreates the object. It is called by repr() and shown in the REPL. - If you only define one, define __repr__ — Python falls back to it for __str__ if __str__ is not defined.


Exercise 2: Add Methods to a Class

class BankAccount:
    def __init__(self, owner, balance=0):
        self.owner = owner
        self.balance = balance

    def deposit(self, amount):
        if amount <= 0:
            raise ValueError("Deposit must be positive")
        self.balance += amount
        print(f"Deposited ${amount}. New balance: ${self.balance}")

    def withdraw(self, amount):
        if amount <= 0:
            raise ValueError("Withdrawal must be positive")
        if amount > self.balance:
            raise ValueError("Insufficient funds")
        self.balance -= amount
        print(f"Withdrew ${amount}. New balance: ${self.balance}")

    def get_balance(self):
        return self.balance

    def __str__(self):
        return f"{self.owner}'s account: ${self.balance}"

account = BankAccount("Alice", 1000)
account.deposit(500)   # Deposited $500. New balance: $1500
account.withdraw(200)  # Withdrew $200. New balance: $1300

Raise exceptions for invalid operations rather than returning error codes or printing errors. The caller can decide how to handle the error. This makes the class reusable in different contexts (CLI, web app, tests).

get_balance() returns the balance rather than accessing account.balance directly. This is a simple form of encapsulation — if you later want to add logic (like rounding), you only change one place.


Exercise 3: Use Class Attributes

class Dog:
    species = "Canis familiaris"  # class attribute

    def __init__(self, name, age):
        self.name = name   # instance attribute
        self.age = age

    def bark(self):
        return f"{self.name} says: Woof!"

dog1 = Dog("Buddy", 3)
dog2 = Dog("Max", 5)

print(Dog.species)   # Canis familiaris
print(dog1.species)  # Canis familiaris (inherited from class)

Dog.species = "Canis lupus familiaris"
print(dog1.species)  # Canis lupus familiaris (all instances see the change)

Class attributes are shared across all instances. Modifying Dog.species changes it for every Dog instance. Instance attributes (set with self.x = ...) are unique to each instance.

Be careful with mutable class attributes. If a class attribute is a list or dict, all instances share the same object. Appending to it from one instance affects all instances. Use instance attributes for mutable data.


Exercise 4: Understand Inheritance

class Animal:
    def __init__(self, name):
        self.name = name

    def speak(self):
        return f"{self.name} makes a sound"

    def __str__(self):
        return f"{self.name} ({self.__class__.__name__})"

class Dog(Animal):
    def speak(self):
        return f"{self.name} says: Woof!"

class Cat(Animal):
    def speak(self):
        return f"{self.name} says: Meow!"

animals = [Dog("Buddy"), Cat("Whiskers")]
for animal in animals:
    print(animal)         # uses __str__ from Animal
    print(animal.speak()) # uses overridden speak()

self.__class__.__name__ returns the name of the actual class of the instance, not the class where the method is defined. So str(Dog("Buddy")) prints Buddy (Dog), not Buddy (Animal).

Polymorphism: The for loop calls animal.speak() without knowing whether animal is a Dog or Cat. Python dispatches to the correct method based on the actual type. This is polymorphism — the same interface, different behavior.


Practice Exercise Solutions

Exercise 5: Use super() for Inheritance

class Vehicle:
    def __init__(self, brand, model):
        self.brand = brand
        self.model = model

    def info(self):
        return f"{self.brand} {self.model}"

class Car(Vehicle):
    def __init__(self, brand, model, doors):
        super().__init__(brand, model)  # initialize parent
        self.doors = doors

    def info(self):
        return f"{super().info()} ({self.doors} doors)"

class Truck(Vehicle):
    def __init__(self, brand, model, capacity):
        super().__init__(brand, model)
        self.capacity = capacity

    def info(self):
        return f"{super().info()} (capacity: {self.capacity} tons)"

car = Car("Toyota", "Camry", 4)
print(car.info())   # Toyota Camry (4 doors)

truck = Truck("Ford", "F-150", 2)
print(truck.info()) # Ford F-150 (capacity: 2 tons)

Always call super().__init__() in a subclass __init__ to ensure the parent class is properly initialized. Forgetting this means the parent's attributes are never set, leading to AttributeError when you try to use them.

super().info() calls the parent's info() method. This avoids duplicating the parent's logic and ensures that if the parent changes, the child automatically benefits.


Exercise 6: Implement Special Methods

class Point:
    def __init__(self, x, y):
        self.x = x
        self.y = y

    def __str__(self):
        return f"({self.x}, {self.y})"

    def __repr__(self):
        return f"Point({self.x}, {self.y})"

    def __eq__(self, other):
        return self.x == other.x and self.y == other.y

    def __lt__(self, other):
        return (self.x ** 2 + self.y ** 2) < (other.x ** 2 + other.y ** 2)

    def __add__(self, other):
        return Point(self.x + other.x, self.y + other.y)

    def __len__(self):
        return int((self.x ** 2 + self.y ** 2) ** 0.5)

p1 = Point(3, 4)
p2 = Point(3, 4)
p3 = Point(1, 1)

print(p1 == p2)    # True
print(p1 < p3)     # False (distance of p1 is 5, p3 is ~1.4)
print(p1 + p3)     # (4, 5)
print(len(p1))     # 5

Special methods (dunder methods) let your objects work with Python's built-in operators and functions. __eq__ enables ==, __lt__ enables < (and, with functools.total_ordering, all comparison operators), __add__ enables +, __len__ enables len().

__eq__ also affects in and == in collections. If you define __eq__, also define __hash__ if you want instances to be usable as dictionary keys or in sets. If you define __eq__ without __hash__, Python sets __hash__ to None, making instances unhashable.


Exercise 7: Create a Class Hierarchy

class Employee:
    def __init__(self, name, salary):
        self.name = name
        self.salary = salary

    def get_info(self):
        return f"{self.name}: ${self.salary}"

    def give_raise(self, amount):
        self.salary += amount

class Manager(Employee):
    def __init__(self, name, salary, team_size):
        super().__init__(name, salary)
        self.team_size = team_size

    def get_info(self):
        return f"{super().get_info()} (manages {self.team_size} people)"

class Developer(Employee):
    def __init__(self, name, salary, language):
        super().__init__(name, salary)
        self.language = language

    def get_info(self):
        return f"{super().get_info()} (specializes in {self.language})"

employees = [
    Manager("Alice", 100000, 5),
    Developer("Bob", 80000, "Python"),
]

for emp in employees:
    print(emp.get_info())

for emp in employees:
    emp.give_raise(5000)  # inherited from Employee

print("\nAfter raises:")
for emp in employees:
    print(emp.get_info())

give_raise() is defined once in Employee and inherited by both Manager and Developer. This is the key benefit of inheritance — shared behavior lives in one place.


Challenge Exercise Solutions

Challenge 1: Build a Game Character System

class Character:
    def __init__(self, name, health, mana):
        self.name = name
        self.health = health
        self.mana = mana

    def take_damage(self, damage):
        self.health = max(0, self.health - damage)

    def heal(self, amount):
        self.health += amount

    def __str__(self):
        return f"{self.name} (HP: {self.health}, Mana: {self.mana})"

class Warrior(Character):
    def __init__(self, name, health, mana, strength):
        super().__init__(name, health, mana)
        self.strength = strength

    def attack(self):
        damage = self.strength * 1.5
        return f"{self.name} attacks for {damage:.0f} damage!"

class Mage(Character):
    def __init__(self, name, health, mana, intelligence):
        super().__init__(name, health, mana)
        self.intelligence = intelligence

    def cast_spell(self):
        if self.mana < 20:
            return f"{self.name} doesn't have enough mana!"
        self.mana -= 20
        damage = self.intelligence * 2
        return f"{self.name} casts a spell for {damage} damage!"

warrior = Warrior("Conan", 100, 20, 15)
mage = Mage("Gandalf", 60, 100, 18)

print(warrior.attack())    # Conan attacks for 22 damage!
print(mage.cast_spell())   # Gandalf casts a spell for 36 damage!
warrior.take_damage(10)
print(warrior)             # Conan (HP: 90, Mana: 20)

max(0, self.health - damage) prevents health from going below zero without an if statement. This is a common pattern for clamping values.


Challenge 2: Implement a Data Container

class Person:
    def __init__(self, name, age, email):
        self.name = name
        self.age = age
        self.email = email

    def __str__(self):
        return f"{self.name} ({self.age})"

    def __repr__(self):
        return f"Person('{self.name}', {self.age}, '{self.email}')"

    def __eq__(self, other):
        return self.email == other.email  # unique identifier

    def __lt__(self, other):
        return self.age < other.age

people = [
    Person("Alice", 30, "alice@example.com"),
    Person("Bob", 25, "bob@example.com"),
    Person("Carol", 28, "carol@example.com"),
]

print(sorted(people))  # sorted by age: Bob, Carol, Alice

alice = Person("Alice", 30, "alice@example.com")
print(alice in people)  # True — uses __eq__ (compares email)

Using email as the equality key makes sense because email addresses are unique identifiers. Two Person objects with the same email are the same person, even if the name or age differs.


Common Mistakes

Forgetting self in method definitions. Every instance method must have self as its first parameter. def bark(): inside a class will fail with TypeError when called.

Not calling super().__init__() in a subclass. The parent's __init__ sets up the parent's attributes. Without calling it, those attributes do not exist.

Mutable class attributes. Using a list or dict as a class attribute means all instances share the same object. Use instance attributes for mutable data.

# Bug: all Dog instances share the same tricks list
class Dog:
    tricks = []  # class attribute — shared!

    def add_trick(self, trick):
        self.tricks.append(trick)

# Fix: use an instance attribute
class Dog:
    def __init__(self):
        self.tricks = []  # instance attribute — unique per dog

Overusing inheritance. Inheritance is for "is-a" relationships. If Car is a Vehicle, inheritance makes sense. If you just want to reuse some methods, consider composition (storing an instance of another class as an attribute) instead.


What to Review Next

  • Review the matching handbook chapter if any exercise felt difficult.
  • Revisit the matching exercise set and try solving it again without looking at the solution.
  • Continue with the next handbook chapter: Chapter 17 - Standard Library