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
classkeyword __init__is a special method called when an object is createdselfrefers 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
clsand are defined using the@classmethoddecorator - Static methods don't need instance or class data and are defined using the
@staticmethoddecorator - 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
@propertycreates a getter method@attribute.settercreates 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:
-
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() -
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:
-
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() -
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 whyselfis 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