Inheritance and Exceptions

CS 108

Monday: Inheritance

Why Inheritance?

Suppose we have a Warrior class and a Mage class. Both have:

  • name, health attributes
  • attack(), __str__() methods

What’s the problem with maintaining these two separately?

Code smell: copying code means bugs in two places, updates in two places, inconsistencies creep in.

Inheritance lets us share common code in a base class and specialize in subclasses.

UML Class Diagram

        Character
      name, health
       attack()
       __str__()
    /      |      \
Warrior   Mage   Healer
strength  mana   heal_power
  • Warrior, Mage, Healer inherit from Character
  • “A Healer is-a Character
  • Character is the base class (or parent class)
  • The three subclasses are derived classes

POGIL: Extending Classes

Work through the Extending Classes POGIL handout in your groups.

  • Model 1: UML Class Diagrams (~15 min)
  • Model 2: Single-Class Approach (~10 min)
  • Model 3: Derived Classes (~20 min)

Inheritance: Syntax Summary

(after POGIL debrief)

Defining a Subclass

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

    def attack(self, target):
        print(f"{self.name} attacks {target.name}!")

    def __str__(self):
        return f"{self.name} (HP: {self.health})"
class Warrior(Character):
    def __init__(self, name, health, strength):
        super().__init__(name, health)
        self.strength = strength

    def attack(self, target):
        target.health -= self.strength
        print(f"{self.name} hits {target.name} for {self.strength} damage!")

Key points:

  • class Warrior(Character): — Warrior inherits from Character
  • super().__init__(...) — calls the parent constructor
  • attack() in Warrior overrides the one in Character

A Subclass Can Repurpose a Method

class Healer(Character):
    def __init__(self, name, health, heal_power):
        super().__init__(name, health)
        self.heal_power = heal_power

    def attack(self, target):
        target.health += self.heal_power
        print(f"{self.name} heals {target.name} for {self.heal_power} HP!")

Healer inherits from Character, but its attack() does the opposite of damage.

character.attack(target) works for all characters, but behavior can be very different.

This is polymorphism — the caller doesn’t need to know what kind of character it has.

Using Inherited Classes

aragorn = Warrior("Aragorn", health=100, strength=15)
mage = Mage("Gandalf", health=80, mana=50)
healer = Healer("Arwen", health=70, heal_power=20)

What does each line print?

print(aragorn)
aragorn.attack(mage)
healer.attack(mage)
print(mage)
Aragorn (HP: 100)
Aragorn hits Gandalf for 15 damage!
Arwen heals Gandalf for 20 HP!
Gandalf (HP: 85)

__str__() is inherited from Character; attack() is overridden in each subclass.

isinstance checks

aragorn = Warrior("Aragorn", health=100, strength=15)

isinstance(aragorn, Warrior)    # True
isinstance(aragorn, Character)  # True  ← aragorn IS-A Character
isinstance(aragorn, Healer)     # False

A subclass instance is also an instance of its parent class.

Fill in the Blank

  1. Make Mage inherit from Character:

    class Mage(___):
        ...
  2. Call the parent constructor from Mage.__init__:

    def __init__(self, name, health, mana):
        ___(name, health)
        self.mana = mana
  3. True or False: A Mage object is also a Character object.

  1. class Mage(Character):
  2. super().__init__(name, health)
  3. True

Wednesday: Exceptions

You’ve seen these before

All semester, these have crashed your programs:

ValueError: invalid literal for int() with base 10: 'hello'
TypeError: can only concatenate str (not "int") to str
IndexError: list index out of range
FileNotFoundError: [Errno 2] No such file or directory: 'data.csv'
KeyError: 'name'
ZeroDivisionError: division by zero

Today: learn to handle them gracefully and raise your own.

Match the Exception

Which exception type does each expression raise?

Expression Exception
int("hello") ?
[1, 2, 3][10] ?
"hi" + 4 ?
open("missing.txt") ?
{"a": 1}["b"] ?
Expression Exception
int("hello") ValueError
[1, 2, 3][10] IndexError
"hi" + 4 TypeError
open("missing.txt") FileNotFoundError
{"a": 1}["b"] KeyError

try / except: the basics

Without handling:

age = int(input("Enter your age: "))  # crashes if user types "twenty"

With handling:

try:
    age = int(input("Enter your age: "))
    print(f"In 10 years you'll be {age + 10}")
except ValueError:
    print("That's not a number!")

The except block runs only if an exception of that type is raised inside try.

Catching specific types

try:
    result = numerator / denominator
except ZeroDivisionError:
    print("Can't divide by zero!")
except TypeError:
    print("Both values must be numbers.")

Or catch multiple at once:

except (ValueError, TypeError) as e:
    print(f"Bad input: {e}")

as e gives you access to the exception message.

else and finally

try:
    f = open("data.txt")
except FileNotFoundError:
    print("File not found.")
else:
    print("File opened successfully!")   # runs only if no exception
    data = f.read()
finally:
    print("This always runs.")           # cleanup goes here
  • else — runs when no exception occurred
  • finally — runs always (great for closing files, etc.)

Exercise: Fix the crash

This program crashes when the user types a non-number. Add try/except to handle it gracefully:

def get_count():
    n = int(input("How many players? "))
    return n

count = get_count()
print(f"Starting game with {count} players.")

Make it print a helpful message and ask again if the input is invalid.

raise: enforcing invariants

Classes can raise exceptions to reject bad values:

class BankAccount:
    def __init__(self, balance):
        if balance < 0:
            raise ValueError("Balance cannot be negative.")
        self.balance = balance
acct = BankAccount(100)   # fine
acct = BankAccount(-50)   # raises ValueError

Compare to assert: similar idea, but raise is for production code; assert is for debugging.

raise vs if

Use raise (exceptions) for exceptional situations — things that shouldn’t happen if the caller uses your code correctly:

def set_player_count(n):
    if n <= 0:
        raise ValueError(f"Player count must be positive, got {n}")
    self.players = n

Use if for normal control flow — things you expect to happen:

if score > high_score:
    high_score = score

Rule of thumb: if it’s a programming mistake or invalid state, raise. If it’s a normal user choice or condition, if.

Exercise: Add validation

Add a raise ValueError to this class constructor so it rejects invalid inputs:

class Rectangle:
    def __init__(self, width, height):
        # TODO: raise ValueError if width or height is not positive
        self.width = width
        self.height = height

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

Then test it:

try:
    r = Rectangle(-3, 5)
except ValueError as e:
    print(f"Caught: {e}")