Teacher's Guide

Chapter 9: Classes and Objects

Teaching Objectives

By the end of this chapter, students should:

  • Understand the concept of object-oriented programming (OOP)
  • Know how to define classes and create objects in Python
  • Learn about class attributes, instance attributes, and methods
  • Understand the __init__ method and object initialization
  • Master inheritance and method overriding
  • Apply OOP concepts to solve real-world problems

Preparation

Before teaching this chapter, ensure:

  • Students understand basic Python concepts including functions
  • Students are familiar with dictionaries and other data structures
  • Your teaching environment allows for interactive coding demonstrations
  • You have prepared simple examples that illustrate OOP concepts

Lesson Overview

1. Introduction to Object-Oriented Programming (15 minutes)

Start by explaining basic OOP concepts:

  • Object-Oriented Programming (OOP) is a programming paradigm centered around objects
  • Objects are instances of classes, which serve as blueprints or templates
  • Classes encapsulate data (attributes) and behavior (methods)
  • OOP allows us to model real-world entities in our code
  • Key OOP concepts include: Classes, Objects, Attributes, Methods, Inheritance

The problem OOP solves:

# Procedural approach (without OOP)
student1_name = "Alice"
student1_age = 15
student1_grade = 9

student2_name = "Bob"
student2_age = 16
student2_grade = 10

# Functions to operate on student data
def display_student(name, age, grade):
    print(f"Name: {name}, Age: {age}, Grade: {grade}")

def promote_student(name, age, grade):
    return grade + 1

# Using the functions
display_student(student1_name, student1_age, student1_grade)
new_grade = promote_student(student1_name, student1_age, student1_grade)
print(f"{student1_name}'s new grade: {new_grade}")

Teaching points:

  • Traditional procedural code separates data from functions
  • As programs grow, managing related data becomes cumbersome
  • Object-oriented programming keeps related data and functionality together
  • Objects provide a more intuitive way to model real-world entities

2. Defining Classes and Creating Objects (20 minutes)

Show how to define a class and create objects:

# Define a class
class Student:
    # Class attributes (shared by all instances)
    school = "High School"
    
    # Initialization method (constructor)
    def __init__(self, name, age, grade):
        # Instance attributes (unique to each instance)
        self.name = name
        self.age = age
        self.grade = grade
    
    # Instance method
    def display_info(self):
        print(f"Name: {self.name}, Age: {self.age}, Grade: {self.grade}")
    
    # Another instance method
    def promote(self):
        self.grade += 1
        print(f"{self.name} has been promoted to grade {self.grade}")

# Create objects (instances of the class)
student1 = Student("Alice", 15, 9)
student2 = Student("Bob", 16, 10)

# Access attributes
print(f"Student 1 name: {student1.name}")
print(f"Student 2 age: {student2.age}")
print(f"Student 1 school: {student1.school}")  # Accessing class attribute

# Call methods
student1.display_info()
student2.display_info()
student1.promote()

Teaching points:

  • Classes are defined using the class keyword
  • __init__ is a special method called when an object is created
  • self refers to the current instance and must be the first parameter
  • Instance attributes are defined with self.attribute_name
  • Class attributes are shared by all instances
  • Methods are functions defined inside a class

3. Attributes and Methods in Depth (20 minutes)

Explore different types of attributes and methods:

class BankAccount:
    # Class attribute
    bank_name = "Python National Bank"
    interest_rate = 0.02  # 2% interest rate
    
    # Class variable to track number of accounts
    num_accounts = 0
    
    def __init__(self, account_holder, balance=0):
        # Instance attributes
        self.account_holder = account_holder
        self.balance = balance
        self.account_number = BankAccount.num_accounts + 1000
        
        # Increment the count of accounts
        BankAccount.__class__.num_accounts += 1
    
    # Instance methods
    def deposit(self, amount):
        if amount <= 0:
            print("Deposit amount must be positive")
            return False
        self.balance += amount
        print(f"Deposited ${amount}. New balance: ${self.balance}")
        return True
    
    def withdraw(self, amount):
        if amount <= 0:
            print("Withdrawal amount must be positive")
            return False
        if amount > self.balance:
            print("Insufficient funds")
            return False
        self.balance -= amount
        print(f"Withdrew ${amount}. New balance: ${self.balance}")
        return True
    
    def display_info(self):
        print(f"Account Number: {self.account_number}")
        print(f"Account Holder: {self.account_holder}")
        print(f"Balance: ${self.balance}")
    
    # Class method (operates on the class rather than instances)
    @classmethod
    def get_bank_info(cls):
        return f"{cls.bank_name} has {cls.num_accounts} accounts"
    
    # Static method (doesn't need class or instance data)
    @staticmethod
    def is_workday(day):
        # Returns whether the given day is a workday (Monday=0, Sunday=6)
        return day < 5  # Weekdays are 0-4 (Monday-Friday)

# Create accounts
account1 = BankAccount("Alice", 1000)
account2 = BankAccount("Bob", 500)

# Use instance methods
account1.display_info()
account1.deposit(500)
account1.withdraw(200)

# Access class attribute
print(f"Bank name: {BankAccount.bank_name}")
print(f"Interest rate: {BankAccount.interest_rate * 100}%")

# Use class method
print(BankAccount.get_bank_info())

# Use static method
import datetime
today = datetime.datetime.now().weekday()
if BankAccount.is_workday(today):
    print("The bank is open today")
else:
    print("The bank is closed today")

Teaching points:

  • Instance attributes are unique to each object
  • Class attributes are shared across all instances
  • Instance methods operate on instance data via self
  • Class methods operate on class data via cls and are defined using the @classmethod decorator
  • Static methods don't need instance or class data and are defined using the @staticmethod decorator
  • Class attributes can be accessed via the class name or through instances

4. Special Methods (Magic Methods) (15 minutes)

Introduce special methods (also called magic or dunder methods):

class Rectangle:
    def __init__(self, width, height):
        self.width = width
        self.height = height
    
    # String representation for debugging
    def __repr__(self):
        return f"Rectangle(width={self.width}, height={self.height})"
    
    # String representation for users
    def __str__(self):
        return f"Rectangle with width {self.width} and height {self.height}"
    
    # Calculate area
    def area(self):
        return self.width * self.height
    
    # Define behavior for + operator
    def __add__(self, other):
        if isinstance(other, Rectangle):
            return Rectangle(self.width + other.width, self.height + other.height)
        return NotImplemented
    
    # Define behavior for == operator
    def __eq__(self, other):
        if not isinstance(other, Rectangle):
            return False
        return self.width == other.width and self.height == other.height
    
    # Define behavior for < operator
    def __lt__(self, other):
        if not isinstance(other, Rectangle):
            return NotImplemented
        return self.area() < other.area()

# Create rectangles
rect1 = Rectangle(5, 3)
rect2 = Rectangle(2, 4)

# Use the special methods
print(rect1)                # Uses __str__
print(repr(rect1))          # Uses __repr__

# Addition
rect3 = rect1 + rect2
print(f"Combined rectangle: {rect3}")

# Comparison
print(f"rect1 == rect2: {rect1 == rect2}")
print(f"rect1 < rect2: {rect1 < rect2}")

# Using the methods in a list context
rectangles = [Rectangle(5, 2), Rectangle(3, 4), Rectangle(2, 8)]
for rect in sorted(rectangles):
    print(f"{rect} has area {rect.area()}")

Teaching points:

  • Special methods customize how objects behave with built-in functions and operators
  • __init__ initializes new instances
  • __str__ defines the string representation for end-users (str(), print())
  • __repr__ defines the developer-friendly string representation (repr())
  • __add__ defines behavior for the + operator
  • __eq__ defines behavior for the == operator
  • __lt__ defines behavior for the < operator
  • Many other special methods exist for various operations

5. Inheritance and Method Overriding (25 minutes)

Explain inheritance and how to override methods:

# Base class (parent class)
class Animal:
    def __init__(self, name, species):
        self.name = name
        self.species = species
    
    def make_sound(self):
        print("Some generic animal sound")
    
    def __str__(self):
        return f"{self.name} the {self.species}"

# Derived class (child class)
class Dog(Animal):
    def __init__(self, name, breed):
        # Call the parent class's __init__ method
        super().__init__(name, "Dog")
        self.breed = breed
    
    # Override the make_sound method
    def make_sound(self):
        print("Woof! Woof!")
    
    # Add a new method
    def fetch(self, item):
        print(f"{self.name} fetches the {item}")
    
    def __str__(self):
        return f"{self.name} the {self.breed} dog"

# Another derived class
class Cat(Animal):
    def __init__(self, name, color):
        super().__init__(name, "Cat")
        self.color = color
    
    def make_sound(self):
        print("Meow!")
    
    def scratch(self):
        print(f"{self.name} scratches the furniture")

# Create instances
animal = Animal("Generic", "Animal")
dog = Dog("Buddy", "Golden Retriever")
cat = Cat("Whiskers", "Tabby")

# Call methods
print(animal)
animal.make_sound()

print(dog)
dog.make_sound()
dog.fetch("ball")

print(cat)
cat.make_sound()
cat.scratch()

# Check inheritance
print(f"Is dog an Animal? {isinstance(dog, Animal)}")
print(f"Is cat an Animal? {isinstance(cat, Animal)}")
print(f"Is dog a Dog? {isinstance(dog, Dog)}")
print(f"Is dog a Cat? {isinstance(dog, Cat)}")

Teaching points:

  • Inheritance allows a class to inherit attributes and methods from another class
  • The parent class is placed in parentheses after the child class name
  • super() calls methods from the parent class
  • Method overriding occurs when a child class redefines a method from the parent class
  • Child classes can add new methods and attributes
  • isinstance() checks if an object is an instance of a class or its subclasses

Multiple inheritance:

# Multiple inheritance example
class Flyable:
    def fly(self):
        print("Flying...")
    
    def land(self):
        print("Landing...")

class Swimmable:
    def swim(self):
        print("Swimming...")
    
    def dive(self):
        print("Diving...")

# Class that inherits from multiple base classes
class Duck(Animal, Flyable, Swimmable):
    def __init__(self, name):
        super().__init__(name, "Duck")
    
    def make_sound(self):
        print("Quack!")

# Create a duck and use all inherited methods
duck = Duck("Donald")
print(duck)
duck.make_sound()  # From Animal (overridden)
duck.fly()         # From Flyable
duck.swim()        # From Swimmable

# Check inheritance
print(f"Is duck an Animal? {isinstance(duck, Animal)}")
print(f"Is duck Flyable? {isinstance(duck, Flyable)}")
print(f"Is duck Swimmable? {isinstance(duck, Swimmable)}")

Teaching points:

  • Python supports multiple inheritance (inheriting from more than one class)
  • Child classes inherit all methods and attributes from all parent classes
  • Method resolution order (MRO) determines which method gets called when names conflict
  • Multiple inheritance should be used carefully to avoid complexity
  • Mixins (small, focused classes) are a common use case for multiple inheritance

6. Encapsulation and Access Control (15 minutes)

Discuss encapsulation and property decorators:

class BankAccount:
    def __init__(self, account_holder, balance=0):
        self.account_holder = account_holder
        # Private attribute (convention)
        self._balance = balance
        # Strongly private attribute (name mangling)
        self.__account_number = BankAccount._generate_account_number()
    
    @staticmethod
    def _generate_account_number():
        # In reality, this would generate a unique number
        import random
        return random.randint(10000, 99999)
    
    # Property getter
    @property
    def balance(self):
        return self._balance
    
    # Property setter
    @balance.setter
    def balance(self, value):
        if value < 0:
            raise ValueError("Balance cannot be negative")
        self._balance = value
    
    # Property for account number (read-only)
    @property
    def account_number(self):
        return self.__account_number
    
    def deposit(self, amount):
        if amount <= 0:
            raise ValueError("Deposit amount must be positive")
        self._balance += amount
        return self._balance
    
    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
        return self._balance

# Create an account
account = BankAccount("Alice", 1000)

# Access attributes
print(f"Account holder: {account.account_holder}")
print(f"Balance: {account.balance}")  # Uses the property getter
print(f"Account number: {account.account_number}")  # Uses the property getter

# Modify balance using property
try:
    account.balance = 1500  # Uses the property setter
    print(f"New balance: {account.balance}")
    
    account.balance = -500  # Will raise ValueError
except ValueError as e:
    print(f"Error: {e}")

# Try to access private attribute directly
try:
    print(account.__account_number)  # Will raise AttributeError
except AttributeError as e:
    print(f"Error: {e}")

# Name mangling can still be accessed, but it's not recommended
print(f"Accessing mangled name: {account._BankAccount__account_number}")

# Use methods
print(f"After deposit: {account.deposit(500)}")
print(f"After withdrawal: {account.withdraw(200)}")

Teaching points:

  • Encapsulation is about bundling data with methods that operate on that data
  • Python uses naming conventions rather than strict access control:
    • No prefix: Public (can be accessed from anywhere)
    • Single underscore _: Protected (indicates "don't touch this directly")
    • Double underscore __: Private (name mangling occurs)
  • Properties allow controlled access to attributes
  • @property creates a getter method
  • @attribute.setter creates a setter method
  • Using properties allows validation and computation when getting/setting attributes

7. Class Composition (15 minutes)

Explain composition as an alternative to inheritance:

# Composition example
class Engine:
    def __init__(self, type_="Gasoline"):
        self.type = type_
        self.is_running = False
    
    def start(self):
        if self.is_running:
            print("Engine is already running")
        else:
            self.is_running = True
            print(f"{self.type} engine started")
    
    def stop(self):
        if not self.is_running:
            print("Engine is already stopped")
        else:
            self.is_running = False
            print("Engine stopped")

class Wheels:
    def __init__(self, count=4):
        self.count = count
        self.position = 0  # 0: straight, -1: left, 1: right
    
    def turn(self, direction):
        if direction == "left":
            self.position = -1
            print("Wheels turned left")
        elif direction == "right":
            self.position = 1
            print("Wheels turned right")
        else:
            self.position = 0
            print("Wheels centered")

class Car:
    def __init__(self, make, model):
        self.make = make
        self.model = model
        self.engine = Engine()  # Composition
        self.wheels = Wheels()  # Composition
        self.speed = 0
    
    def start(self):
        print(f"Starting {self.make} {self.model}")
        self.engine.start()
    
    def stop(self):
        self.speed = 0
        print(f"Stopping {self.make} {self.model}")
        self.engine.stop()
    
    def accelerate(self, amount):
        if not self.engine.is_running:
            print("Cannot accelerate. Engine is not running.")
            return
        self.speed += amount
        print(f"Accelerating to {self.speed} mph")
    
    def turn(self, direction):
        self.wheels.turn(direction)
    
    def describe(self):
        return (f"{self.make} {self.model}, "
                f"Engine: {self.engine.type}, "
                f"Wheels: {self.wheels.count}")

# Create a car and use its components
car = Car("Toyota", "Corolla")
car.start()
car.accelerate(30)
car.turn("left")
car.accelerate(20)
car.turn("straight")
car.stop()
print(car.describe())

# Create an electric car
electric_car = Car("Tesla", "Model 3")
electric_car.engine = Engine("Electric")  # Replace the engine
electric_car.start()
electric_car.accelerate(50)
electric_car.stop()
print(electric_car.describe())

Teaching points:

  • Composition means building classes that contain instances of other classes
  • "Has-a" relationship rather than inheritance's "is-a" relationship
  • More flexible than inheritance in many cases
  • Allows objects to have components that can be changed at runtime
  • Design principle: "Favor composition over inheritance"
  • Both inheritance and composition have their places

8. Practical Example: Simple Game (20 minutes)

Build a simple game to demonstrate OOP concepts:

import random

class Character:
    def __init__(self, name, health=100, attack_power=10):
        self.name = name
        self.health = health
        self.attack_power = attack_power
        self.is_alive = True
    
    def take_damage(self, damage):
        self.health -= damage
        print(f"{self.name} takes {damage} damage. Health: {self.health}")
        if self.health <= 0:
            self.health = 0
            self.is_alive = False
            print(f"{self.name} has been defeated!")
    
    def attack(self, target):
        if not self.is_alive:
            print(f"{self.name} cannot attack because they are defeated.")
            return
        
        damage = random.randint(1, self.attack_power)
        print(f"{self.name} attacks {target.name} for {damage} damage!")
        target.take_damage(damage)
    
    def heal(self, amount):
        if not self.is_alive:
            print(f"Cannot heal {self.name} because they are defeated.")
            return
            
        self.health += amount
        print(f"{self.name} heals for {amount} health. Health: {self.health}")

class Warrior(Character):
    def __init__(self, name):
        super().__init__(name, health=120, attack_power=15)
        self.shield = 5
    
    def take_damage(self, damage):
        reduced_damage = max(1, damage - self.shield)
        print(f"{self.name}'s shield absorbs {damage - reduced_damage} damage.")
        super().take_damage(reduced_damage)
    
    def power_attack(self, target):
        if not self.is_alive:
            print(f"{self.name} cannot attack because they are defeated.")
            return
            
        damage = random.randint(self.attack_power, self.attack_power * 2)
        print(f"{self.name} performs a power attack on {target.name} for {damage} damage!")
        target.take_damage(damage)

class Mage(Character):
    def __init__(self, name):
        super().__init__(name, health=80, attack_power=8)
        self.mana = 100
    
    def fireball(self, target):
        if not self.is_alive:
            print(f"{self.name} cannot cast because they are defeated.")
            return
            
        mana_cost = 20
        if self.mana < mana_cost:
            print(f"{self.name} doesn't have enough mana to cast Fireball.")
            return
            
        self.mana -= mana_cost
        damage = random.randint(15, 30)
        print(f"{self.name} casts Fireball at {target.name} for {damage} damage! Mana: {self.mana}")
        target.take_damage(damage)
    
    def heal_spell(self, target):
        if not self.is_alive:
            print(f"{self.name} cannot cast because they are defeated.")
            return
            
        mana_cost = 15
        if self.mana < mana_cost:
            print(f"{self.name} doesn't have enough mana to cast Heal.")
            return
            
        self.mana -= mana_cost
        heal_amount = random.randint(10, 25)
        print(f"{self.name} casts Heal on {target.name}. Mana: {self.mana}")
        target.heal(heal_amount)

# Simple game loop
def battle(player, enemy):
    print(f"\nBattle begins: {player.name} vs {enemy.name}")
    
    round_num = 1
    while player.is_alive and enemy.is_alive:
        print(f"\nRound {round_num}")
        
        # Player's turn
        print(f"\n{player.name}'s turn")
        if isinstance(player, Warrior):
            if random.random() < 0.7:  # 70% chance for normal attack
                player.attack(enemy)
            else:
                player.power_attack(enemy)
        elif isinstance(player, Mage):
            choice = random.randint(1, 3)
            if choice == 1:
                player.attack(enemy)
            elif choice == 2:
                player.fireball(enemy)
            else:
                player.heal_spell(player)
        
        # Check if enemy is defeated
        if not enemy.is_alive:
            break
        
        # Enemy's turn
        print(f"\n{enemy.name}'s turn")
        if isinstance(enemy, Warrior):
            if random.random() < 0.7:  # 70% chance for normal attack
                enemy.attack(player)
            else:
                enemy.power_attack(player)
        elif isinstance(enemy, Mage):
            choice = random.randint(1, 3)
            if choice == 1:
                enemy.attack(player)
            elif choice == 2:
                enemy.fireball(player)
            else:
                enemy.heal_spell(enemy)
        
        round_num += 1
    
    # Determine the winner
    print("\nBattle ended!")
    if player.is_alive:
        print(f"{player.name} is victorious!")
    else:
        print(f"{enemy.name} is victorious!")

# Create characters
warrior = Warrior("Aragorn")
mage = Mage("Gandalf")

# Start the battle simulation
battle(warrior, mage)

Teaching points:

  • OOP is excellent for modeling game entities
  • Inheritance allows us to create specialized character types
  • Method overriding customizes behavior for different classes
  • We can use composition to add new features (like items or abilities)
  • Random elements add excitement and unpredictability
  • Complex interactions can be built from relatively simple components

9. Guided Practice (20 minutes)

Have students create these classes:

  1. Library system:

    # Create a simple library management system
    
    class Book:
        def __init__(self, title, author, isbn):
            self.title = title
            self.author = author
            self.isbn = isbn
            self.is_checked_out = False
        
        def __str__(self):
            status = "checked out" if self.is_checked_out else "available"
            return f"{self.title} by {self.author} ({self.isbn}) - {status}"
        
        def check_out(self):
            if self.is_checked_out:
                return False
            self.is_checked_out = True
            return True
        
        def return_book(self):
            if not self.is_checked_out:
                return False
            self.is_checked_out = False
            return True
    
    class Library:
        def __init__(self, name):
            self.name = name
            self.books = []
        
        def add_book(self, book):
            self.books.append(book)
            print(f"Added {book.title} to {self.name}")
        
        def remove_book(self, isbn):
            for i, book in enumerate(self.books):
                if book.isbn == isbn:
                    removed = self.books.pop(i)
                    print(f"Removed {removed.title} from {self.name}")
                    return True
            print(f"Book with ISBN {isbn} not found")
            return False
        
        def check_out_book(self, isbn):
            for book in self.books:
                if book.isbn == isbn:
                    if book.check_out():
                        print(f"{book.title} has been checked out")
                        return True
                    else:
                        print(f"{book.title} is already checked out")
                        return False
            print(f"Book with ISBN {isbn} not found")
            return False
        
        def return_book(self, isbn):
            for book in self.books:
                if book.isbn == isbn:
                    if book.return_book():
                        print(f"{book.title} has been returned")
                        return True
                    else:
                        print(f"{book.title} was not checked out")
                        return False
            print(f"Book with ISBN {isbn} not found")
            return False
        
        def list_books(self):
            if not self.books:
                print("The library is empty")
                return
                
            print(f"\nBooks in {self.name}:")
            for book in self.books:
                print(f"- {book}")
        
        def list_available_books(self):
            available = [book for book in self.books if not book.is_checked_out]
            if not available:
                print("No books are available")
                return
                
            print(f"\nAvailable books in {self.name}:")
            for book in available:
                print(f"- {book}")
    
    # Create a library and some books
    library = Library("City Library")
    
    books = [
        Book("The Hobbit", "J.R.R. Tolkien", "978-0-261-10295-3"),
        Book("Harry Potter", "J.K. Rowling", "978-0-7475-3269-6"),
        Book("To Kill a Mockingbird", "Harper Lee", "978-0-06-112008-4")
    ]
    
    for book in books:
        library.add_book(book)
    
    # Test library functionality
    library.list_books()
    library.check_out_book("978-0-261-10295-3")
    library.list_available_books()
    library.return_book("978-0-261-10295-3")
    library.list_available_books()
    
  2. Shape hierarchy:

    # Create a hierarchy of shape classes
    import math
    
    class Shape:
        def __init__(self, color="white"):
            self.color = color
        
        def area(self):
            # Abstract method, to be implemented by subclasses
            raise NotImplementedError("Subclasses must implement area()")
        
        def perimeter(self):
            # Abstract method, to be implemented by subclasses
            raise NotImplementedError("Subclasses must implement perimeter()")
        
        def __str__(self):
            return f"{self.__class__.__name__} (color: {self.color})"
    
    class Circle(Shape):
        def __init__(self, radius, color="white"):
            super().__init__(color)
            self.radius = radius
        
        def area(self):
            return math.pi * self.radius ** 2
        
        def perimeter(self):
            return 2 * math.pi * self.radius
        
        def __str__(self):
            return f"{super().__str__()}, radius: {self.radius}"
    
    class Rectangle(Shape):
        def __init__(self, width, height, color="white"):
            super().__init__(color)
            self.width = width
            self.height = height
        
        def area(self):
            return self.width * self.height
        
        def perimeter(self):
            return 2 * (self.width + self.height)
        
        def __str__(self):
            return f"{super().__str__()}, width: {self.width}, height: {self.height}"
    
    class Square(Rectangle):
        def __init__(self, side, color="white"):
            super().__init__(side, side, color)
            self.side = side
        
        def __str__(self):
            return f"{self.__class__.__name__} (color: {self.color}), side: {self.side}"
    
    # Create some shapes
    shapes = [
        Circle(5, "red"),
        Rectangle(4, 6, "blue"),
        Square(3, "green")
    ]
    
    # Display shape information
    for shape in shapes:
        print(f"\n{shape}")
        print(f"Area: {shape.area():.2f}")
        print(f"Perimeter: {shape.perimeter():.2f}")
    
    # Demonstrate polymorphism
    def print_shape_info(shape):
        print(f"\nShape: {shape}")
        print(f"Area: {shape.area():.2f}")
        print(f"Perimeter: {shape.perimeter():.2f}")
    
    # Use the function with different shapes
    print("\nUsing polymorphism:")
    for shape in shapes:
        print_shape_info(shape)
    

10. Challenge Activity (15 minutes)

For more advanced students:

  1. Bank account system:

    # Implement a bank account class hierarchy
    
    class BankAccount:
        def __init__(self, account_number, owner_name, balance=0):
            self.account_number = account_number
            self.owner_name = owner_name
            self._balance = balance
            self.transactions = []
            self.add_transaction("Initial deposit", balance)
        
        @property
        def balance(self):
            return self._balance
        
        def deposit(self, amount):
            if amount <= 0:
                raise ValueError("Deposit amount must be positive")
            self._balance += amount
            self.add_transaction("Deposit", amount)
            return True
        
        def withdraw(self, amount):
            if amount <= 0:
                raise ValueError("Withdrawal amount must be positive")
            if amount > self._balance:
                print("Insufficient funds")
                return False
            self._balance -= amount
            self.add_transaction("Withdrawal", -amount)
            return True
        
        def add_transaction(self, transaction_type, amount):
            import datetime
            transaction = {
                "date": datetime.datetime.now(),
                "type": transaction_type,
                "amount": amount,
                "balance": self._balance
            }
            self.transactions.append(transaction)
        
        def get_transaction_history(self):
            for transaction in self.transactions:
                date_str = transaction["date"].strftime("%Y-%m-%d %H:%M:%S")
                amount = transaction["amount"]
                sign = "+" if amount >= 0 else ""
                print(f"{date_str} | {transaction['type']}: {sign}{amount:.2f} | Balance: {transaction['balance']:.2f}")
        
        def __str__(self):
            return f"Account #{self.account_number} ({self.owner_name}) - Balance: ${self._balance:.2f}"
    
    class SavingsAccount(BankAccount):
        def __init__(self, account_number, owner_name, balance=0, interest_rate=0.01):
            super().__init__(account_number, owner_name, balance)
            self.interest_rate = interest_rate
            self.withdrawal_limit = 3
            self.withdrawals_this_month = 0
        
        def withdraw(self, amount):
            if self.withdrawals_this_month >= self.withdrawal_limit:
                print(f"Withdrawal limit of {self.withdrawal_limit} reached for this month")
                return False
            if super().withdraw(amount):
                self.withdrawals_this_month += 1
                return True
            return False
        
        def apply_interest(self):
            interest = self._balance * self.interest_rate
            self._balance += interest
            self.add_transaction("Interest", interest)
            print(f"Applied interest: ${interest:.2f}")
        
        def reset_withdrawals(self):
            self.withdrawals_this_month = 0
            print("Monthly withdrawal limit has been reset")
        
        def __str__(self):
            return f"Savings Account #{self.account_number} ({self.owner_name}) - Balance: ${self._balance:.2f}, Interest Rate: {self.interest_rate*100:.1f}%"
    
    class CheckingAccount(BankAccount):
        def __init__(self, account_number, owner_name, balance=0, overdraft_limit=100):
            super().__init__(account_number, owner_name, balance)
            self.overdraft_limit = overdraft_limit
        
        def withdraw(self, amount):
            if amount <= 0:
                raise ValueError("Withdrawal amount must be positive")
            if amount > (self._balance + self.overdraft_limit):
                print("Exceeds available balance and overdraft limit")
                return False
            if amount > self._balance:
                overdraft = amount - self._balance
                self._balance = 0
                # Apply overdraft fee
                fee = 5
                self._balance -= fee
                self.add_transaction("Withdrawal", -self._balance)
                self.add_transaction("Overdraft fee", -fee)
                print(f"Used ${overdraft:.2f} from overdraft. Fee: ${fee:.2f}")
            else:
                self._balance -= amount
                self.add_transaction("Withdrawal", -amount)
            return True
        
        def __str__(self):
            available = self._balance + self.overdraft_limit
            return f"Checking Account #{self.account_number} ({self.owner_name}) - Balance: ${self._balance:.2f}, Available: ${available:.2f}"
    
    # Test the accounts
    print("Creating accounts...")
    savings = SavingsAccount("S123", "Alice", 1000, 0.02)
    checking = CheckingAccount("C456", "Bob", 500, 200)
    
    print(savings)
    print(checking)
    
    print("\nPerforming transactions...")
    savings.deposit(500)
    savings.withdraw(200)
    savings.apply_interest()
    
    checking.deposit(100)
    checking.withdraw(550)  # Should trigger overdraft
    
    print("\nAccount status after transactions:")
    print(savings)
    print(checking)
    
    print("\nTransaction history for savings account:")
    savings.get_transaction_history()
    
    print("\nTransaction history for checking account:")
    checking.get_transaction_history()
    
  2. Simple game with inventory:

    # Create a simple game with characters and inventory
    import random
    
    class Item:
        def __init__(self, name, description, value=0, weight=0):
            self.name = name
            self.description = description
            self.value = value
            self.weight = weight
        
        def __str__(self):
            return f"{self.name} ({self.weight} kg, {self.value} gold)"
    
    class Weapon(Item):
        def __init__(self, name, description, damage, value=0, weight=0):
            super().__init__(name, description, value, weight)
            self.damage = damage
        
        def __str__(self):
            return f"{self.name} - Damage: {self.damage} ({self.weight} kg, {self.value} gold)"
    
    class Armor(Item):
        def __init__(self, name, description, defense, value=0, weight=0):
            super().__init__(name, description, value, weight)
            self.defense = defense
        
        def __str__(self):
            return f"{self.name} - Defense: {self.defense} ({self.weight} kg, {self.value} gold)"
    
    class Character:
        def __init__(self, name, health=100):
            self.name = name
            self.health = health
            self.max_health = health
            self.inventory = []
            self.gold = 0
            self.equipped_weapon = None
            self.equipped_armor = None
        
        def add_to_inventory(self, item):
            self.inventory.append(item)
            print(f"{self.name} picked up {item.name}")
        
        def remove_from_inventory(self, item):
            if item in self.inventory:
                self.inventory.remove(item)
                print(f"{self.name} removed {item.name} from inventory")
                return True
            print(f"{item.name} not in inventory")
            return False
        
        def equip_weapon(self, weapon):
            if weapon not in self.inventory:
                print(f"{weapon.name} not in inventory")
                return False
            
            if not isinstance(weapon, Weapon):
                print(f"{weapon.name} is not a weapon")
                return False
            
            self.equipped_weapon = weapon
            print(f"{self.name} equipped {weapon.name}")
            return True
        
        def equip_armor(self, armor):
            if armor not in self.inventory:
                print(f"{armor.name} not in inventory")
                return False
            
            if not isinstance(armor, Armor):
                print(f"{armor.name} is not armor")
                return False
            
            self.equipped_armor = armor
            print(f"{self.name} equipped {armor.name}")
            return True
        
        def attack(self, target):
            base_damage = 5  # Unarmed damage
            if self.equipped_weapon:
                base_damage = self.equipped_weapon.damage
            
            # Random variation in damage
            damage = random.randint(max(1, base_damage - 2), base_damage + 2)
            
            print(f"{self.name} attacks {target.name} for {damage} damage!")
            target.take_damage(damage)
        
        def take_damage(self, damage):
            defense = 0
            if self.equipped_armor:
                defense = self.equipped_armor.defense
            
            actual_damage = max(1, damage - defense)
            self.health -= actual_damage
            
            if defense > 0:
                print(f"{self.name}'s armor absorbs {damage - actual_damage} damage.")
            
            print(f"{self.name} takes {actual_damage} damage. Health: {self.health}/{self.max_health}")
            
            if self.health <= 0:
                self.health = 0
                print(f"{self.name} has been defeated!")
        
        def show_inventory(self):
            if not self.inventory:
                print(f"{self.name}'s inventory is empty")
                return
            
            print(f"\n{self.name}'s Inventory:")
            print(f"Gold: {self.gold}")
            for item in self.inventory:
                equipped = ""
                if item == self.equipped_weapon:
                    equipped = " (Equipped Weapon)"
                elif item == self.equipped_armor:
                    equipped = " (Equipped Armor)"
                print(f"- {item}{equipped}")
    
    # Create items
    sword = Weapon("Steel Sword", "A sharp sword made of steel", 15, 50, 2)
    axe = Weapon("Battle Axe", "A heavy battle axe", 20, 60, 4)
    leather_armor = Armor("Leather Armor", "Light armor made of leather", 5, 40, 3)
    plate_armor = Armor("Plate Armor", "Heavy armor made of metal plates", 10, 100, 8)
    healing_potion = Item("Healing Potion", "Restores 20 health points", 30, 0.5)
    
    # Create characters
    player = Character("Hero", 100)
    enemy = Character("Goblin", 50)
    
    # Add items to inventory
    player.add_to_inventory(sword)
    player.add_to_inventory(leather_armor)
    player.add_to_inventory(healing_potion)
    player.gold = 100
    
    enemy.add_to_inventory(axe)
    enemy.equip_weapon(axe)
    
    # Display inventories
    player.show_inventory()
    enemy.show_inventory()
    
    # Equip items
    player.equip_weapon(sword)
    player.equip_armor(leather_armor)
    
    # Simulate a battle
    print("\nBattle begins!")
    while player.health > 0 and enemy.health > 0:
        player.attack(enemy)
        if enemy.health <= 0:
            break
        
        enemy.attack(player)
    
    print("\nBattle ended!")
    if player.health > 0:
        print(f"{player.name} is victorious!")
    else:
        print(f"{enemy.name} is victorious!")
    

11. Review and Discussion (10 minutes)

  • Review the key concepts covered
  • Ask students to explain in their own words:
    • What is a class and what is an object?
    • What is inheritance and when would you use it?
    • How do methods and attributes work in Python?
    • What are the benefits of using OOP?

Common Challenges and Solutions

  • Understanding self: Students often struggle with why self is needed. Explain that it refers to the specific instance being operated on.
  • Class vs. Instance attributes: Clarify the difference with examples and diagrams.
  • Inheritance vs. Composition: Guide students on when to use each approach.
  • Method overriding confusion: Ensure students understand how parent methods can be called with super().

Extension Activities

For students who finish early:

  • Have them extend the game example with new character types
  • Challenge them to implement a more complex class hierarchy for a specific domain
  • Ask them to implement a design pattern (like Singleton or Factory)

Assessment

Look for these indicators of understanding:

  • Students can define classes and create objects correctly
  • They understand the relationship between classes and instances
  • They can use inheritance appropriately
  • They can apply OOP concepts to solve problems

Resources

Chapter 9: Classes and Objects | Teacher's Guide