Object-Oriented Programming & Exceptions

Designing Robust Python Programs

Oliver Bonham-Carter

On For Today

Two Essential Python Skills for Real Programs

Topics covered in today’s discussion:

  • 🧱 Object-Oriented Programming (OOP) β€” classes, objects, methods, and design
  • 🧱 Constructors and Instance State β€” using __init__ and self
  • 🧱 Encapsulation & Validation β€” controlling how data is updated
  • 🧱 Inheritance & Polymorphism β€” reusing and extending behavior
  • ⚠️ Exceptions β€” handling runtime errors safely
  • ⚠️ try / except / else / finally β€” structured error handling
  • ⚠️ Raising Custom Exceptions β€” communicating meaningful failures
  • 🧠 Challenge Problems + Solutions β€” practice and mechanism-focused discussion

Note

By the end, you should be able to design small object-based systems and make them resilient when things go wrong.

Part 1: Object-Oriented Programming

What Is OOP?

Object-oriented programming organizes code around objects (instances) created from classes (blueprints).

A class groups together:

  1. Attributes (data), and
  2. Methods (functions that operate on that data).

Key idea: OOP helps model real systems by bundling data + behavior together.

Your First Class

Class, Constructor, Method

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

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

b1 = Book("Python Basics", "A. Rivera", 240)
b2 = Book("Data Stories", "L. Chen", 180)

print(b1.summary())
print(b2.title)

Important

How it works: Book(...) calls __init__ automatically. Each created object stores its own title, author, and pages.

Instance State with self

Each Object Keeps Its Own Data

class Counter:
    def __init__(self):
        self.value = 0

    def increment(self):
        self.value += 1

    def reset(self):
        self.value = 0

c1 = Counter()
c2 = Counter()

#combine two statements into one line
c1.increment(); c1.increment() 

c2.increment()

print(f"c1.value is {c1.value}")  # 2
print(f"c2.value is {c2.value}")  # 1

self refers to the current object, so c1 and c2 do not overwrite each other.

Challenge: Independent Object State

After creating c1 and c2 as separate Counter objects, the program calls c1.increment() twice and c2.increment() once. What is c1.value?
1
2
3
0

Encapsulation and Validation

Guarding Against Invalid Updates

class Temperature:
    def __init__(self, celsius=0):
        self.set_celsius(celsius)

    def set_celsius(self, value):
        if value < -273.15:
            raise ValueError("Temperature cannot be below absolute zero.")
        self._celsius = value

    def get_celsius(self):
        return self._celsius

    def to_fahrenheit(self):
        return (self._celsius * 9 / 5) + 32

room = Temperature(21)
print(room.to_fahrenheit())

Note

The attribute _celsius is a convention for β€œinternal use.” Public methods (set_celsius, get_celsius) enforce valid state.

Challenge: Validation During Construction

What happens when the temperature conversion program evaluates room = Temperature(-300) using the previous code?
A Temperature object is created with -273.15 degrees.
A ValueError is raised before a valid object is created.
A TypeError is raised because negative numbers are not allowed.
The object is created and the invalid value is stored.

__str__ for Readable Objects

Custom Print Output

class Student:
    def __init__(self, name, grade):
        self.name = name
        self.grade = grade

    def __str__(self):
        return f"Student(name={self.name}, grade={self.grade})"

    def is_passing(self):
        return self.grade >= 60

s = Student("Maya", 88)
print(s)
print(s.is_passing())

Inheritance: Reusing a Base Class

A Base Class and Two Subclasses

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

    def speak(self):
        return "..."

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

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

pets = [Dog("Buddy"), Cat("Luna")]
for pet in pets:
    print(pet.speak())

Note

This is polymorphism: same method call (speak) produces behavior based on object type.

Challenge: Polymorphic Method Calls

Given pets = [Dog("Buddy"), Cat("Luna")], what does pet.speak() produce for each pet in a loop?
Buddy says ... then Luna says ...
Buddy says meow then Luna says woof
Buddy says woof then Luna says meow
The loop raises an error because Dog and Cat have different classes

Composition: Objects Inside Objects

Modeling Relationships with Contained Objects

class Engine:
    def __init__(self, horsepower):
        self.horsepower = horsepower

class Car:
    def __init__(self, brand, horsepower):
        self.brand = brand
        self.engine = Engine(horsepower)

    def info(self):
        return f"{self.brand} with {self.engine.horsepower} HP"

car = Car("Toyota", 169)
print(car.info())

Design point: Composition often models β€œhas-a” relationships cleanly (Car has an Engine).

Lets Build a Class Together

Class Forge: Build a D&D Character

Choose a class name and starting stats, then add your own instance attributes and methods. The Python source updates as you build; the comments point out where class syntax and self appear.

Instance attributes

Each added attribute becomes a constructor parameter and a self.attribute assignment.

Methods to include

Generated Python

Combat preview

This small browser preview mirrors the class settings; it does not execute Python.

Choose a method and start the encounter.

Reading and Using Your Class

Important

  1. Change the class name, character name, hit points, or strength; the Python source updates immediately.
  2. Select Add attribute to define another piece of instance state. Choose whether its default value is text, a number, or a Boolean; each field becomes a constructor parameter and a self.name = name assignment.
  3. Toggle methods to add or remove their def blocks. Combat is available when both attack() and take_damage() are included.
  4. Play a turn in the combat preview, then select Copy Python code and paste it into a Python notebook to run the generated class.

Note

The combat preview is JavaScript that mirrors the selected stats and methods; the generated Python itself runs in a Python environment. Notice how hero and goblin are two independent instances made from the same class.

OOP Mini Challenges

Challenge A β€” Circle Class

Warning

Create a Circle class with: - radius in __init__ - area() - circumference() - __str__

# Write Circle here

c = Circle(3)
print(c)
print(round(c.area(), 2))
print(round(c.circumference(), 2))

Solution A β€” Circle Class

import math

class Circle:
    def __init__(self, radius):
        if radius <= 0:
            raise ValueError("Radius must be positive.")
        self.radius = radius

    def area(self):
        return math.pi * self.radius ** 2

    def circumference(self):
        return 2 * math.pi * self.radius

    def __str__(self):
        return f"Circle(radius={self.radius})"

c = Circle(3)
print(c)
print(round(c.area(), 2))
print(round(c.circumference(), 2))

Important

Mechanism: Validation in __init__ ensures every Circle instance is meaningful, and methods compute directly from object state.

Challenge B β€” Inheritance Practice

Warning

Create a base class Employee(name, salary) with method annual_pay(). Then create subclass Manager(name, salary, bonus) overriding annual_pay() to include bonus.

# Write Employee and Manager here

e = Employee("Avery", 5000)
m = Manager("Jules", 7000, 1500)
print(e.annual_pay())
print(m.annual_pay())

Solution B β€” Inheritance Practice

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

    def annual_pay(self):
        return self.salary * 12

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

    def annual_pay(self):
        return super().annual_pay() + self.bonus

e = Employee("Avery", 5000)
m = Manager("Jules", 7000, 1500)
print(e.annual_pay())
print(m.annual_pay())

Important

Mechanism: Manager inherits shared behavior and state from Employee, then overrides one method to extend the pay rule.

Challenge C β€” Simple Inventory Object

Warning

Build InventoryItem(name, quantity) with methods: - restock(amount) - sell(amount) (reject sale if not enough stock) - __str__

# Write InventoryItem here

Solution C β€” Simple Inventory Object

class InventoryItem:
    def __init__(self, name, quantity):
        if quantity < 0:
            raise ValueError("Quantity cannot be negative.")
        self.name = name
        self.quantity = quantity

    def restock(self, amount):
        if amount <= 0:
            raise ValueError("Restock amount must be positive.")
        self.quantity += amount

    def sell(self, amount):
        if amount <= 0:
            raise ValueError("Sell amount must be positive.")
        if amount > self.quantity:
            return "Not enough stock."
        self.quantity -= amount
        return "Sale completed."

    def __str__(self):
        return f"InventoryItem(name={self.name}, quantity={self.quantity})"

item = InventoryItem("Notebook", 10)
item.restock(5)
print(item.sell(8))
print(item)

Important

Mechanism: The class centralizes inventory rules, so updates happen through methods that preserve valid stock counts.

Challenge: Exceptions Preserve Valid State

An inventory item has quantity 3. Its sell(5) method raises OutOfStockError before subtracting stock. What is its quantity after the error is caught?
-2
3
0
The item no longer exists

Part 2: Exceptions

What Is an Exception?

An exception is a runtime error event that interrupts normal program flow.

Common examples:

  • ValueError (bad value)
  • TypeError (wrong type)
  • ZeroDivisionError
  • FileNotFoundError

Key idea: Exceptions let you fail safely and explain what went wrong.

Basic try / except

Catching a Conversion Error

def parse_age(text):
    try:
        age = int(text)
        return f"Age accepted: {age}"
    except ValueError:
        return "Age must be a whole number."

print(parse_age("21"))
print(parse_age("twenty"))

Multiple except Clauses

Handling Different Failures Differently

def divide_from_text(a_text, b_text):
    try:
        a = float(a_text)
        b = float(b_text)
        return a / b
    except ValueError:
        return "Inputs must be numbers."
    except ZeroDivisionError:
        return "Cannot divide by zero."

print(divide_from_text("10", "2"))
print(divide_from_text("10", "0"))
print(divide_from_text("ten", "2"))

Challenge: else and finally

In a try/except/else/finally statement, converting "7" with int() succeeds. Which blocks run, in order?
except, then finally
else, then finally
finally only
else only

Challenge: Matching an Exception Type

In divide_from_text(a, b), what does divide_from_text("10", "0") return?
"100"
infinity
"Inputs must be numbers."
"Cannot divide by zero."

Opening files without an exception handler

Full Exception Structure

def read_first_line(path):
    file = None
    file = open(path, "r")
    print("File closed.")
    return file.readline().strip()

print(read_first_line("notes.txt"))
  • Code works wonderfully, as long as the file exists and is readable.
  • Running this code with a missing file will cause a FileNotFoundError and crash the program. 

( ꩜ α―… ꩜;)

(Β·β€’α·„β€Žΰ‘‡β€’α·… )

q°(°¯᷄◠¯᷅°)°q

else and finally

Full Exception Structure

def read_first_line(path):
    file = None
    try:
        file = open(path, "r")
    except FileNotFoundError:
        return "File not found."
    else:
        return file.readline().strip()
    finally:
        if file:
            file.close()
            print("File closed.")

print(read_first_line("notes.txt"))
  • else runs only if no exception occurs.
  • finally runs no matter what.

Raising Exceptions Yourself

Use raise for Invalid States

def withdraw(balance, amount):
    if amount <= 0:
        raise ValueError("Withdrawal amount must be positive.")
    if amount > balance:
        raise ValueError("Insufficient funds.")
    return balance - amount

print(withdraw(200, 50))

Custom Exception Classes

Domain-Specific Error Types

class InvalidGradeError(Exception):
    pass

def set_grade(value):
    if not (0 <= value <= 100):
        raise InvalidGradeError("Grade must be between 0 and 100.")
    return value

try:
    set_grade(140)
except InvalidGradeError as e:
    print("Grade update failed:", e)

Exceptions Inside Classes

Enforcing Valid Object Behavior

class BankAccount:
    def __init__(self, owner, balance=0):
        if balance < 0:
            raise ValueError("Opening balance cannot be negative.")
        self.owner = owner
        self.balance = balance

    def deposit(self, amount):
        if amount <= 0:
            raise ValueError("Deposit must be positive.")
        self.balance += amount

    def withdraw(self, amount):
        if amount <= 0:
            raise ValueError("Withdrawal must be positive.")
        if amount > self.balance:
            raise ValueError("Insufficient funds.")
        self.balance -= amount

acct = BankAccount("Alex", 100)
acct.deposit(25)
print(acct.balance)

Exceptions: Mini Challenges

Challenge D β€” Safe Integer Input Function

Warning

Write safe_int(text, default):

  • return int(text) when possible
  • return default on failure
def safe_int(text, default=0):
    # your code here
    pass

# Example usage:
print(safe_int("42"))       # 42
print(safe_int("oops"))     # 0
print(safe_int(None, -1))    # -1

Solution D β€” Safe Integer Input Function

def safe_int(text, default=0):
    try:
        return int(text)
    except (ValueError, TypeError):
        return default

print(safe_int("42"))       # 42
print(safe_int("oops"))     # 0
print(safe_int(None, -1))    # -1

Important

Mechanism: The function isolates conversion risk inside try/except and guarantees a usable fallback value.

Challenge E β€” Custom Exception in a Class

Warning

Define a custom exception OutOfStockError and use it in InventoryItem.sell(amount). Raise it when amount > quantity.

class OutOfStockError(Exception):
    print("This is a custom exception for out-of-stock situations.")
    # pass

class InventoryItem:
    def __init__(self, name, quantity):
        self.name = name
        self.quantity = quantity

    def sell(self, amount):
        """
        Function to handle selling an item.
        Raise ValueError if amount is not positive.
        Raise OutOfStockError if amount exceeds quantity, otherwise reduce stock.
        """
        # TODO: Implement this method

Solution E β€” Custom Exception in a Class

class OutOfStockError(Exception):
    print("This is a custom exception for out-of-stock situations.")
    # pass

class InventoryItem:
    def __init__(self, name, quantity):
        self.name = name
        self.quantity = quantity

    def sell(self, amount):
        if amount <= 0:
            raise ValueError("Amount must be positive.")
        if amount > self.quantity:
            raise OutOfStockError(
                f"Requested {amount}, but only {self.quantity} available."
            )
        self.quantity -= amount

item = InventoryItem("Pen", 3)
try:
    item.sell(5)
except OutOfStockError as e:
    print("Sale failed:", e)

Important

Mechanism: A domain-specific exception makes stock failures explicit and easier to handle than a generic error.

Challenge F β€” Safe File Counter

Warning

Write count_lines(path) that returns number of lines. If file is missing, return 0 instead of crashing.

Solution F β€” Safe File Counter

def count_lines(path):
    try:
        with open(path, "r") as file:
            return sum(1 for _ in file)
    except FileNotFoundError:
        return 0

print(count_lines("notes.txt"))

Important

Mechanism: The context manager handles file closing automatically, and FileNotFoundError is converted into a safe default result.

Five β€œBig” Challenges

Note

(Try these first before looking at solutions!)

Final Challenge 1 β€” Rectangle with Validation

Warning

Create class Rectangle(width, height):

  • reject non-positive dimensions using ValueError
  • methods area(), perimeter(), is_square()
  • readable __str__

Final Challenge 2 β€” SavingsAccount Inheritance

Warning

Create Account(owner, balance) with deposit/withdraw. Create SavingsAccount(owner, balance, rate) with method apply_interest(). Use exceptions for invalid amounts.

Final Challenge 3 β€” compose + Exception Safety

Warning

Write compose(f, g) returning f(g(x)). Then write safe_compose(f, g, fallback) that catches exceptions and returns fallback.

Final Challenge 4 β€” Student Parser

Warning

Given lines like "Alice,92", build parse_students(lines) returning dict name→grade. Skip malformed lines and out-of-range grades using exceptions.

Final Challenge 5 β€” GradeBook with Robust Rules

Warning

Create GradeBook with:

  • add_student(name, grade) (validate grade)
  • curve(curve_func) returning a new dict of curved grades
  • top_student()
  • passing(min_grade=60) Raise meaningful errors where appropriate.

β€œBig” Challenge Solutions

Solution 1 β€” Rectangle with Validation

class Rectangle:
    def __init__(self, width, height):
        if width <= 0 or height <= 0:
            raise ValueError("Width and height must be positive.")
        self.width = width
        self.height = height

    def area(self):
        return self.width * self.height

    def perimeter(self):
        return 2 * (self.width + self.height)

    def is_square(self):
        return self.width == self.height

    def __str__(self):
        return f"Rectangle({self.width} x {self.height})"


print("rect1")
width = 2
height = 4
rect1 = Rectangle(width, height)
print(f"  Dimensions are: {rect1}")
print(f"  My area is: {rect1.area()}")
print(f"  My perimeter is: {rect1.perimeter()}")
print(f"  This object a square:  {rect1.is_square()}")

print("rect2")
width = 4
height = 4
rect2 = Rectangle(width, height)
print(f"  Dimensions are: {rect2}")
print(f"  My area is: {rect2.area()}")
print(f"  My perimeter is: {rect2.perimeter()}")
print(f"  This object a square:  {rect2.is_square()}")

Important

Mechanism: Constructor validation prevents invalid objects from ever existing. Methods become simpler because state is guaranteed valid.

Solution 2 β€” SavingsAccount Inheritance

class Account:
    def __init__(self, owner, balance=0):
        if balance < 0:
            raise ValueError("Balance cannot be negative.")
        self.owner = owner
        self.balance = balance

    def deposit(self, amount):
        if amount <= 0:
            raise ValueError("Deposit must be positive.")
        self.balance += amount

    def withdraw(self, amount):
        if amount <= 0:
            raise ValueError("Withdrawal must be positive.")
        if amount > self.balance:
            raise ValueError("Insufficient funds.")
        self.balance -= amount

class SavingsAccount(Account):
    def __init__(self, owner, balance=0, rate=0.02):
        super().__init__(owner, balance)
        self.rate = rate

    def apply_interest(self):
        self.balance += self.balance * self.rate


name = "Alice"
deposit = 1000
account = SavingsAccount(name,deposit)
print(f" Initial balance: {account.balance}")
account.deposit(500)
print(f" Balance After deposit: {account.balance}")
account.withdraw(200)
print(f" Balance After withdrawal: {account.balance}")
account.apply_interest()
print(f" Endling balance: {account.balance}")

Important

Mechanism: SavingsAccount reuses base logic through inheritance and super(), then adds behavior (apply_interest) without duplicating deposit/withdraw code.

Solution 3 β€” safe_compose

def compose(f, g):
    def composed(x):
        return f(g(x))
    return composed

def safe_compose(f, g, fallback=None):
    def composed(x):
        try:
            return f(g(x))
        except Exception:
            return fallback
    return composed


def reciprocal(x):
    return 1 / x

safe = safe_compose(lambda y: y + 10, reciprocal, fallback="bad input")
print(safe(2))
print(safe(0))

Important

Mechanism: Closures capture function references (f, g) and return a callable pipeline. Wrapping in try/except creates a resilient higher-order function.

Solution 4 β€” parse_students

def parse_students(lines):
    students = {}
    for line in lines:
        try:
            name, grade_text = line.split(",")
            grade = int(grade_text)
            if not (0 <= grade <= 100):
                raise ValueError("grade out of range")
            students[name.strip()] = grade
        except ValueError:
            continue
    return students

lines = ["Alice,92", "Bob,hello", "Cara,105", "Dylan,77"]
print(parse_students(lines))  # {'Alice': 92, 'Dylan': 77}

Important

Mechanism: The parsing loop is intentionally defensive. Bad records fail locally and are skipped, while good records still accumulate.

Solution 5 β€” Robust GradeBook

class GradeBook:
    def __init__(self):
        self._grades = {}

    def add_student(self, name, grade):
        if not name:
            raise ValueError("Name cannot be empty.")
        if not (0 <= grade <= 100):
            raise ValueError("Grade must be between 0 and 100.")
        self._grades[name] = grade

    def curve(self, curve_func):
        curved = {}
        for name, grade in self._grades.items():
            new_grade = curve_func(grade)
            curved[name] = max(0, min(100, new_grade))
        return curved

    def top_student(self):
        if not self._grades:
            raise ValueError("No students in grade book.")
        return max(self._grades, key=self._grades.get)

    def passing(self, min_grade=60):
        return [
            name for name, grade in self._grades.items()
            if grade >= min_grade
        ]


gb = GradeBook()
gb.add_student("Alice", 92)
gb.add_student("Bob", 55)
gb.add_student("Charlie", 78)
print(f"Passing student(s): {gb.passing()}")
print(f"After applying curve: {gb.curve(lambda g: g + 8)}")
print(f"Top student: {gb.top_student()}")

Discussion

  • Encapsulation: Internal dictionary _grades stores state in one place.
  • Validation: add_student enforces invariants early.
  • Higher-order behavior: curve accepts any callable transformation.
  • Exception strategy: methods raise clear errors for invalid usage (for example, top student on empty data).

What’s the Big Picture?

Object-Oriented Programming

  • Classes model real concepts with data + behavior
  • __init__ constructs valid object state
  • Inheritance and composition support code reuse
  • Polymorphism enables flexible, interchangeable objects

Exceptions

  • Exceptions handle runtime failures cleanly
  • try / except prevent crashes in expected failure paths
  • raise communicates invalid operations explicitly
  • Custom exceptions make domain errors readable and maintainable

Tip

When combined, OOP + exceptions let you build programs that are both organized and robust.