Object-Oriented Programming (OOP) - Part 1

Object-Oriented Programming organizes code around 'objects' that represent real-world things. A class is a blueprint that defines attributes (data) and methods (functions) for objects. Four main concepts: 1) Encapsulation (bundling data and methods), 2) Inheritance (reusing code), 3) Polymorphism (same interface, different behavior), 4) Abstraction (hiding complexity).

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

# Example 1: Creating a simple class
print("\n1. CREATING A SIMPLE CLASS")
print("-" * 30)

class Dog:
    """A simple Dog class"""
    
    # Constructor method (initializer)
    def __init__(self, name, age, breed):
        """Initialize dog attributes"""
        self.name = name      # Instance variable
        self.age = age        # Instance variable
        self.breed = breed    # Instance variable
    
    # Instance method
    def bark(self):
        """Make the dog bark"""
        return f"{self.name} says: Woof!"
    
    def get_info(self):
        """Get dog information"""
        return f"{self.name} is a {self.age}-year-old {self.breed}"
    
    def have_birthday(self):
        """Celebrate dog's birthday"""
        self.age += 1
        return f"Happy Birthday {self.name}! Now {self.age} years old."

# Creating objects (instances) from the class
print("Creating dog objects:")
dog1 = Dog("Buddy", 3, "Golden Retriever")
dog2 = Dog("Luna", 2, "German Shepherd")

print(f"Dog 1: {dog1.get_info()}")
print(f"Dog 2: {dog2.get_info()}")
print(f"\n{dog1.bark()}")
print(f"{dog2.bark()}")

# Calling methods
print(f"\n{dog1.have_birthday()}")
print(f"Updated: {dog1.get_info()}")

# Accessing attributes directly
print(f"\nDirect attribute access:")
print(f"Dog1's name: {dog1.name}")
print(f"Dog2's breed: {dog2.breed}")

# Example 2: Bank Account class
print("\n\n2. BANK ACCOUNT CLASS")
print("-" * 30)

class BankAccount:
    """A simple bank account class"""
    
    # Class variable (shared by all instances)
    bank_name = "Python Bank"
    
    def __init__(self, account_holder, initial_balance=0):
        self.account_holder = account_holder
        self.balance = initial_balance
        self.account_number = self._generate_account_number()
    
    def _generate_account_number(self):
        """Private method to generate account number"""
        import random
        return f"ACC{random.randint(10000, 99999)}"
    
    def deposit(self, amount):
        """Deposit money into account"""
        if amount > 0:
            self.balance += amount
            return f"Deposited ${amount:.2f}. New balance: ${self.balance:.2f}"
        return "Deposit amount must be positive"
    
    def withdraw(self, amount):
        """Withdraw money from account"""
        if amount > self.balance:
            return "Insufficient funds!"
        elif amount <= 0:
            return "Withdrawal amount must be positive"
        else:
            self.balance -= amount
            return f"Withdrew ${amount:.2f}. New balance: ${self.balance:.2f}"
    
    def check_balance(self):
        """Check current balance"""
        return f"Account balance: ${self.balance:.2f}"
    
    def transfer(self, amount, other_account):
        """Transfer money to another account"""
        if amount > self.balance:
            return "Transfer failed: Insufficient funds"
        
        # Withdraw from this account
        self.balance -= amount
        # Deposit to other account
        other_account.balance += amount
        return f"Transferred ${amount:.2f} to {other_account.account_holder}"
    
    def get_statement(self):
        """Get account statement"""
        return f"""
{self.bank_name}
Account Holder: {self.account_holder}
Account Number: {self.account_number}
Balance: ${self.balance:.2f}
"""

# Using the BankAccount class
print(f"Bank: {BankAccount.bank_name}")

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

print(f"\nCreated accounts:")
print(f"Alice's account: {account1.account_number}")
print(f"Bob's account: {account2.account_number}")

# Perform transactions
print("\nTransactions:")
print(account1.deposit(200))
print(account1.withdraw(150))
print(account1.withdraw(2000))  # Should fail

# Transfer money
print(f"\nBefore transfer:")
print(f"Alice: {account1.check_balance()}")
print(f"Bob: {account2.check_balance()}")

print(f"\n{account1.transfer(300, account2)}")

print(f"\nAfter transfer:")
print(f"Alice: {account1.check_balance()}")
print(f"Bob: {account2.check_balance()}")

# Get statement
print("\nAccount statement:")
print(account1.get_statement())

# Example 3: Class vs Instance variables
print("\n\n3. CLASS VS INSTANCE VARIABLES")
print("-" * 30)

class Student:
    # Class variable (shared by all students)
    school_name = "Python High School"
    total_students = 0
    
    def __init__(self, name, grade):
        # Instance variables (unique to each student)
        self.name = name
        self.grade = grade
        Student.total_students += 1  # Modify class variable
        self.student_id = f"S{Student.total_students:03d}"
    
    @classmethod
    def get_school_info(cls):
        """Class method - works on class, not instance"""
        return f"School: {cls.school_name}, Total Students: {cls.total_students}"
    
    @staticmethod
    def calculate_average(grades):
        """Static method - doesn't need class or instance"""
        if not grades:
            return 0
        return sum(grades) / len(grades)
    
    def get_info(self):
        return f"{self.name} (ID: {self.student_id}), Grade: {self.grade}"

# Create students
student1 = Student("Alice", 10)
student2 = Student("Bob", 11)
student3 = Student("Charlie", 12)

print("Student Information:")
print(f"School: {Student.school_name}")
print(f"Total Students: {Student.total_students}")
print(f"\nIndividual students:")
print(student1.get_info())
print(student2.get_info())
print(student3.get_info())

# Using class method
print(f"\n{Student.get_school_info()}")

# Using static method
grades = [85, 90, 78, 92, 88]
print(f"\nAverage of grades {grades}: {Student.calculate_average(grades):.1f}")

# Example 4: Property decorators (getters/setters)
print("\n\n4. PROPERTY DECORATORS")
print("-" * 30)

class Temperature:
    def __init__(self, celsius):
        self._celsius = celsius  # Private variable convention
    
    @property
    def celsius(self):
        """Getter for celsius"""
        return self._celsius
    
    @celsius.setter
    def celsius(self, value):
        """Setter for celsius with validation"""
        if value < -273.15:
            raise ValueError("Temperature cannot be below absolute zero!")
        self._celsius = value
    
    @property
    def fahrenheit(self):
        """Property that calculates Fahrenheit"""
        return (self._celsius * 9/5) + 32
    
    @fahrenheit.setter
    def fahrenheit(self, value):
        """Setter for Fahrenheit"""
        self._celsius = (value - 32) * 5/9

# Using properties
temp = Temperature(25)
print(f"Initial: {temp.celsius}°C = {temp.fahrenheit:.1f}°F")

# Change celsius
print("\nChanging celsius to 30:")
temp.celsius = 30
print(f"Now: {temp.celsius}°C = {temp.fahrenheit:.1f}°F")

# Change fahrenheit
print("\nSetting fahrenheit to 100:")
temp.fahrenheit = 100
print(f"Now: {temp.celsius:.1f}°C = {temp.fahrenheit}°F")

# Try invalid temperature
try:
    print("\nTrying to set invalid temperature:")
    temp.celsius = -300  # Should raise error
except ValueError as e:
    print(f"Error: {e}")

# Example 5: Simple OOP project - Library System
print("\n\n5. LIBRARY SYSTEM PROJECT")
print("-" * 30)

class Book:
    def __init__(self, title, author, isbn):
        self.title = title
        self.author = author
        self.isbn = isbn
        self.is_checked_out = False
        self.checked_out_by = None
    
    def check_out(self, member_name):
        if not self.is_checked_out:
            self.is_checked_out = True
            self.checked_out_by = member_name
            return f"'{self.title}' checked out by {member_name}"
        return f"'{self.title}' is already checked out by {self.checked_out_by}"
    
    def return_book(self):
        if self.is_checked_out:
            member = self.checked_out_by
            self.is_checked_out = False
            self.checked_out_by = None
            return f"'{self.title}' returned by {member}"
        return f"'{self.title}' was not checked out"
    
    def __str__(self):
        status = "Checked out" if self.is_checked_out else "Available"
        if self.is_checked_out:
            status += f" by {self.checked_out_by}"
        return f"'{self.title}' by {self.author} - {status}"

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}")
    
    def find_book(self, title):
        for book in self.books:
            if book.title.lower() == title.lower():
                return book
        return None
    
    def list_books(self):
        if not self.books:
            print("No books in library")
        else:
            print(f"\nBooks in {self.name}:")
            for book in self.books:
                print(f"  - {book}")

# Create library and books
library = Library("City Library")

book1 = Book("Python Programming", "John Doe", "123456")
book2 = Book("Data Science Basics", "Jane Smith", "789012")
book3 = Book("Web Development", "Bob Johnson", "345678")

library.add_book(book1)
library.add_book(book2)
library.add_book(book3)

# List all books
library.list_books()

# Check out and return books
print("\nChecking out books:")
print(book1.check_out("Alice"))
print(book2.check_out("Bob"))
print(book1.check_out("Charlie"))  # Should fail

library.list_books()

print("\nReturning books:")
print(book1.return_book())

library.list_books()