Object-Oriented Programming (OOP) - Part 2

Inheritance allows new classes to be based on existing classes, reusing code and creating hierarchical relationships. Polymorphism allows objects of different classes to be treated as objects of a common superclass. Abstraction hides complex implementation details and shows only essential features. Encapsulation bundles data and methods together, protecting data from direct access.

# Object-Oriented Programming - Part 2
print("OBJECT-ORIENTED PROGRAMMING (OOP) - PART 2")
print("=" * 60)

# Example 1: Basic Inheritance
print("\n1. BASIC INHERITANCE")
print("-" * 30)

class Animal:
    """Base class for all animals"""
    def __init__(self, name, age):
        self.name = name
        self.age = age
    
    def eat(self):
        return f"{self.name} is eating."
    
    def sleep(self):
        return f"{self.name} is sleeping."
    
    def make_sound(self):
        return "Some generic animal sound"

class Dog(Animal):  # Dog inherits from Animal
    """Dog class - extends Animal"""
    def __init__(self, name, age, breed):
        # Call parent class constructor
        super().__init__(name, age)
        self.breed = breed
    
    # Override parent method
    def make_sound(self):
        return "Woof! Woof!"
    
    # Add new method specific to Dog
    def fetch(self):
        return f"{self.name} is fetching the ball!"

class Cat(Animal):  # Cat inherits from Animal
    """Cat class - extends Animal"""
    def __init__(self, name, age, color):
        super().__init__(name, age)
        self.color = color
    
    # Override parent method
    def make_sound(self):
        return "Meow!"
    
    # Add new method specific to Cat
    def climb(self):
        return f"{self.name} is climbing a tree!"

# Create animals
generic_animal = Animal("Generic", 1)
dog = Dog("Buddy", 3, "Golden Retriever")
cat = Cat("Whiskers", 2, "Orange")

print("Animal behaviors:")
print(f"Generic: {generic_animal.make_sound()}")
print(f"Dog: {dog.make_sound()}")
print(f"Cat: {cat.make_sound()}")

print("\nCommon behaviors (inherited):")
print(dog.eat())
print(cat.sleep())

print("\nSpecial behaviors (specific to subclass):")
print(dog.fetch())
print(cat.climb())

print("\nAnimal information:")
print(f"Dog: {dog.name}, {dog.age} years old, {dog.breed}")
print(f"Cat: {cat.name}, {cat.age} years old, {cat.color}")

# Example 2: Multi-level Inheritance
print("\n\n2. MULTI-LEVEL INHERITANCE")
print("-" * 30)

class Vehicle:
    """Base class for all vehicles"""
    def __init__(self, brand, model, year):
        self.brand = brand
        self.model = model
        self.year = year
        self.speed = 0
    
    def start(self):
        return f"{self.brand} {self.model} started."
    
    def stop(self):
        self.speed = 0
        return f"{self.brand} {self.model} stopped."
    
    def accelerate(self, increment):
        self.speed += increment
        return f"Accelerating to {self.speed} km/h"

class Car(Vehicle):
    """Car class - extends Vehicle"""
    def __init__(self, brand, model, year, doors):
        super().__init__(brand, model, year)
        self.doors = doors
    
    def honk(self):
        return "Beep! Beep!"

class ElectricCar(Car):
    """Electric Car - extends Car"""
    def __init__(self, brand, model, year, doors, battery_capacity):
        super().__init__(brand, model, year, doors)
        self.battery_capacity = battery_capacity
        self.battery_level = 100
    
    def charge(self, amount):
        """Charge the battery"""
        self.battery_level = min(100, self.battery_level + amount)
        return f"Battery: {self.battery_level}%"
    
    def drive(self, distance):
        """Drive and consume battery"""
        battery_used = distance * 0.5  # 0.5% per km
        if battery_used <= self.battery_level:
            self.battery_level -= battery_used
            return f"Drove {distance}km. Battery: {self.battery_level:.1f}%"
        return "Not enough battery!"

# Create vehicles
regular_car = Car("Toyota", "Camry", 2022, 4)
tesla = ElectricCar("Tesla", "Model 3", 2023, 4, 75)

print("Regular Car:")
print(regular_car.start())
print(regular_car.accelerate(50))
print(regular_car.honk())

print("\nElectric Car:")
print(tesla.start())
print(tesla.drive(50))
print(tesla.charge(25))

# Check inheritance
print("\nChecking inheritance hierarchy:")
print(f"Is tesla an ElectricCar? {isinstance(tesla, ElectricCar)}")
print(f"Is tesla a Car? {isinstance(tesla, Car)}")
print(f"Is tesla a Vehicle? {isinstance(tesla, Vehicle)}")
print(f"Is ElectricCar a subclass of Vehicle? {issubclass(ElectricCar, Vehicle)}")

# Example 3: Multiple Inheritance
print("\n\n3. MULTIPLE INHERITANCE")
print("-" * 30)

class Camera:
    """Camera functionality"""
    def __init__(self, resolution):
        self.resolution = resolution
    
    def take_photo(self):
        return f"Taking photo at {self.resolution} resolution"
    
    def zoom(self, factor):
        return f"Zooming {factor}x"

class Phone:
    """Phone functionality"""
    def __init__(self, number):
        self.number = number
    
    def call(self, contact):
        return f"Calling {contact} from {self.number}"
    
    def send_sms(self, contact, message):
        return f"Sending SMS to {contact}: {message}"

class SmartPhone(Camera, Phone):  # Inherits from both
    """SmartPhone - combines Camera and Phone"""
    def __init__(self, number, resolution, model):
        # Initialize both parent classes
        Camera.__init__(self, resolution)
        Phone.__init__(self, number)
        self.model = model
    
    def browse_internet(self):
        return f"Browsing internet on {self.model}"

# Create smartphone
iphone = SmartPhone("123-456-7890", "12MP", "iPhone 15")

print("Smartphone features:")
print(iphone.take_photo())
print(iphone.zoom(2))
print(iphone.call("Mom"))
print(iphone.send_sms("Friend", "Hello!"))
print(iphone.browse_internet())

# Check Method Resolution Order (MRO)
print("\nMethod Resolution Order:")
for i, cls in enumerate(SmartPhone.__mro__, 1):
    print(f"{i}. {cls.__name__}")

# Example 4: Abstract Base Classes
print("\n\n4. ABSTRACT BASE CLASSES")
print("-" * 30)

from abc import ABC, abstractmethod

class Shape(ABC):  # Abstract Base Class
    """Abstract base class for shapes"""
    
    @abstractmethod
    def area(self):
        """Calculate area - must be implemented by subclasses"""
        pass
    
    @abstractmethod
    def perimeter(self):
        """Calculate perimeter - must be implemented by subclasses"""
        pass
    
    def display_info(self):
        """Common method for all shapes"""
        return f"Area: {self.area():.2f}, Perimeter: {self.perimeter():.2f}"

class Circle(Shape):
    """Circle class implementing Shape"""
    def __init__(self, radius):
        self.radius = radius
    
    def area(self):
        import math
        return math.pi * self.radius ** 2
    
    def perimeter(self):
        import math
        return 2 * math.pi * self.radius

class Rectangle(Shape):
    """Rectangle class implementing Shape"""
    def __init__(self, length, width):
        self.length = length
        self.width = width
    
    def area(self):
        return self.length * self.width
    
    def perimeter(self):
        return 2 * (self.length + self.width)

# Cannot instantiate abstract class
# shape = Shape()  # This would raise TypeError

# Create concrete shapes
circle = Circle(5)
rectangle = Rectangle(4, 6)

print("Shape calculations:")
print(f"Circle with radius 5:")
print(circle.display_info())

print(f"\nRectangle 4x6:")
print(rectangle.display_info())

# Example 5: Polymorphism in action
print("\n\n5. POLYMORPHISM IN ACTION")
print("-" * 30)

class Employee(ABC):
    """Abstract base employee class"""
    def __init__(self, name, employee_id):
        self.name = name
        self.employee_id = employee_id
    
    @abstractmethod
    def calculate_salary(self):
        pass
    
    def get_info(self):
        return f"{self.name} (ID: {self.employee_id})"

class FullTimeEmployee(Employee):
    """Full-time employee with monthly salary"""
    def __init__(self, name, employee_id, monthly_salary):
        super().__init__(name, employee_id)
        self.monthly_salary = monthly_salary
    
    def calculate_salary(self):
        return self.monthly_salary

class PartTimeEmployee(Employee):
    """Part-time employee with hourly wage"""
    def __init__(self, name, employee_id, hourly_wage, hours_worked):
        super().__init__(name, employee_id)
        self.hourly_wage = hourly_wage
        self.hours_worked = hours_worked
    
    def calculate_salary(self):
        return self.hourly_wage * self.hours_worked

class Contractor(Employee):
    """Contractor with project-based payment"""
    def __init__(self, name, employee_id, project_fee, completed_projects):
        super().__init__(name, employee_id)
        self.project_fee = project_fee
        self.completed_projects = completed_projects
    
    def calculate_salary(self):
        return self.project_fee * self.completed_projects

# Create different types of employees
employees = [
    FullTimeEmployee("Alice", "FT001", 5000),
    PartTimeEmployee("Bob", "PT001", 20, 80),
    Contractor("Charlie", "CT001", 1000, 3)
]

# Process all employees polymorphically
print("Employee Salaries:")
print("-" * 40)
total_payroll = 0

for employee in employees:
    salary = employee.calculate_salary()
    total_payroll += salary
    print(f"{employee.get_info():30} Salary: ${salary:,.2f}")

print("-" * 40)
print(f"Total Payroll:{'':22} ${total_payroll:,.2f}")

# Example 6: Composition vs Inheritance
print("\n\n6. COMPOSITION VS INHERITANCE")
print("-" * 30)

# Composition example
class Engine:
    def __init__(self, horsepower):
        self.horsepower = horsepower
    
    def start(self):
        return "Engine started"
    
    def stop(self):
        return "Engine stopped"

class Wheels:
    def __init__(self, count):
        self.count = count
    
    def rotate(self):
        return f"{self.count} wheels rotating"

# Using composition (has-a relationship)
class CarComposition:
    def __init__(self, brand, engine_hp, wheel_count):
        self.brand = brand
        self.engine = Engine(engine_hp)  # Car HAS an Engine
        self.wheels = Wheels(wheel_count)  # Car HAS Wheels
    
    def drive(self):
        return f"{self.brand}: {self.engine.start()}, {self.wheels.rotate()}"

# Create car using composition
my_car = CarComposition("Toyota", 150, 4)
print("Car using composition:")
print(my_car.drive())
print(f"Engine HP: {my_car.engine.horsepower}")
print(f"Wheel count: {my_car.wheels.count}")

print("\nWhen to use each:")
print("Inheritance (is-a): A Dog IS AN Animal")
print("Composition (has-a): A Car HAS AN Engine")