Python Core Concepts OOPs Tutorial
Fundamental Level Object-Oriented

Python OOPs: Objects and Classes Complete Guide

Master Python Object-Oriented Programming with focus on objects, classes, abstraction, and encapsulation. Learn OOP fundamentals with practical examples.

Objects

Real-world entities

Classes

Blueprints for objects

Abstraction

Hide complexity

Encapsulation

Data protection

What is Object-Oriented Programming?

Object-Oriented Programming (OOP) is a programming paradigm that organizes software design around objects rather than functions and logic. Python is a multi-paradigm language that fully supports OOP concepts.

Key OOP Concepts
  • Class: Blueprint for creating objects
  • Object: Instance of a class
  • Attribute: Data stored in object/class
  • Method: Function defined in class
  • Constructor: Special method for initialization
  • Self: Reference to current instance
OOP Pillars (We'll Cover)
  • Abstraction: Hide complex implementation details
  • Encapsulation: Bundle data with methods
  • Inheritance: Create new classes from existing
  • Polymorphism: Same interface, different implementation

Note: This tutorial focuses on Abstraction & Encapsulation

Real-world Analogy

Think of a class as a blueprint for a house. The object is the actual house built from that blueprint. Each house (object) has attributes (rooms, color) and methods (openDoor, turnOnLights).

basic_class_object.py
# Defining a simple class
class Dog:
    """A simple Dog class"""
    
    # Class attribute (shared by all instances)
    species = "Canis familiaris"
    
    # Constructor method (initializer)
    def __init__(self, name, age):
        # Instance attributes (unique to each instance)
        self.name = name
        self.age = age
    
    # Instance method
    def bark(self):
        return f"{self.name} says: Woof!"
    
    def describe(self):
        return f"{self.name} is {self.age} years old and is a {self.species}"

# Creating objects (instances) of Dog class
dog1 = Dog("Buddy", 3)
dog2 = Dog("Max", 5)

# Accessing attributes
print(f"Dog 1 name: {dog1.name}")  # Buddy
print(f"Dog 2 age: {dog2.age}")    # 5
print(f"Both are: {dog1.species}") # Canis familiaris

# Calling methods
print(dog1.bark())      # Buddy says: Woof!
print(dog2.describe())  # Max is 5 years old and is a Canis familiaris

# Checking object types
print(type(dog1))       # 
print(isinstance(dog1, Dog))  # True

# Object identity (memory address)
print(f"dog1 id: {id(dog1)}")
print(f"dog2 id: {id(dog2)}")

Classes: Blueprints for Objects

A class is a blueprint or template for creating objects. It defines the attributes (data) and methods (functions) that the objects will have.

Class Component Description Example Access
Class Attribute Shared by all instances of the class species = "Animal" ClassName.attr or obj.attr
Instance Attribute Unique to each object instance self.name = name obj.attr only
Instance Method Function that operates on instance def bark(self): obj.method()
Class Method Function that operates on class @classmethod ClassName.method()
Static Method Utility function in class namespace @staticmethod ClassName.method()
Constructor Initializes new objects __init__(self) Called automatically
String Representation String representation of object __str__(self) str(obj) or print(obj)
class_components.py
class BankAccount:
    """A Bank Account class demonstrating class components"""
    
    # Class attribute (shared by all accounts)
    bank_name = "Python Bank"
    interest_rate = 0.05  # 5% annual interest
    
    def __init__(self, account_holder, initial_balance=0):
        """Constructor - Initialize new account"""
        # Instance attributes
        self.account_holder = account_holder
        self._balance = initial_balance  # Private convention
        self.account_number = self._generate_account_number()
        
        # Track all accounts
        if not hasattr(BankAccount, 'all_accounts'):
            BankAccount.all_accounts = []
        BankAccount.all_accounts.append(self)
    
    def _generate_account_number(self):
        """Private method to generate account number"""
        import random
        return f"ACC{random.randint(10000, 99999)}"
    
    # Instance method
    def deposit(self, amount):
        """Deposit money into account"""
        if amount > 0:
            self._balance += amount
            return f"Deposited ${amount}. New balance: ${self._balance}"
        return "Deposit amount must be positive"
    
    def withdraw(self, amount):
        """Withdraw money from account"""
        if 0 < amount <= self._balance:
            self._balance -= amount
            return f"Withdrew ${amount}. New balance: ${self._balance}"
        return "Insufficient funds or invalid amount"
    
    def get_balance(self):
        """Get current balance"""
        return f"Balance: ${self._balance}"
    
    # Class method
    @classmethod
    def change_interest_rate(cls, new_rate):
        """Change interest rate for all accounts"""
        cls.interest_rate = new_rate
        return f"Interest rate changed to {new_rate*100}%"
    
    @classmethod
    def get_total_accounts(cls):
        """Get total number of accounts"""
        return len(cls.all_accounts) if hasattr(cls, 'all_accounts') else 0
    
    # Static method
    @staticmethod
    def calculate_interest(principal, years):
        """Calculate interest (utility function)"""
        return principal * (1 + BankAccount.interest_rate) ** years - principal
    
    # Special methods
    def __str__(self):
        """String representation for users"""
        return f"Account({self.account_number}): {self.account_holder} - Balance: ${self._balance}"
    
    def __repr__(self):
        """String representation for developers"""
        return f"BankAccount('{self.account_holder}', {self._balance})"

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

print("=== Instance Methods ===")
print(account1.deposit(500))  # Deposited $500. New balance: $1500
print(account2.withdraw(200)) # Withdrew $200. New balance: $300

print("\n=== Class Methods ===")
print(f"Bank name: {BankAccount.bank_name}")
print(f"Total accounts: {BankAccount.get_total_accounts()}")  # 2
print(BankAccount.change_interest_rate(0.06))  # Interest rate changed to 6.0%

print("\n=== Static Method ===")
interest = BankAccount.calculate_interest(1000, 2)
print(f"Interest on $1000 for 2 years: ${interest:.2f}")

print("\n=== Special Methods ===")
print(account1)  # Uses __str__
print(repr(account2))  # Uses __repr__

print("\n=== Accessing Attributes ===")
print(f"Account holder: {account1.account_holder}")
print(f"Account number: {account1.account_number}")

# Class vs Instance attributes
print(f"\nClass attribute access:")
print(f"BankAccount.bank_name: {BankAccount.bank_name}")
print(f"account1.bank_name: {account1.bank_name}")

# Modifying class attribute affects all instances
BankAccount.bank_name = "Python OOP Bank"
print(f"\nAfter change - account1.bank_name: {account1.bank_name}")
print(f"After change - account2.bank_name: {account2.bank_name}")
The self Parameter: In Python, self is a convention (not a keyword) that refers to the current instance. It must be the first parameter of instance methods. When you call obj.method(), Python automatically passes obj as self.

Objects: Instances of Classes

Objects are instances of classes. Each object has its own state (attribute values) but shares the same behavior (methods) defined in the class.

objects_demo.py
class Car:
    """Car class representing a vehicle"""
    
    # Class attribute
    wheels = 4
    
    def __init__(self, brand, model, year, color):
        # Instance attributes
        self.brand = brand
        self.model = model
        self.year = year
        self.color = color
        self._mileage = 0  # Private by convention
        self.is_running = False
    
    def start(self):
        """Start the car"""
        if not self.is_running:
            self.is_running = True
            return f"{self.brand} {self.model} started."
        return f"{self.brand} {self.model} is already running."
    
    def stop(self):
        """Stop the car"""
        if self.is_running:
            self.is_running = False
            return f"{self.brand} {self.model} stopped."
        return f"{self.brand} {self.model} is already stopped."
    
    def drive(self, miles):
        """Drive the car for specified miles"""
        if self.is_running:
            self._mileage += miles
            return f"Drove {miles} miles. Total mileage: {self._mileage}"
        return "Cannot drive. Car is not running!"
    
    def get_mileage(self):
        """Get current mileage"""
        return self._mileage
    
    def repaint(self, new_color):
        """Change car color"""
        old_color = self.color
        self.color = new_color
        return f"Car repainted from {old_color} to {new_color}"
    
    def __str__(self):
        return f"{self.year} {self.brand} {self.model} ({self.color})"

# Creating multiple car objects
car1 = Car("Toyota", "Camry", 2022, "Blue")
car2 = Car("Honda", "Civic", 2023, "Red")
car3 = Car("Tesla", "Model 3", 2024, "White")

print("=== Car Objects ===")
print(f"Car 1: {car1}")
print(f"Car 2: {car2}")
print(f"Car 3: {car3}")

print("\n=== Operating Cars ===")
print(car1.start())        # Toyota Camry started.
print(car1.drive(50))      # Drove 50 miles. Total mileage: 50
print(car1.drive(30))      # Drove 30 miles. Total mileage: 80
print(car1.stop())         # Toyota Camry stopped.

print(f"\nCar1 mileage: {car1.get_mileage()}")  # 80
print(f"Car2 mileage: {car2.get_mileage()}")    # 0 (not driven yet)

print("\n=== Modifying Objects ===")
print(car2.repaint("Black"))  # Car repainted from Red to Black
print(f"Car2 color: {car2.color}")  # Black

print("\n=== Object Identity and Equality ===")
# Different objects with same values
car4 = Car("Toyota", "Camry", 2022, "Blue")
print(f"car1: {id(car1)}")
print(f"car4: {id(car4)}")
print(f"car1 == car4: {car1 == car4}")  # False (different objects)
print(f"Same class? {type(car1) == type(car4)}")  # True

print("\n=== Object Attributes ===")
# Accessing attributes
print(f"Car1 brand: {car1.brand}")
print(f"Car1 model: {car1.model}")
print(f"Car1 wheels: {car1.wheels}")  # Class attribute

# Listing all attributes
print(f"\nCar1 attributes: {car1.__dict__}")
print(f"Car class attributes: {Car.__dict__.keys()}")

print("\n=== Dynamic Attributes ===")
# Python allows adding attributes dynamically
car1.vin_number = "1HGCM82633A123456"
print(f"Car1 VIN: {car1.vin_number}")

# But this only affects car1, not other cars or the class
try:
    print(f"Car2 VIN: {car2.vin_number}")
except AttributeError:
    print("Car2 doesn't have vin_number attribute")

print("\n=== Multiple Objects with Different States ===")
# Each object maintains its own state
cars = [car1, car2, car3, car4]
for i, car in enumerate(cars, 1):
    print(f"Car{i}: {car.brand} {car.model}, Running: {car.is_running}, Mileage: {car.get_mileage()}")
Object Memory Model
Class Definition (Shared)
  • Methods (functions)
  • Class attributes
  • Blueprint structure
Object Instance (Unique)
  • Instance attributes
  • Current state values
  • Memory location

Key Insight: Multiple objects share the same class methods but have separate copies of instance attributes.

Abstraction: Hide Complexity

Abstraction is the concept of hiding complex implementation details and showing only the essential features of an object. It helps manage complexity by focusing on what an object does rather than how it does it.

Real-world Abstraction Example

When you drive a car, you use the steering wheel, pedals, and gear shift (interface) without needing to know how the engine, transmission, or braking system works internally (implementation).

abstraction_example.py
# =============================================
# EXAMPLE 1: Email System Abstraction
# =============================================
class EmailSender:
    """Abstraction for sending emails - hides complexity"""
    
    def __init__(self, smtp_server="smtp.gmail.com", port=587):
        self.smtp_server = smtp_server
        self.port = port
        self._connection = None  # Private - internal detail
    
    def connect(self):
        """Connect to email server (hidden complexity)"""
        # Simulate complex connection logic
        print(f"[DEBUG] Connecting to {self.smtp_server}:{self.port}")
        # Complex networking code would go here
        self._connection = "CONNECTED"
        print("[DEBUG] Connection established")
    
    def _authenticate(self, username, password):
        """Private method - authentication details hidden"""
        print(f"[DEBUG] Authenticating {username}")
        # Complex authentication logic
        return True
    
    def _prepare_email(self, to, subject, body):
        """Private method - email preparation hidden"""
        print(f"[DEBUG] Preparing email to {to}")
        email_data = {
            'to': to,
            'subject': subject,
            'body': body,
            'headers': {'Content-Type': 'text/html'}
        }
        return email_data
    
    def _send_raw_email(self, email_data):
        """Private method - actual sending hidden"""
        print(f"[DEBUG] Sending email: {email_data['subject']}")
        # Complex SMTP protocol handling
        return True
    
    # PUBLIC INTERFACE - What users interact with
    def send_email(self, to, subject, body, username, password):
        """
        Simple public method - hides all complexity
        Users only need to know this interface
        """
        # Step 1: Connect (complexity hidden)
        self.connect()
        
        # Step 2: Authenticate (complexity hidden)
        if not self._authenticate(username, password):
            return "Authentication failed"
        
        # Step 3: Prepare email (complexity hidden)
        email_data = self._prepare_email(to, subject, body)
        
        # Step 4: Send (complexity hidden)
        if self._send_raw_email(email_data):
            return f"Email sent successfully to {to}"
        else:
            return "Failed to send email"
    
    def disconnect(self):
        """Clean up connection"""
        if self._connection:
            print("[DEBUG] Disconnecting from server")
            self._connection = None

# Using the abstraction
print("=== Email System Abstraction ===")
sender = EmailSender()

# User only needs to know this simple interface
result = sender.send_email(
    to="client@example.com",
    subject="Project Update",
    body="Hello, here's the latest update...",
    username="me@example.com",
    password="password123"
)

print(f"\nResult: {result}")
sender.disconnect()

print("\n" + "="*50 + "\n")

# =============================================
# EXAMPLE 2: Payment Processor Abstraction
# =============================================
class PaymentProcessor:
    """Abstraction for payment processing"""
    
    def process_payment(self, amount, payment_method):
        """
        Simple public interface
        Hides all the complex payment gateway logic
        """
        # Validate input
        if amount <= 0:
            return "Invalid amount"
        
        # Process based on payment method
        if payment_method == "credit_card":
            return self._process_credit_card(amount)
        elif payment_method == "paypal":
            return self._process_paypal(amount)
        elif payment_method == "bank_transfer":
            return self._process_bank_transfer(amount)
        else:
            return "Unsupported payment method"
    
    # Private methods - implementation details hidden
    def _process_credit_card(self, amount):
        """Complex credit card processing logic"""
        print(f"[DEBUG] Processing ${amount} via credit card")
        print("[DEBUG] Validating card details...")
        print("[DEBUG] Charging card...")
        print("[DEBUG] Updating transaction records...")
        return f"Credit card payment of ${amount} processed successfully"
    
    def _process_paypal(self, amount):
        """Complex PayPal processing logic"""
        print(f"[DEBUG] Processing ${amount} via PayPal")
        print("[DEBUG] Redirecting to PayPal...")
        print("[DEBUG] Handling OAuth...")
        print("[DEBUG] Completing transaction...")
        return f"PayPal payment of ${amount} processed successfully"
    
    def _process_bank_transfer(self, amount):
        """Complex bank transfer logic"""
        print(f"[DEBUG] Processing ${amount} via bank transfer")
        print("[DEBUG] Generating payment reference...")
        print("[DEBUG] Creating bank transaction...")
        print("[DEBUG] Sending confirmation...")
        return f"Bank transfer of ${amount} initiated successfully"

# Using the payment abstraction
print("=== Payment Processor Abstraction ===")
processor = PaymentProcessor()

# Simple interface for users
payment_result = processor.process_payment(99.99, "credit_card")
print(f"\nPayment Result: {payment_result}")

print("\n" + "="*50 + "\n")

# =============================================
# EXAMPLE 3: File Compression Abstraction
# =============================================
class FileCompressor:
    """Abstraction for file compression - hides algorithm details"""
    
    def compress(self, file_path, algorithm="zip"):
        """
        Simple interface for file compression
        Hides complex compression algorithms
        """
        print(f"Compressing: {file_path}")
        
        if algorithm == "zip":
            return self._compress_zip(file_path)
        elif algorithm == "gzip":
            return self._compress_gzip(file_path)
        elif algorithm == "bzip2":
            return self._compress_bzip2(file_path)
        else:
            return "Unsupported compression algorithm"
    
    # Private methods - algorithm details hidden
    def _compress_zip(self, file_path):
        """Complex ZIP compression algorithm"""
        print("[DEBUG] Using DEFLATE algorithm...")
        print("[DEBUG] Creating archive structure...")
        print("[DEBUG] Calculating CRC...")
        return f"{file_path}.zip created successfully"
    
    def _compress_gzip(self, file_path):
        """Complex GZIP compression algorithm"""
        print("[DEBUG] Using LZ77 algorithm...")
        print("[DEBUG] Adding gzip header...")
        print("[DEBUG] Calculating Adler-32 checksum...")
        return f"{file_path}.gz created successfully"
    
    def _compress_bzip2(self, file_path):
        """Complex BZIP2 compression algorithm"""
        print("[DEBUG] Using Burrows-Wheeler transform...")
        print("[DEBUG] Applying Huffman coding...")
        print("[DEBUG] Creating bzip2 header...")
        return f"{file_path}.bz2 created successfully"

# Using the compression abstraction
print("=== File Compression Abstraction ===")
compressor = FileCompressor()

# Simple interface - user doesn't need to know algorithm details
result = compressor.compress("document.txt", algorithm="zip")
print(f"\n{result}")

# Try different algorithm
result2 = compressor.compress("data.csv", algorithm="gzip")
print(f"\n{result2}")

print("\n" + "="*50 + "\n")

# =============================================
# KEY TAKEAWAY: Benefits of Abstraction
# =============================================
print("=== Benefits of Abstraction ===")
print("1. Simplified Interface: Users interact with simple methods")
print("2. Implementation Hiding: Complex details are hidden")
print("3. Code Maintainability: Change implementation without affecting users")
print("4. Reduced Complexity: Users focus on 'what' not 'how'")
print("5. Reusability: Same interface can have multiple implementations")
Abstraction Principle: By convention in Python, methods starting with a single underscore _method_name are considered "protected" (for internal use), while methods starting with double underscore __method_name are "private" (name mangled). This helps implement abstraction by indicating which methods are part of the internal implementation.

Encapsulation: Data Protection

Encapsulation is the bundling of data (attributes) and methods that operate on that data within a single unit (class), while restricting direct access to some of the object's components. This is often called "information hiding".

Encapsulation in Practice

Think of a capsule medicine: the medicine (data) is protected inside the capsule (class), and you interact with it through specific methods (swallowing) without direct access to the contents.

encapsulation_example.py
# =============================================
# EXAMPLE 1: Bank Account with Encapsulation
# =============================================
class SecureBankAccount:
    """
    Bank account with encapsulation
    Direct access to balance is restricted
    """
    
    def __init__(self, account_holder, initial_balance=0):
        # Private attributes (by convention)
        self._account_holder = account_holder
        self.__balance = initial_balance  # Name mangled for stronger privacy
        self.__transaction_history = []
        self.__pin = self._generate_pin()
        
        # Record initial deposit
        if initial_balance > 0:
            self.__add_transaction("Initial deposit", initial_balance)
    
    # PRIVATE METHODS (implementation details)
    def _generate_pin(self):
        """Generate a secure PIN"""
        import random
        return str(random.randint(1000, 9999))
    
    def __validate_amount(self, amount):
        """Validate amount is positive"""
        return amount > 0
    
    def __add_transaction(self, description, amount):
        """Add transaction to history"""
        import datetime
        transaction = {
            'timestamp': datetime.datetime.now(),
            'description': description,
            'amount': amount,
            'balance': self.__balance
        }
        self.__transaction_history.append(transaction)
    
    def __verify_pin(self, provided_pin):
        """Verify PIN without exposing it"""
        return self.__pin == provided_pin
    
    # PUBLIC INTERFACE (controlled access)
    def deposit(self, amount, pin):
        """Deposit money with PIN verification"""
        if not self.__verify_pin(pin):
            return "Invalid PIN"
        
        if self.__validate_amount(amount):
            self.__balance += amount
            self.__add_transaction("Deposit", amount)
            return f"Deposited ${amount}. New balance: ${self.__balance}"
        return "Invalid deposit amount"
    
    def withdraw(self, amount, pin):
        """Withdraw money with PIN verification"""
        if not self.__verify_pin(pin):
            return "Invalid PIN"
        
        if not self.__validate_amount(amount):
            return "Invalid withdrawal amount"
        
        if amount > self.__balance:
            return "Insufficient funds"
        
        self.__balance -= amount
        self.__add_transaction("Withdrawal", -amount)
        return f"Withdrew ${amount}. New balance: ${self.__balance}"
    
    def get_balance(self, pin):
        """Get balance with PIN verification"""
        if not self.__verify_pin(pin):
            return "Invalid PIN"
        return f"Current balance: ${self.__balance}"
    
    def get_statement(self, pin, last_n=5):
        """Get recent transactions"""
        if not self.__verify_pin(pin):
            return "Invalid PIN"
        
        statement = f"Statement for {self._account_holder}\n"
        statement += "=" * 40 + "\n"
        
        recent = self.__transaction_history[-last_n:] if last_n > 0 else []
        for trans in recent:
            date = trans['timestamp'].strftime("%Y-%m-%d %H:%M")
            amount = f"+${trans['amount']}" if trans['amount'] > 0 else f"-${-trans['amount']}"
            statement += f"{date}: {trans['description']:20} {amount:>10} Balance: ${trans['balance']}\n"
        
        statement += "=" * 40 + "\n"
        statement += f"Current Balance: ${self.__balance}"
        return statement
    
    def change_pin(self, old_pin, new_pin):
        """Change PIN with verification"""
        if not self.__verify_pin(old_pin):
            return "Invalid current PIN"
        
        if len(new_pin) != 4 or not new_pin.isdigit():
            return "PIN must be 4 digits"
        
        self.__pin = new_pin
        return "PIN changed successfully"

print("=== Bank Account Encapsulation ===")
account = SecureBankAccount("John Doe", 1000)

# Try to access private attributes directly (won't work properly)
print(f"Account holder: {account._account_holder}")  # Accessible but by convention shouldn't
try:
    print(f"Balance: {account.__balance}")  # This will fail
except AttributeError as e:
    print(f"Cannot access __balance directly: {e}")

# Proper way to interact with the account
print("\n=== Using Public Interface ===")
print(account.deposit(500, "1234"))  # Invalid PIN
print(account.deposit(500, account._SecureBankAccount__pin))  # Using actual PIN
print(account.withdraw(200, account._SecureBankAccount__pin))
print(account.get_balance(account._SecureBankAccount__pin))

print("\n=== Getting Statement ===")
print(account.get_statement(account._SecureBankAccount__pin, 3))

print("\n=== Trying to Change PIN ===")
print(account.change_pin("wrong", "9999"))  # Should fail
print(account.change_pin(account._SecureBankAccount__pin, "9999"))  # Should work

print("\n" + "="*50 + "\n")

# =============================================
# EXAMPLE 2: Temperature Converter with Validation
# =============================================
class Temperature:
    """
    Temperature class with encapsulation
    Ensures temperature stays in valid range
    """
    
    ABSOLUTE_ZERO_C = -273.15  # Class constant
    
    def __init__(self, celsius=0):
        # Private attribute with validation
        self.__celsius = self.__validate_celsius(celsius)
    
    def __validate_celsius(self, value):
        """Private validation method"""
        if value < self.ABSOLUTE_ZERO_C:
            raise ValueError(f"Temperature cannot be below absolute zero ({self.ABSOLUTE_ZERO_C}°C)")
        return value
    
    # Getter methods (provide controlled access)
    def get_celsius(self):
        """Get temperature in Celsius"""
        return self.__celsius
    
    def get_fahrenheit(self):
        """Get temperature in Fahrenheit"""
        return (self.__celsius * 9/5) + 32
    
    def get_kelvin(self):
        """Get temperature in Kelvin"""
        return self.__celsius + 273.15
    
    # Setter methods with validation
    def set_celsius(self, value):
        """Set temperature in Celsius with validation"""
        self.__celsius = self.__validate_celsius(value)
    
    def set_fahrenheit(self, value):
        """Set temperature using Fahrenheit"""
        celsius = (value - 32) * 5/9
        self.__celsius = self.__validate_celsius(celsius)
    
    def set_kelvin(self, value):
        """Set temperature using Kelvin"""
        celsius = value - 273.15
        self.__celsius = self.__validate_celsius(celsius)
    
    # Operator overloading for natural syntax
    def __add__(self, other):
        """Add temperatures"""
        if isinstance(other, Temperature):
            return Temperature(self.__celsius + other.__celsius)
        return Temperature(self.__celsius + other)
    
    def __str__(self):
        return f"{self.__celsius:.1f}°C ({self.get_fahrenheit():.1f}°F)"

print("=== Temperature Encapsulation ===")
temp = Temperature(25)
print(f"Initial temperature: {temp}")

# Using getters
print(f"\nIn different units:")
print(f"Celsius: {temp.get_celsius()}°C")
print(f"Fahrenheit: {temp.get_fahrenheit()}°F")
print(f"Kelvin: {temp.get_kelvin()}K")

# Using setters with validation
print(f"\n=== Changing Temperature ===")
temp.set_fahrenheit(77)
print(f"After setting to 77°F: {temp}")

temp.set_celsius(100)
print(f"After setting to 100°C: {temp}")

# Try invalid temperature
try:
    temp.set_celsius(-300)  # Below absolute zero
except ValueError as e:
    print(f"\nError: {e}")

# Using operator overloading
temp1 = Temperature(20)
temp2 = Temperature(30)
result = temp1 + temp2
print(f"\n{temp1} + {temp2} = {result}")

print("\n" + "="*50 + "\n")

# =============================================
# EXAMPLE 3: Employee Management with Encapsulation
# =============================================
class Employee:
    """
    Employee class demonstrating encapsulation
    with property decorators
    """
    
    def __init__(self, name, position, salary):
        self._name = name
        self._position = position
        self.__salary = salary  # Private
        self.__bonus = 0
        self.__performance_rating = 1.0  # 1.0 to 5.0
    
    # Property decorators for controlled access
    @property
    def name(self):
        """Get employee name"""
        return self._name
    
    @name.setter
    def name(self, value):
        """Set employee name with validation"""
        if not value or not isinstance(value, str):
            raise ValueError("Name must be a non-empty string")
        self._name = value
    
    @property
    def position(self):
        """Get position"""
        return self._position
    
    @position.setter
    def position(self, value):
        """Set position with validation"""
        valid_positions = ["Developer", "Manager", "Designer", "Analyst"]
        if value not in valid_positions:
            raise ValueError(f"Position must be one of: {valid_positions}")
        self._position = value
    
    # Salary access with business logic
    @property
    def salary(self):
        """Get total compensation (salary + bonus)"""
        return self.__salary + self.__bonus
    
    def set_salary(self, new_salary, approver_level):
        """Set salary with approval check"""
        if approver_level < 3:
            raise PermissionError("Only managers can change salaries")
        if new_salary < 0:
            raise ValueError("Salary cannot be negative")
        self.__salary = new_salary
    
    def give_bonus(self, amount, reason):
        """Give bonus with validation"""
        if amount <= 0:
            raise ValueError("Bonus must be positive")
        if not reason:
            raise ValueError("Must provide reason for bonus")
        
        self.__bonus += amount
        print(f"Bonus of ${amount} given for: {reason}")
    
    # Performance rating with encapsulation
    def set_performance_rating(self, rating, evaluator):
        """Set performance rating with validation"""
        if evaluator.position != "Manager":
            raise PermissionError("Only managers can set performance ratings")
        
        if not 1.0 <= rating <= 5.0:
            raise ValueError("Rating must be between 1.0 and 5.0")
        
        self.__performance_rating = rating
        # Auto-adjust bonus based on performance
        self.__adjust_bonus_based_on_performance()
    
    def __adjust_bonus_based_on_performance(self):
        """Private method: Adjust bonus based on performance"""
        if self.__performance_rating >= 4.5:
            self.__bonus += self.__salary * 0.1  # 10% bonus
        elif self.__performance_rating >= 4.0:
            self.__bonus += self.__salary * 0.05  # 5% bonus
    
    def get_performance_summary(self):
        """Get performance summary (encapsulated data)"""
        return {
            'rating': self.__performance_rating,
            'bonus': self.__bonus,
            'total_compensation': self.salary
        }
    
    def __str__(self):
        return f"{self._name} - {self._position} (Total comp: ${self.salary})"

print("=== Employee Encapsulation ===")
emp = Employee("Alice Smith", "Developer", 80000)
print(f"Employee: {emp}")

# Using properties
print(f"\nName: {emp.name}")
print(f"Position: {emp.position}")

# Try to change position with validation
try:
    emp.position = "CEO"  # Not in valid positions
except ValueError as e:
    print(f"Cannot change position: {e}")

emp.position = "Manager"  # Valid change
print(f"New position: {emp.position}")

# Salary access (with bonus included)
print(f"\nTotal compensation: ${emp.salary}")

# Give bonus
emp.give_bonus(5000, "Excellent Q4 performance")
print(f"After bonus - Total compensation: ${emp.salary}")

# Set performance rating (with validation)
try:
    emp.set_performance_rating(4.8, emp)  # emp is not a Manager
except PermissionError as e:
    print(f"Cannot set rating: {e}")

# Create a manager to set rating
manager = Employee("Bob Johnson", "Manager", 100000)
emp.set_performance_rating(4.8, manager)

# Check performance summary
summary = emp.get_performance_summary()
print(f"\nPerformance Summary:")
print(f"  Rating: {summary['rating']}/5.0")
print(f"  Bonus: ${summary['bonus']}")
print(f"  Total Compensation: ${summary['total_compensation']}")

print("\n" + "="*50 + "\n")

# =============================================
# BENEFITS OF ENCAPSULATION
# =============================================
print("=== Benefits of Encapsulation ===")
print("1. Data Protection: Prevent invalid state")
print("2. Controlled Access: Validation in setters")
print("3. Implementation Hiding: Change internals without affecting users")
print("4. Maintainability: Business logic centralized")
print("5. Flexibility: Can add logging/auditing in getters/setters")
Property Decorators for Encapsulation: Python's @property, @attribute.setter, and @attribute.deleter decorators provide an elegant way to implement encapsulation. They allow you to use methods like attributes while hiding the implementation details.
Important Note: Python doesn't have true private variables like Java or C++. The single underscore _var is a convention (protected), and double underscore __var triggers name mangling (_ClassName__var). However, both can still be accessed if needed. True encapsulation in Python relies on convention and trust.

Real-world OOP Application

E-commerce Product System

Complete e-commerce product with abstraction and encapsulation:

class Product:
    def __init__(self, name, price, category):
        self._name = name
        self._price = self._validate_price(price)
        self._category = category
        self._discount = 0
    
    def _validate_price(self, price):
        if price < 0:
            raise ValueError("Price cannot be negative")
        return price
    
    @property
    def final_price(self):
        return self._price * (1 - self._discount)
    
    def apply_discount(self, percentage):
        if 0 <= percentage <= 1:
            self._discount = percentage
        else:
            raise ValueError("Discount must be between 0 and 1")
Game Character System

Game character with encapsulated health and abstraction:

class GameCharacter:
    def __init__(self, name, health=100):
        self.name = name
        self.__health = min(max(health, 0), 100)
    
    def take_damage(self, amount):
        self.__health = max(0, self.__health - amount)
        return self.__health > 0
    
    def heal(self, amount):
        self.__health = min(100, self.__health + amount)
    
    def get_health_status(self):
        if self.__health > 70: return "Healthy"
        if self.__health > 30: return "Injured"
        return "Critical"
Library Management

Library book system with encapsulation:

class LibraryBook:
    def __init__(self, title, author, isbn):
        self.title = title
        self.author = author
        self.__isbn = isbn
        self.__is_checked_out = False
        self.__due_date = None
    
    def checkout(self, borrower, days=14):
        if not self.__is_checked_out:
            self.__is_checked_out = True
            # Calculate due date logic
            return True
        return False
Vehicle Management

Vehicle system with abstraction:

class Vehicle:
    def start_engine(self):
        self._connect_fuel_system()
        self._ignite_spark_plugs()
        self._engage_starter()
        return "Engine started"
    
    def _connect_fuel_system(self):
        # Complex fuel system logic
        pass
    
    def _ignite_spark_plugs(self):
        # Ignition logic
        pass

OOP Best Practices

  • Use meaningful class names: Nouns that represent real-world entities
  • Follow single responsibility: Each class should have one job
  • Encapsulate by default: Start with private attributes, expose via methods
  • Use properties for getters/setters: Pythonic way to encapsulate
  • Document your classes: Use docstrings for class and methods
  • Keep methods small: Each method should do one thing well
  • Use abstraction layers: Hide complexity from users
  • Validate in constructors: Ensure objects start in valid state
  • Avoid God classes: Don't put everything in one class
  • Follow naming conventions: _private, __really_private
good_vs_bad_oop.py
# =============================================
# BAD OOP PRACTICE
# =============================================
class BadUser:
    """Example of bad OOP practices"""
    
    def __init__(self):
        # Public attributes (no encapsulation)
        self.name = ""
        self.email = ""
        self.password = ""  # Password exposed!
        self.age = 0
        
        # Mixing responsibilities
        self.db_connection = None  # Database logic mixed with user
    
    # Methods doing too much
    def save_to_database_and_send_email(self):
        """Mixing responsibilities"""
        # Database code
        # Email sending code
        # Validation code
        pass
    
    # No validation
    def set_age(self, age):
        self.age = age  # Could be negative or 1000!

# =============================================
# GOOD OOP PRACTICE
# =============================================
class User:
    """Example of good OOP practices"""
    
    def __init__(self, name, email, password, age):
        # Validation in constructor
        self._name = self._validate_name(name)
        self._email = self._validate_email(email)
        self.__password_hash = self._hash_password(password)  # Encrypted
        self._age = self._validate_age(age)
        self.__created_at = datetime.datetime.now()
    
    # Private validation methods
    def _validate_name(self, name):
        if not name or len(name.strip()) < 2:
            raise ValueError("Name must be at least 2 characters")
        return name.strip()
    
    def _validate_email(self, email):
        import re
        pattern = r'^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$'
        if not re.match(pattern, email):
            raise ValueError("Invalid email format")
        return email
    
    def _validate_age(self, age):
        if not 0 <= age <= 150:
            raise ValueError("Age must be between 0 and 150")
        return age
    
    def _hash_password(self, password):
        """Hash password for security"""
        import hashlib
        return hashlib.sha256(password.encode()).hexdigest()
    
    # Property getters
    @property
    def name(self):
        return self._name
    
    @property
    def email(self):
        return self._email
    
    @property
    def age(self):
        return self._age
    
    @property
    def created_at(self):
        return self.__created_at
    
    # Property setters with validation
    @name.setter
    def name(self, value):
        self._name = self._validate_name(value)
    
    @email.setter
    def email(self, value):
        self._email = self._validate_email(value)
    
    @age.setter
    def age(self, value):
        self._age = self._validate_age(value)
    
    # Business logic methods
    def verify_password(self, password):
        """Verify password without exposing hash"""
        return self.__password_hash == self._hash_password(password)
    
    def change_password(self, old_password, new_password):
        """Change password with verification"""
        if not self.verify_password(old_password):
            raise ValueError("Incorrect current password")
        
        if len(new_password) < 8:
            raise ValueError("Password must be at least 8 characters")
        
        self.__password_hash = self._hash_password(new_password)
        return "Password changed successfully"
    
    def is_adult(self):
        """Business logic method"""
        return self._age >= 18
    
    def __str__(self):
        return f"User: {self._name} ({self._email})"
    
    def __repr__(self):
        return f"User(name='{self._name}', email='{self._email}', age={self._age})"

print("=== Good vs Bad OOP Comparison ===")
print("\nBad OOP Issues:")
print("1. No encapsulation (password exposed)")
print("2. No validation (invalid states possible)")
print("3. Mixed responsibilities (database + email)")
print("4. Poor naming (vague method names)")
print("5. No abstraction (exposes implementation)")

print("\nGood OOP Benefits:")
print("1. Full encapsulation (private attributes)")
print("2. Validation in constructors/setters")
print("3. Single responsibility (User class only)")
print("4. Clear, descriptive method names")
print("5. Abstraction (hides password hashing)")
print("6. Properties for controlled access")
print("7. Business logic methods")
print("8. Proper string representations")

# Example usage of good OOP
try:
    user = User("John Doe", "john@example.com", "secure123", 25)
    print(f"\nCreated user: {user}")
    print(f"Is adult? {user.is_adult()}")
    print(f"Password correct? {user.verify_password('secure123')}")
    
    # Try to change password
    user.change_password("secure123", "newsecure456")
    print("Password changed successfully")
    
except ValueError as e:
    print(f"Error: {e}")

Python OOP Key Takeaways

  • Class is a blueprint, Object is an instance
  • Abstraction hides complex implementation details from users
  • Encapsulation bundles data with methods and restricts direct access
  • Use _single_underscore for protected attributes/methods (convention)
  • Use __double_underscore for name mangling (stronger privacy)
  • @property decorators provide elegant getter/setter syntax
  • Always validate data in constructors and setters
  • Each class should have a single responsibility
  • Use docstrings to document classes and methods
  • Python OOP follows conventions rather than strict enforcement
Next Topics: We'll cover Inheritance and Polymorphism - the other two OOP pillars