IS4010: AI-Enhanced Application Development

Week 6: Object-Oriented Programming - Interactive Notebook

Instructor: Brandon M. Greenwell Course: IS4010 - AI-Enhanced Application Development


๐Ÿ—๏ธ Welcome to Object-Oriented Programming

The foundation of scalable software architecture ๐ŸŽฏ

Object-Oriented Programming (OOP) revolutionizes how we design and structure applications by providing:

  • Real-world modeling: Create digital representations of real-world entities and their interactions
  • Career relevance: Essential for modern software development, system design interviews, and team collaboration
  • Scalability: Build applications that can grow from prototypes to enterprise-level systems
  • Code organization: Transform chaotic procedural code into elegant, maintainable architectures

๐Ÿ“ Additional Notes: Historical context: OOP emerged in the 1960s with Simula, popularized by Smalltalk in the 1970s. Industry adoption: Nearly every major software system uses OOP principles - from operating systems to web frameworks. Career impact: OOP concepts appear in 90% of technical interviews for software engineering roles.

Real-World Examples You Use Daily

  • Instagram: User, Post, Comment, Story classes
  • Banking systems: Account, Transaction, Customer classes
  • Games: Player, Inventory, Character, Weapon classes
  • E-commerce: Product, ShoppingCart, Order, Payment classes

Learning Objectives

By the end of this notebook, you will be able to: - Distinguish when to use classes instead of functions for complex state management - Create classes with constructors (__init__) and string representations (__str__) - Implement methods that give objects behavior and encapsulate functionality - Use inheritance to create specialized classes and enable code reuse - Apply advanced OOP concepts like properties, class methods, and static methods

How to Use This Notebook

  • Run each cell by clicking the play button or pressing Shift+Enter
  • Experiment with the code - modify values and see what happens
  • Complete the exercises marked with ๐Ÿ‹๏ธโ€โ™€๏ธ for hands-on practice
  • Use AI assistants to help you understand concepts or explore variations

Session 1: From Functions to Objects

๐Ÿค” When Functions Arenโ€™t Enough: A Shopping Cart Story

The Problem: Managing complex state with functions

Building an e-commerce shopping cart system reveals the limitations of the function-only approach:

  • Scenario: Creating an online shopping cart (like Amazon, Target, or any e-commerce site)
  • Challenge: Multiple pieces of related data (items, totals, discounts, tax rates) need coordination
  • Function approach: Becomes unwieldy as complexity grows
  • Real pain points: Parameter passing chaos, state management nightmares, code duplication

Real-World Shopping Cart Requirements

  • Add/remove items with quantities
  • Calculate subtotals and tax
  • Apply discounts and promotions
  • Save cart state between sessions
  • Handle multiple payment methods
  • Track inventory availability

โŒ The Function Approach: Shopping Cart Chaos

Watch how quickly this becomes unwieldy as we try to manage cart state with only functions:

# Shopping cart implementation using functions - gets messy fast!

def create_cart():
    """Create a new empty shopping cart."""
    return {"items": [], "subtotal": 0, "tax_rate": 0.08, "discount": 0}

def add_item(cart, name, price, quantity=1):
    """Add an item to the cart.
    
    Parameters
    ----------
    cart : dict
        The shopping cart to which the item will be added.
    name : str
        The name of the item.
    price : float
        The price of a single unit of the item.
    quantity : int, optional
        The quantity of the item to add (default is 1).
    Returns
    -------
    dict        The updated shopping cart with the new item added and subtotal updated.

    """
    cart["items"].append({"name": name, "price": price, "qty": quantity})
    cart["subtotal"] += price * quantity
    return cart

def apply_discount(cart, discount_percent):
    """Apply a discount to the cart."""
    cart["discount"] = discount_percent
    return cart

def calculate_total(cart):
    """Calculate the final total after discount and tax."""
    discounted = cart["subtotal"] * (1 - cart["discount"]/100)
    return discounted * (1 + cart["tax_rate"])

def display_cart(cart):
    """Display cart contents."""
    print("Shopping Cart:")
    for item in cart["items"]:
        print(f"  {item['name']} x{item['qty']} - ${item['price']:.2f} each")
    print(f"  Subtotal: ${cart['subtotal']:.2f}")
    if cart["discount"] > 0:
        print(f"  Discount: {cart['discount']}%")
    print(f"  Total: ${calculate_total(cart):.2f}")

# Usage - notice how cumbersome this becomes
cart = create_cart()
cart = add_item(cart, "Laptop", 999.99)
cart = add_item(cart, "Mouse", 29.99)
cart = apply_discount(cart, 10)
display_cart(cart)

some_unrelated_dict = {"items": ["a", "b"]}
add_item(some_unrelated_dict, "Test", 10)  # This will cause an error since the dict doesn't have the expected structure!
Shopping Cart:
  Laptop x1 - $999.99 each
  Mouse x1 - $29.99 each
  Subtotal: $1029.98
  Discount: 10%
  Total: $1001.14
---------------------------------------------------------------------------
KeyError                                  Traceback (most recent call last)
Cell In[6], line 57
     54 display_cart(cart)
     56 some_unrelated_dict = {"items": ["a", "b"]}
---> 57 add_item(some_unrelated_dict, "Test", 10)  # This will cause an error since the dict doesn't have the expected structure!

Cell In[6], line 26, in add_item(cart, name, price, quantity)
      8 """Add an item to the cart.
      9 
     10 Parameters
   (...)     23 
     24 """
     25 cart["items"].append({"name": name, "price": price, "qty": quantity})
---> 26 cart["subtotal"] += price * quantity
     27 return cart

KeyError: 'subtotal'

๐Ÿ‹๏ธโ€โ™€๏ธ Exercise 1: Feel the Pain

Try to add more functionality to the function-based cart: 1. Add a function to remove items 2. Add a function to change item quantities 3. Notice how many parameters you need to pass around!

# Your code here - try adding remove_item() and change_quantity() functions
# Notice how complex the parameter passing becomes!

def remove_item(cart, name):
    """Remove an item from the cart."""
    for item in cart["items"]:
        if item["name"] == name:
            cart["subtotal"] -= item["price"] * item["qty"]
            cart["items"].remove(item)
            break
    return cart

def change_quantity(cart, name, quantity):
    """Change the quantity of an item in the cart."""
    for item in cart["items"]:
        if item["name"] == name:
            cart["subtotal"] -= item["price"] * item["qty"]
            item["qty"] = quantity
            cart["subtotal"] += item["price"] * item["qty"]
            break
    return cart

โœ… The OOP Solution: Clean and Scalable

Now letโ€™s see the dramatic improvement with object-oriented programming:

Why Classes Excel Here

  • Encapsulation: All cart data and behavior bundled together
  • State management: No parameter passing chaos - data lives with the object
  • Extensibility: Easy to add new methods and features
  • Reusability: Create multiple independent cart instances
  • Maintainability: Changes are localized to the class

๐Ÿ“ Additional Notes: Professional context: Every major framework (Django, Flask, React) uses this pattern extensively. Code organization: Classes provide natural boundaries for related functionality. Testing: Much easier to unit test class methods than function chains.

class ShoppingCart:
    """Represents a shopping cart with items, discounts, and tax calculation."""

    def __init__(self, tax_rate=0.08):
        """Initialize a new shopping cart."""
        self.items = []
        self.tax_rate = tax_rate
        self.discount_percent = 0

    def add_item(self, name, price, quantity=1):
        """Add an item to the cart."""
        self.items.append({"name": name, "price": price, "qty": quantity})

    def apply_discount(self, discount_percent):
        """Apply a discount to the entire cart."""
        self.discount_percent = discount_percent

    

    @property
    def subtotal(self):
        """Calculate subtotal before discount and tax."""
        return sum(item["price"] * item["qty"] for item in self.items)

    @property
    def total(self):
        """Calculate final total after discount and tax."""
        discounted = self.subtotal * (1 - self.discount_percent/100)
        return discounted * (1 + self.tax_rate)

    def __str__(self):
        """Return a string representation of the cart."""
        lines = ["Shopping Cart:"]
        for item in self.items:
            lines.append(f"  {item['name']} x{item['qty']} - ${item['price']:.2f} each")
        lines.append(f"  Subtotal: ${self.subtotal:.2f}")
        if self.discount_percent > 0:
            lines.append(f"  Discount: {self.discount_percent}%")
        lines.append(f"  Total: ${self.total:.2f}")
        return "\n".join(lines)



# Usage is much cleaner!
cart = ShoppingCart()
#print(cart)
print(cart.items)
cart.add_item("Laptop", 999.99)
#print(cart)
cart.add_item("Mouse", 29.99)
cart.apply_discount(10)
#print(cart)
print(cart.items)

print(cart)
[]
[{'name': 'Laptop', 'price': 999.99, 'qty': 1}, {'name': 'Mouse', 'price': 29.99, 'qty': 1}]
Shopping Cart:
  Laptop x1 - $999.99 each
  Mouse x1 - $29.99 each
  Subtotal: $1029.98
  Discount: 10%
  Total: $1001.14

๐Ÿ‹๏ธโ€โ™€๏ธ Exercise 2: Extend the Class

Add methods to the ShoppingCart class: 1. remove_item(name) - remove an item by name 2. clear() - empty the cart 3. item_count - property that returns total number of items

class ShoppingCart:
    """Represents a shopping cart with items, discounts, and tax calculation."""

    def __init__(self, tax_rate=0.08):
        """Initialize a new shopping cart."""
        self.items = []
        self.tax_rate = tax_rate
        self.discount_percent = 0

    def add_item(self, name, price, quantity=1):
        """Add an item to the cart."""
        self.items.append({"name": name, "price": price, "qty": quantity})

    def remove_item(self, name):
        """Remove an item from the cart by name."""
        # Keep only items that don't match the name
        self.items = [item for item in self.items if item["name"] != name]

    def clear(self):
        """Empty the cart."""
        self.items = []

    def apply_discount(self, discount_percent):
        """Apply a discount to the entire cart."""
        self.discount_percent = discount_percent

    @property
    def item_count(self):
        """Return total number of items in the cart."""
        return sum(item["qty"] for item in self.items)
    
    @property
    def subtotal(self):
        """Calculate subtotal before discount and tax."""
        return sum(item["price"] * item["qty"] for item in self.items)

    @property
    def total(self):
        """Calculate final total after discount and tax."""
        discounted = self.subtotal * (1 - self.discount_percent/100)
        return discounted * (1 + self.tax_rate)

    def __str__(self):
        """Return a string representation of the cart."""
        lines = ["Shopping Cart:"]
        if not self.items:
            lines.append("  (Empty)")
        else:
            for item in self.items:
                lines.append(f"  {item['name']} x{item['qty']} - ${item['price']:.2f} each")
            lines.append(f"  Subtotal: ${self.subtotal:.2f}")
            if self.discount_percent > 0:
                lines.append(f"  Discount: {self.discount_percent}%")
            lines.append(f"  Total: ${self.total:.2f}")
            lines.append(f"  Item Count: {self.item_count}")
            
        return "\n".join(lines)

# Test the enhanced class
cart = ShoppingCart()
cart.add_item("Laptop", 999.99)
cart.add_item("Mouse", 29.99, 2)
print("--- Initial Cart ---")
print(cart)

cart.remove_item("Mouse")
print("\n--- After removing Mouse ---")
print(cart)

cart.clear()
print("\n--- After clearing ---")
print(cart)

Part 2: Understanding Classes and Objects

๐Ÿ”ง The __init__ Method: Object Construction

Understanding the constructor method is fundamental to creating useful classes:

  • The __init__ method is a special constructor function
  • Automatic execution: Runs immediately when a new object is created
  • Purpose: Initialize the objectโ€™s attributes with starting values
  • The self parameter: References the specific instance being created
  • Convention: Always the first parameter in instance methods

๐Ÿ“ Additional Notes: Modern Python: Use type hints for better code clarity and IDE support. Instance attributes: Each object has its own copy of the data in separate memory. Common errors: Forgetting self or incorrect parameter order are frequent mistakes when starting with OOP.

Key Constructor Concepts

  • Instance attributes: Each object has its own copy of the data
  • Parameter validation: Constructors can enforce business rules
  • Default values: Provide sensible defaults for optional parameters
  • Documentation: Docstrings explain the class purpose and usage

Basic Class Structure

Letโ€™s explore the fundamental concepts with a simple example:

class BankAccount:
    """Represents a bank account with balance tracking."""

    def __init__(self, account_holder: str, initial_balance: float = 0.0):
        """Initialize a new bank account."""
        # Instance attributes - unique to each account
        self.account_holder = account_holder
        self.balance = initial_balance
        self.transaction_history = []

    def deposit(self, amount: float):
        """Add money to the account."""
        self.balance += amount
        self.transaction_history.append(f"Deposited ${amount:.2f}")
        print(f"Deposited ${amount:.2f}. New balance: ${self.balance:.2f}")

    def withdraw(self, amount: float):
        """Remove money from the account if sufficient funds exist."""
        if amount <= self.balance:
            self.balance -= amount
            self.transaction_history.append(f"Withdrew ${amount:.2f}")
            print(f"Withdrew ${amount:.2f}. New balance: ${self.balance:.2f}")
        else:
            print(f"Insufficient funds! Current balance: ${self.balance:.2f}")

    def __str__(self):
        """Return a string representation of the account."""
        return f"Account holder: {self.account_holder}, Balance: ${self.balance:.2f}"

# Create specific account instances
alice_account = BankAccount("Alice Johnson", 1000.0)
bob_account = BankAccount("Bob Smith")  # Uses default balance of 0.0

print(alice_account)
print(bob_account)

# Each account operates independently
alice_account.deposit(500)
bob_account.deposit(100)
alice_account.withdraw(200)

print("\nFinal states:")
print(alice_account)
print(bob_account)

๐Ÿ‹๏ธโ€โ™€๏ธ Exercise 3: Bank Account Features

Enhance the BankAccount class: 1. Add a get_transaction_history() method 2. Add an account_number attribute (you can use a simple counter) 3. Add a minimum balance requirement

# Your enhanced BankAccount class here

๐Ÿ“ The __str__ Method: Human-Readable Representation

Making your objects print meaningfully is essential for debugging and user interfaces:

  • The __str__ method defines how objects appear when printed
  • Automatic invocation: Called by print(), str(), and string formatting
  • User-friendly: Should return meaningful information for end users
  • AI assistant friendly: Perfect boilerplate code to generate with AI tools
  • Debugging essential: Makes testing and troubleshooting much easier

๐Ÿ“ Additional Notes: Professional practice: All production classes should have meaningful __str__ methods - they save hours of debugging time. String formatting: Modern Python uses f-strings for clean, readable string construction. Unicode symbols: Adding symbols like โœ“ makes output more engaging and readable.

String Representation Best Practices

  • Include key identifying information: Name, ID, or other unique attributes
  • Keep it concise but informative: One line is usually sufficient
  • Use consistent formatting: Establish patterns across your application
  • Consider the audience: End users vs developers may need different information
from datetime import datetime

class User:
    """Represents a user in a social media application."""

    def __init__(self, username: str, email: str, join_date: str = None):
        self.username = username
        self.email = email
        self.join_date = join_date or datetime.now().strftime("%Y-%m-%d")
        self.followers = 0
        self.following = 0
        self.posts = []
        self.is_verified = False

    def follow_user(self, other_user):
        """Follow another user."""
        self.following += 1
        other_user.followers += 1
        print(f"@{self.username} is now following @{other_user.username}")

    def create_post(self, content: str):
        """Create a new post."""
        post = {
            "content": content,
            "timestamp": datetime.now().strftime("%Y-%m-%d %H:%M"),
            "likes": 0
        }
        self.posts.append(post)
        print(f"@{self.username} posted: {content}")

    def verify_account(self):
        """Verify the user account."""
        self.is_verified = True
        print(f"@{self.username} is now verified! โœ“")

    def __str__(self) -> str:
        """Return a user-friendly string representation."""
        verification = "โœ“" if self.is_verified else ""
        return f"@{self.username}{verification} ({self.followers} followers, {self.following} following) - Joined {self.join_date}"

# Create some users and let them interact
grace = User("grace_hopper", "grace@example.com", "2023-01-15")
ada = User("ada_lovelace", "ada@example.com", "2023-02-20")

print(grace)
print(ada)

# Simulate social media activity
grace.create_post("Debugging is like being the detective in a crime movie where you are also the murderer.")
ada.follow_user(grace)
ada.verify_account()

print("\nUpdated profiles:")
print(grace)
print(ada)

๐Ÿ‹๏ธโ€โ™€๏ธ Exercise 4: Social Media Features

Extend the User class with: 1. A like_post(user, post_index) method 2. A get_recent_posts(count=5) method 3. A bio attribute and update_bio(new_bio) method

# Your enhanced User class here

Part 3: Methods and Behavior

๐ŸŽฎ Understanding Object Behavior Through Game Characters

Methods define what objects can DO, not just what data they store:

Why Methods Matter

  • Behavior encapsulation: Actions are logically grouped with related data
  • State management: Methods can modify object attributes safely
  • Code organization: Related functionality stays together
  • Interface design: Methods define how other code interacts with your objects

๐Ÿ“ Additional Notes: Private methods: Use the _method_name convention for internal helper methods. Method design: Good method interfaces are easy to use correctly and hard to use incorrectly.

Method Types Youโ€™ll Encounter

  • Action methods: Perform operations that change object state (attack, deposit, add_item)
  • Query methods: Return information without changing state (get_balance, is_alive)
  • Factory methods: Create new instances or related objects
  • Utility methods: Helper functions that support the main functionality

Game Character Example

Letโ€™s create a more complex example with a game character that demonstrates how methods work together:

import random

class GameCharacter:
    """Represents a character in a role-playing game."""

    def __init__(self, name: str, health: int = 100):
        self.name = name
        self.health = health
        self.max_health = health
        self.experience = 0
        self.level = 1
        self.inventory = []

    def take_damage(self, damage: int):
        """Reduce character health, ensuring it doesn't go below 0."""
        self.health = max(0, self.health - damage)
        print(f"๐Ÿ’ฅ {self.name} takes {damage} damage! Health: {self.health}/{self.max_health}")
        
        if self.health == 0:
            print(f"๐Ÿ’€ {self.name} has been defeated!")

    def heal(self, amount: int):
        """Restore character health, capped at maximum."""
        old_health = self.health
        self.health = min(self.max_health, self.health + amount)
        healed = self.health - old_health
        print(f"โค๏ธ {self.name} heals for {healed} points! Health: {self.health}/{self.max_health}")

    def gain_experience(self, exp: int):
        """Add experience and level up if threshold is reached."""
        self.experience += exp
        print(f"โœจ {self.name} gains {exp} experience! Total: {self.experience}")
        
        # Simple leveling formula: need level * 100 exp to level up
        if self.experience >= self.level * 100:
            self._level_up()

    def _level_up(self):
        """Private method to handle leveling up."""
        self.level += 1
        health_increase = 20
        self.max_health += health_increase
        self.health = self.max_health  # Full heal on level up
        print(f"๐ŸŽ‰ {self.name} reached level {self.level}! Max health increased by {health_increase}!")

    def attack(self, target):
        """Attack another character."""
        damage = random.randint(15, 25) + (self.level - 1) * 5
        print(f"โš”๏ธ {self.name} attacks {target.name}!")
        target.take_damage(damage)
        
        # Gain experience for fighting
        if target.health == 0:
            self.gain_experience(50)
        else:
            self.gain_experience(10)

    def add_to_inventory(self, item: str):
        """Add an item to the character's inventory."""
        self.inventory.append(item)
        print(f"๐Ÿ“ฆ {self.name} found: {item}")

    def __str__(self):
        return f"{self.name} (Level {self.level}) - Health: {self.health}/{self.max_health}, XP: {self.experience}"

# Create characters and simulate gameplay
hero = GameCharacter("Aria the Brave")
monster = GameCharacter("Goblin Warrior", 80)

print("=== Game Start ===")
print(hero)
print(monster)
print()

# Simulate a battle
print("=== Battle Begins ===")
hero.attack(monster)
monster.attack(hero)
hero.attack(monster)
hero.heal(15)

print("\n=== Final Status ===")
print(hero)
print(monster)

๐Ÿ‹๏ธโ€โ™€๏ธ Exercise 5: Game Character Enhancement

Add these features to the GameCharacter class: 1. A use_potion() method that heals based on inventory 2. A magic_attack(target) method with different damage 3. A get_inventory_value() method that calculates total value

# Your enhanced GameCharacter class here

Part 4: Inheritance - Code Reuse and Specialization

๐Ÿš— Vehicle Hierarchy: Understanding Inheritance

Inheritance allows you to create specialized versions of existing classes:

Core Inheritance Concepts

  • Base class (parent): Contains common attributes and methods shared by all subclasses
  • Derived class (child): Inherits from base class and adds specialized behavior
  • Method overriding: Child classes can replace parent methods with specialized versions
  • super() function: Calls parent class methods from child class
  • Code reuse: Write common functionality once, use it everywhere

๐Ÿ“ Additional Notes: Design principle: Inheritance should represent โ€œis-aโ€ relationships (ElectricCar IS-A Vehicle), not just code sharing. Inheritance vs composition: Sometimes composition (has-a relationships) is better than inheritance. Method resolution order: Python searches parent classes in a specific order when looking for methods.

When to Use Inheritance

  • โ€œIs-aโ€ relationships: ElectricCar IS-A Vehicle
  • Shared behavior: Multiple classes need similar functionality
  • Specialization: Different types need slightly different behavior
  • Framework design: Plugin architectures often use inheritance

Professional Inheritance Examples

  • Web frameworks: BaseView, ListView, DetailView in Django
  • Game engines: GameObject, Player, Enemy, Projectile
  • GUI frameworks: Widget, Button, TextBox, Menu

Vehicle Hierarchy Example

Letโ€™s explore inheritance with a vehicle example that shows code reuse and specialization:

class Vehicle:
    """Base class for all vehicles."""

    def __init__(self, make: str, model: str, year: int):
        self.make = make
        self.model = model
        self.year = year
        self.mileage = 0
        self.is_running = False

    def start_engine(self):
        """Start the vehicle's engine."""
        if not self.is_running:
            self.is_running = True
            print(f"๐Ÿš— The {self.year} {self.make} {self.model} engine starts.")
        else:
            print(f"The {self.make} {self.model} is already running.")

    def stop_engine(self):
        """Stop the vehicle's engine."""
        if self.is_running:
            self.is_running = False
            print(f"๐Ÿ›‘ The {self.make} {self.model} engine stops.")
        else:
            print(f"The {self.make} {self.model} is already stopped.")

    def drive(self, miles: float):
        """Drive the vehicle and update mileage."""
        if self.is_running:
            self.mileage += miles
            print(f"๐Ÿ›ฃ๏ธ Drove {miles} miles. Total mileage: {self.mileage}")
        else:
            print("Cannot drive - engine is not running!")

    def __str__(self):
        status = "running" if self.is_running else "stopped"
        return f"{self.year} {self.make} {self.model} - {self.mileage} miles ({status})"

# Test the base Vehicle class
generic_car = Vehicle("Honda", "Civic", 2020)
print(generic_car)
generic_car.start_engine()
generic_car.drive(25)
print(generic_car)

๐Ÿ”‹ Electric vs Gas Cars: Method Overriding in Action

Watch how inheritance enables specialized behavior while maintaining a common interface:

Method Overriding Benefits

  • Polymorphism: Same method name, different behavior based on object type
  • Interface consistency: All vehicles can start_engine() and drive()
  • Specialized behavior: Electric cars handle battery, gas cars handle fuel
  • Extensibility: Easy to add new vehicle types later

๐Ÿ“ Additional Notes: Real-world example: Tesla vs Toyota - same basic concept (transportation), completely different implementations. Method signatures: Overridden methods should have compatible signatures with the parent method. Polymorphism: This pattern enables polymorphism, a key concept in advanced OOP.

class ElectricCar(Vehicle):
    """Electric vehicle with battery management."""

    def __init__(self, make: str, model: str, year: int, battery_capacity: float):
        # Call the parent class constructor
        super().__init__(make, model, year)
        
        # Add electric-specific attributes
        self.battery_capacity = battery_capacity
        self.battery_level = 100.0  # Start fully charged
        self.charging = False

    def start_engine(self):
        """Override: Electric cars don't have traditional engines."""
        if not self.is_running:
            if self.battery_level > 0:
                self.is_running = True
                print(f"๐Ÿ”‹ The {self.year} {self.make} {self.model} powers on silently.")
            else:
                print(f"โŒ Cannot start - battery is empty!")
        else:
            print(f"The {self.make} {self.model} is already running.")

    def drive(self, miles: float):
        """Override: Driving uses battery power."""
        if self.is_running and self.battery_level > 0:
            # Electric cars use about 0.3% battery per mile
            battery_used = miles * 0.3
            self.battery_level = max(0, self.battery_level - battery_used)
            self.mileage += miles
            print(f"๐Ÿ›ฃ๏ธ Drove {miles} miles silently. Battery: {self.battery_level:.1f}%")
            
            if self.battery_level == 0:
                self.is_running = False
                print(f"๐Ÿ”‹ Battery empty! The {self.make} {self.model} has stopped.")
        elif not self.is_running:
            print("Cannot drive - car is not powered on!")
        else:
            print("Cannot drive - battery is empty!")

    def charge(self, hours: float):
        """Charge the battery (unique to electric cars)."""
        if self.charging:
            print("Already charging!")
            return
            
        self.charging = True
        # Assume 10% charge per hour
        charge_added = min(hours * 10, 100 - self.battery_level)
        self.battery_level += charge_added
        print(f"๐Ÿ”Œ Charged for {hours} hours. Battery: {self.battery_level:.1f}%")
        self.charging = False

    def __str__(self):
        """Override to include battery information."""
        base_info = super().__str__()
        return f"{base_info} - Battery: {self.battery_level:.1f}%"

# Test the electric car
tesla = ElectricCar("Tesla", "Model S", 2023, 100.0)
print(tesla)

tesla.start_engine()  # Uses overridden method
tesla.drive(50)       # Uses overridden method
tesla.drive(200)      # Should drain battery
tesla.charge(2)       # Uses unique method
tesla.start_engine()  # Should work again

print("\nFinal state:")
print(tesla)

Traditional Car: Another Child Class

class GasCar(Vehicle):
    """Traditional gas-powered vehicle."""

    def __init__(self, make: str, model: str, year: int, tank_size: float):
        super().__init__(make, model, year)
        self.tank_size = tank_size
        self.fuel_level = tank_size  # Start with full tank

    def drive(self, miles: float):
        """Override: Driving uses fuel."""
        if self.is_running and self.fuel_level > 0:
            # Assume 25 miles per gallon
            fuel_used = miles / 25
            self.fuel_level = max(0, self.fuel_level - fuel_used)
            self.mileage += miles
            print(f"๐Ÿ›ฃ๏ธ Drove {miles} miles. Fuel remaining: {self.fuel_level:.1f} gallons")
            
            if self.fuel_level == 0:
                self.is_running = False
                print(f"โ›ฝ Out of gas! The {self.make} {self.model} has stopped.")
        elif not self.is_running:
            print("Cannot drive - engine is not running!")
        else:
            print("Cannot drive - out of gas!")

    def fill_tank(self):
        """Fill the gas tank."""
        gallons_added = self.tank_size - self.fuel_level
        self.fuel_level = self.tank_size
        print(f"โ›ฝ Added {gallons_added:.1f} gallons. Tank is full!")

    def __str__(self):
        base_info = super().__str__()
        return f"{base_info} - Fuel: {self.fuel_level:.1f}/{self.tank_size} gallons"

# Test the gas car
truck = GasCar("Ford", "F-150", 2022, 20.0)
print(truck)

truck.start_engine()
truck.drive(100)
truck.drive(400)  # Should use a lot of fuel
truck.fill_tank()

print("\nComparison:")
print(tesla)
print(truck)

๐Ÿ‹๏ธโ€โ™€๏ธ Exercise 6: Vehicle Inheritance

Create a new vehicle type: 1. Create a Motorcycle class that inherits from Vehicle 2. Override the start_engine() method with a motorcycle-specific message 3. Add a wheelie() method unique to motorcycles 4. Make motorcycles more fuel-efficient in the drive() method

# Your Motorcycle class here

Part 5: Advanced OOP Concepts

โš™๏ธ Properties, Class Methods, and Static Methods

Moving beyond basic classes to professional Python development:

Advanced Features Overview

  • Properties: Control attribute access with getter/setter methods
  • Class methods: Methods that operate on the class rather than instances
  • Static methods: Utility functions that belong with the class logically
  • Class variables: Data shared across all instances of a class

๐Ÿ“ Additional Notes: Professional context: These features are heavily used in frameworks like Django and Flask. Property pattern: Common for validation, computed values, and maintaining API compatibility. Class methods: Often used for alternative constructors (factory pattern) and class-level operations.

When to Use Advanced Features

  • Properties: When you need to validate data or compute values dynamically
  • Class methods: For alternative constructors, factory methods, or class-level operations
  • Static methods: For utility functions related to the class
  • Class variables: For constants or data shared across all instances

Advanced Product Class Example

This comprehensive example demonstrates professional Python OOP techniques used in real applications:

Key Advanced Concepts Demonstrated

๐Ÿ“ Additional Notes: E-commerce relevance: This pattern is used in systems like Amazon, Shopify, and WooCommerce. Property advantages: Properties allow you to change internal implementation without breaking code that uses your class. Validation patterns: Essential for data integrity in production systems.

class Product:
    """Represents a product in an inventory system."""

    # Class variable - shared across all instances
    total_products_created = 0
    tax_rate = 0.08  # Default tax rate

    def __init__(self, name: str, price: float, category: str = "General"):
        self.name = name
        self._price = price  # Private attribute (convention)
        self.category = category
        self.quantity_sold = 0
        
        # Increment class variable
        Product.total_products_created += 1

    @property
    def price(self) -> float:
        """Get the product price."""
        return self._price

    @price.setter
    def price(self, value: float):
        """Set the product price with validation."""
        if value < 0:
            raise ValueError("Price cannot be negative")
        self._price = value
        print(f"Price updated to ${value:.2f}")

    @property
    def price_with_tax(self) -> float:
        """Calculate price including tax."""
        return self._price * (1 + Product.tax_rate)

    @classmethod
    def get_total_products(cls) -> int:
        """Return total number of products created."""
        return cls.total_products_created

    @classmethod
    def set_tax_rate(cls, new_rate: float):
        """Update the tax rate for all products."""
        cls.tax_rate = new_rate
        print(f"Tax rate updated to {new_rate:.2%}")

    @staticmethod
    def calculate_discount(price: float, discount_percent: float) -> float:
        """Calculate discounted price (utility function)."""
        return price * (1 - discount_percent / 100)

    def sell(self, quantity: int = 1):
        """Record a sale of this product."""
        self.quantity_sold += quantity
        revenue = self.price * quantity
        print(f"Sold {quantity} {self.name}(s) for ${revenue:.2f}")

    def __str__(self):
        return f"{self.name} - ${self.price:.2f} ({self.category})"

# Demonstrate advanced features
print(f"Products created so far: {Product.get_total_products()}")

laptop = Product("Gaming Laptop", 1299.99, "Electronics")
mouse = Product("Wireless Mouse", 79.99, "Electronics")
book = Product("Python Programming", 49.99, "Books")

print(f"\nProducts created so far: {Product.get_total_products()}")

# Property usage
print(f"\nLaptop price: ${laptop.price:.2f}")
print(f"Laptop price with tax: ${laptop.price_with_tax:.2f}")

# Price validation
try:
    laptop.price = 1199.99  # Valid
    # laptop.price = -100    # Would raise ValueError
except ValueError as e:
    print(f"Error: {e}")

# Class method usage
Product.set_tax_rate(0.10)  # Update tax rate for all products
print(f"Laptop price with new tax: ${laptop.price_with_tax:.2f}")

# Static method usage
discounted_price = Product.calculate_discount(laptop.price, 15)
print(f"Laptop with 15% discount: ${discounted_price:.2f}")

# Record sales
laptop.sell(2)
mouse.sell(5)

print(f"\nFinal inventory:")
for product in [laptop, mouse, book]:
    print(f"{product} - Sold: {product.quantity_sold}")

๐Ÿ‹๏ธโ€โ™€๏ธ Exercise 7: Advanced Features

Enhance the Product class: 1. Add a stock_quantity attribute and property with validation 2. Create a @classmethod called create_electronics(name, price) that sets category automatically 3. Add a @staticmethod for calculating bulk discount rates

# Your enhanced Product class here

Part 6: Lab 06 Practice - Putting It All Together

๐Ÿ“š Applying OOP Concepts to Books

Now letโ€™s apply everything youโ€™ve learned to the actual Lab 06 requirements:

Lab 06 Concepts Review

  • Class creation: Book class with proper constructor and string representation
  • Inheritance: EBook class that extends Book with additional functionality
  • Method implementation: get_age() method with business logic
  • Method overriding: Enhanced __str__ method in the child class

๐Ÿ“ Additional Notes: Real-world relevance: Digital libraries like Kindle, Apple Books, and Google Books use similar class hierarchies. Design decisions: The file_size attribute belongs to EBook, not Book, because physical books donโ€™t have file sizes. Future extensions: This design easily allows for additional book types like AudioBook or PhysicalBook.

Key Implementation Points

  • Constructor parameters: Required vs optional parameters with defaults
  • Data validation: Consider what validation makes sense for book data
  • Method logic: The get_age() calculation using current year
  • Inheritance chain: How EBook builds upon Book functionality

Book Class Implementation

Letโ€™s work on the exact requirements for Lab 06:

class Book:
    """Represents a book with title, author, and publication year."""

    def __init__(self, title: str, author: str, year: int):
        """Initialize a new book."""
        self.title = title
        self.author = author
        self.year = year

    def get_age(self) -> int:
        """Calculate and return the age of the book based on its publication year."""
        current_year = 2025  # As specified in the lab
        return current_year - self.year

    def __str__(self) -> str:
        """Return a user-friendly string representation of the book."""
        return f"\"{self.title}\" by {self.author} ({self.year})"

# Test the Book class
book1 = Book("The Pragmatic Programmer", "Andy Hunt", 1999)
print(book1)
print(f"Age: {book1.get_age()} years")

book2 = Book("Clean Code", "Robert Martin", 2008)
print(book2)
print(f"Age: {book2.get_age()} years")

EBook Class with Inheritance

class EBook(Book):
    """Electronic book that inherits from Book."""

    def __init__(self, title: str, author: str, year: int, file_size: int):
        """Initialize an EBook with all Book attributes plus file size."""
        # Call the parent class constructor
        super().__init__(title, author, year)
        # Add the EBook-specific attribute
        self.file_size = file_size

    def __str__(self) -> str:
        """Override to include file size information."""
        # Get the base string from the parent class
        base_str = super().__str__()
        # Append the file size information
        return f"{base_str} ({self.file_size} MB)"

# Test the EBook class
ebook1 = EBook("Automate the Boring Stuff with Python", "Al Sweigart", 2015, 12)
print(ebook1)
print(f"Age: {ebook1.get_age()} years")  # Inherited method

ebook2 = EBook("Python Crash Course", "Eric Matthes", 2019, 8)
print(ebook2)
print(f"Age: {ebook2.get_age()} years")

# Demonstrate that both classes work together
library = [book1, book2, ebook1, ebook2]
print("\n=== My Library ===")
for item in library:
    print(f"{item} - {item.get_age()} years old")

๐Ÿ‹๏ธโ€โ™€๏ธ Exercise 8: Complete Lab 06

Create your own implementation of the Book and EBook classes following the exact lab requirements: 1. Implement the Book class with the specified attributes and methods 2. Implement the EBook class that inherits from Book 3. Test both classes thoroughly 4. Try adding additional features like a AudioBook class

# Your complete Lab 06 implementation here
# This is your chance to practice everything you've learned!

if __name__ == '__main__':
    # Test your classes here
    pass

Summary and Next Steps

What Youโ€™ve Learned ๐ŸŽฏ

  1. When to use classes vs functions - Complex state management, related data and behavior encapsulation
  2. Class fundamentals - __init__, __str__, attributes, and methods
  3. Real-world modeling - Bank accounts, social media users, game characters, vehicles
  4. Inheritance - Code reuse, method overriding, super() calls
  5. Advanced concepts - Properties, class methods, static methods

Key Takeaways โœจ

  • OOP solves problems that functions alone cannot handle elegantly
  • Classes are blueprints for creating objects with shared structure and behavior
  • Inheritance enables code reuse and hierarchical relationships through โ€œis-aโ€ modeling
  • Methods define behavior while attributes store state
  • Professional code uses OOP extensively in frameworks, libraries, and applications

Career Relevance ๐Ÿš€

Technical Interview Preparation

  • System design questions: How would you model a social media platform? (User, Post, Comment classes)
  • OOP principles: Demonstrate understanding of encapsulation, inheritance, and polymorphism
  • Code quality: Show ability to write maintainable, extensible object-oriented code

Industry Applications

  • Web frameworks: Django models, Flask-SQLAlchemy classes
  • Game development: Unity/Unreal Engine component systems
  • Data science: Scikit-learn estimators, pandas DataFrames
  • Mobile development: iOS/Android framework patterns

Next Steps ๐ŸŽฏ

  1. Complete Lab 06 using the patterns youโ€™ve practiced here
  2. Experiment with GitHub Copilot to generate and improve your class designs
  3. Think in objects for your upcoming projects and assignments
  4. Explore design patterns like Factory, Observer, and Strategy patterns
  5. Practice inheritance hierarchies with real-world examples from your interests

Resources for Continued Learning ๐Ÿ“š

Official Python Documentation

Professional Development Resources

AI-Assisted Development

  • ChatGPT/Claude prompts: โ€œHelp me design a class hierarchy for [your domain]โ€
  • GitHub Copilot: Generate boilerplate code, constructor methods, and docstrings
  • Code review: Ask AI assistants to review your class designs for best practices

Final Thoughts ๐Ÿ’ญ

Object-oriented programming is not just about syntax - itโ€™s about thinking in terms of objects, their responsibilities, and their interactions. This mindset will serve you well throughout your programming career, from designing simple scripts to architecting enterprise applications.

The patterns youโ€™ve learned here - encapsulation, inheritance, and method design - form the foundation of modern software development. As you continue your journey, youโ€™ll see these concepts everywhere: in web frameworks, mobile apps, data science libraries, and game engines.

Practice, experiment, and most importantly, think like an object-oriented programmer! ๐Ÿ