Object-Oriented Programming

Master Python OOP - classes, inheritance, polymorphism, and design patterns

Overview

Object-Oriented Programming (OOP) is a programming paradigm that organizes code into objects containing data (attributes) and behaviors (methods). Python supports all OOP concepts with a clean, intuitive syntax.

Classes and Objects

Basic Class Definition

PYTHON
# Simple class
class Dog:
    # Class attribute (shared by all instances)
    species = "Canis familiaris"
    
    # Constructor (initializer)
    def __init__(self, name, age):
        # Instance attributes
        self.name = name
        self.age = age
    
    # Instance method
    def bark(self):
        return f"{self.name} says Woof!"
    
    # Method with parameters
    def describe(self):
        return f"{self.name} is {self.age} years old"

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

print(dog1.bark())        # Max says Woof!
print(dog2.describe())    # Buddy is 5 years old
print(Dog.species)        # Canis familiaris

Class vs Instance Attributes

PYTHON
class Counter:
    # Class attribute
    total_instances = 0
    
    def __init__(self, initial_value=0):
        # Instance attributes
        self.value = initial_value
        # Modify class attribute
        Counter.total_instances += 1
    
    def increment(self):
        self.value += 1
    
    @classmethod
    def get_total_instances(cls):
        return cls.total_instances

# Usage
c1 = Counter()
c2 = Counter(10)
c3 = Counter(20)

print(Counter.total_instances)  # 3
print(c1.value)                 # 0
print(c2.value)                 # 10

Methods Types

Instance, Class, and Static Methods

PYTHON
class MyClass:
    class_variable = "shared"
    
    def __init__(self, value):
        self.instance_variable = value
    
    # Instance method - access to self
    def instance_method(self):
        return f"Instance method called on {self.instance_variable}"
    
    # Class method - access to class
    @classmethod
    def class_method(cls):
        return f"Class method called on {cls.class_variable}"
    
    # Static method - no access to self or cls
    @staticmethod
    def static_method(x, y):
        return f"Static method called with {x} and {y}"
    
    # Alternative constructor using class method
    @classmethod
    def from_string(cls, string_data):
        value = int(string_data.split('-')[1])
        return cls(value)

# Usage
obj = MyClass(42)
print(obj.instance_method())           # Instance method
print(MyClass.class_method())          # Class method
print(MyClass.static_method(1, 2))     # Static method

# Alternative constructor
obj2 = MyClass.from_string("value-100")
print(obj2.instance_variable)          # 100

Inheritance

Single Inheritance

PYTHON
# Base class (parent)
class Animal:
    def __init__(self, name, species):
        self.name = name
        self.species = species
    
    def make_sound(self):
        return "Some generic sound"
    
    def describe(self):
        return f"{self.name} is a {self.species}"

# Derived class (child)
class Cat(Animal):
    def __init__(self, name, breed):
        # Call parent constructor
        super().__init__(name, "Cat")
        self.breed = breed
    
    # Override parent method
    def make_sound(self):
        return "Meow"
    
    # Add new method
    def purr(self):
        return f"{self.name} is purring"

# Usage
cat = Cat("Whiskers", "Persian")
print(cat.describe())      # Whiskers is a Cat
print(cat.make_sound())    # Meow
print(cat.purr())          # Whiskers is purring

Multiple Inheritance

PYTHON
# Multiple parent classes
class Flyable:
    def __init__(self):
        self.altitude = 0
    
    def fly(self):
        self.altitude += 100
        return f"Flying at {self.altitude} feet"

class Swimmable:
    def __init__(self):
        self.depth = 0
    
    def swim(self):
        self.depth += 10
        return f"Swimming at {self.depth} feet deep"

# Multiple inheritance
class Duck(Animal, Flyable, Swimmable):
    def __init__(self, name):
        Animal.__init__(self, name, "Duck")
        Flyable.__init__(self)
        Swimmable.__init__(self)
    
    def make_sound(self):
        return "Quack"

# Usage
duck = Duck("Donald")
print(duck.make_sound())  # Quack
print(duck.fly())          # Flying at 100 feet
print(duck.swim())         # Swimming at 10 feet deep

# Method Resolution Order (MRO)
print(Duck.__mro__)

Abstract Base Classes

PYTHON
from abc import ABC, abstractmethod

# Abstract base class
class Shape(ABC):
    @abstractmethod
    def area(self):
        """Calculate area of the shape"""
        pass
    
    @abstractmethod
    def perimeter(self):
        """Calculate perimeter of the shape"""
        pass
    
    def describe(self):
        return f"Area: {self.area()}, Perimeter: {self.perimeter()}"

# Concrete implementations
class Rectangle(Shape):
    def __init__(self, width, height):
        self.width = width
        self.height = height
    
    def area(self):
        return self.width * self.height
    
    def perimeter(self):
        return 2 * (self.width + self.height)

class Circle(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

# Usage
# shape = Shape()  # Error: Can't instantiate abstract class
rectangle = Rectangle(5, 3)
circle = Circle(4)

print(rectangle.describe())
print(circle.describe())

Encapsulation

Private and Protected Members

PYTHON
class BankAccount:
    def __init__(self, account_number, initial_balance):
        # Public attribute
        self.account_number = account_number
        
        # Protected attribute (convention: single underscore)
        self._balance = initial_balance
        
        # Private attribute (name mangling: double underscore)
        self.__pin = None
        
        # Private method
        self.__validate_transaction = lambda amount: amount > 0
    
    # Public method
    def deposit(self, amount):
        if self.__validate_transaction(amount):
            self._balance += amount
            return True
        return False
    
    # Protected method
    def _calculate_interest(self):
        return self._balance * 0.05
    
    # Property for controlled access
    @property
    def balance(self):
        """Read-only access to balance"""
        return self._balance
    
    @property
    def pin(self):
        """Write-only PIN"""
        return "****"  # Never reveal actual PIN
    
    @pin.setter
    def pin(self, value):
        if len(str(value)) == 4:
            self.__pin = value
        else:
            raise ValueError("PIN must be 4 digits")

# Usage
account = BankAccount("123456", 1000)
account.deposit(500)
print(account.balance)      # 1500
# account.balance = 2000    # Error: can't set attribute
account.pin = 1234          # Set PIN
print(account.pin)          # ****
# print(account.__pin)      # Error: no attribute '__pin'
print(account._BankAccount__pin)  # 1234 (name mangling)

Polymorphism

Method Overriding and Duck Typing

PYTHON
class AudioFile:
    def __init__(self, filename):
        self.filename = filename
    
    def play(self):
        raise NotImplementedError("Subclass must implement")

class MP3File(AudioFile):
    def play(self):
        return f"Playing MP3: {self.filename}"

class WAVFile(AudioFile):
    def play(self):
        return f"Playing WAV: {self.filename}"

class FLACFile(AudioFile):
    def play(self):
        return f"Playing FLAC: {self.filename}"

# Polymorphic behavior
def play_audio(audio_file):
    """Works with any object that has a play() method"""
    return audio_file.play()

# Usage
files = [
    MP3File("song.mp3"),
    WAVFile("sound.wav"),
    FLACFile("music.flac")
]

for file in files:
    print(play_audio(file))

# Duck typing example
class RadioStream:
    def __init__(self, url):
        self.url = url
    
    def play(self):
        return f"Streaming from: {self.url}"

# RadioStream also works with play_audio
radio = RadioStream("http://radio.com/stream")
print(play_audio(radio))  # Works due to duck typing

Magic Methods (Dunder Methods)

Common Magic Methods

PYTHON
class Vector:
    def __init__(self, x, y):
        self.x = x
        self.y = y
    
    # String representation
    def __str__(self):
        return f"Vector({self.x}, {self.y})"
    
    def __repr__(self):
        return f"Vector(x={self.x}, y={self.y})"
    
    # Arithmetic operations
    def __add__(self, other):
        return Vector(self.x + other.x, self.y + other.y)
    
    def __sub__(self, other):
        return Vector(self.x - other.x, self.y - other.y)
    
    def __mul__(self, scalar):
        return Vector(self.x * scalar, self.y * scalar)
    
    # Comparison operations
    def __eq__(self, other):
        return self.x == other.x and self.y == other.y
    
    def __lt__(self, other):
        return self.magnitude() < other.magnitude()
    
    # Container protocol
    def __len__(self):
        return 2
    
    def __getitem__(self, index):
        if index == 0:
            return self.x
        elif index == 1:
            return self.y
        raise IndexError("Vector index out of range")
    
    # Callable
    def __call__(self):
        return self.magnitude()
    
    # Context manager
    def __enter__(self):
        print(f"Entering context with {self}")
        return self
    
    def __exit__(self, exc_type, exc_val, exc_tb):
        print(f"Exiting context")
    
    # Helper method
    def magnitude(self):
        return (self.x ** 2 + self.y ** 2) ** 0.5

# Usage
v1 = Vector(3, 4)
v2 = Vector(1, 2)

print(v1 + v2)         # Vector(4, 6)
print(v1 * 2)          # Vector(6, 8)
print(v1 == v2)        # False
print(v1[0])           # 3
print(v1())            # 5.0 (magnitude)

with v1 as v:
    print(f"In context: {v}")

Properties and Descriptors

Using Properties

PYTHON
class Temperature:
    def __init__(self, celsius=0):
        self._celsius = celsius
    
    @property
    def celsius(self):
        return self._celsius
    
    @celsius.setter
    def celsius(self, value):
        if value < -273.15:
            raise ValueError("Temperature below absolute zero")
        self._celsius = value
    
    @property
    def fahrenheit(self):
        return self._celsius * 9/5 + 32
    
    @fahrenheit.setter
    def fahrenheit(self, value):
        self.celsius = (value - 32) * 5/9
    
    @property
    def kelvin(self):
        return self._celsius + 273.15
    
    @kelvin.setter
    def kelvin(self, value):
        self.celsius = value - 273.15

# Usage
temp = Temperature()
temp.celsius = 25
print(temp.fahrenheit)  # 77.0
print(temp.kelvin)      # 298.15

temp.fahrenheit = 86
print(temp.celsius)     # 30.0

Custom Descriptors

PYTHON
class ValidatedAttribute:
    def __init__(self, validator):
        self.validator = validator
        self.data = {}
    
    def __get__(self, obj, objtype=None):
        if obj is None:
            return self
        return self.data.get(id(obj))
    
    def __set__(self, obj, value):
        self.validator(value)
        self.data[id(obj)] = value
    
    def __delete__(self, obj):
        del self.data[id(obj)]

class Person:
    # Descriptors
    name = ValidatedAttribute(lambda x: isinstance(x, str))
    age = ValidatedAttribute(lambda x: 0 <= x <= 150)
    
    def __init__(self, name, age):
        self.name = name
        self.age = age

# Usage
person = Person("Alice", 30)
# person.age = 200  # Raises error

Design Patterns

Singleton Pattern

PYTHON
class Singleton:
    _instance = None
    
    def __new__(cls):
        if cls._instance is None:
            cls._instance = super().__new__(cls)
            cls._instance.initialized = False
        return cls._instance
    
    def __init__(self):
        if not self.initialized:
            self.initialized = True
            self.data = {}
    
    def set_data(self, key, value):
        self.data[key] = value
    
    def get_data(self, key):
        return self.data.get(key)

# Usage
s1 = Singleton()
s2 = Singleton()
print(s1 is s2)  # True

s1.set_data("config", "value")
print(s2.get_data("config"))  # "value"

Factory Pattern

PYTHON
class Animal:
    def speak(self):
        pass

class Dog(Animal):
    def speak(self):
        return "Woof!"

class Cat(Animal):
    def speak(self):
        return "Meow!"

class AnimalFactory:
    @staticmethod
    def create_animal(animal_type):
        animals = {
            'dog': Dog,
            'cat': Cat
        }
        animal_class = animals.get(animal_type.lower())
        if animal_class:
            return animal_class()
        raise ValueError(f"Unknown animal type: {animal_type}")

# Usage
factory = AnimalFactory()
dog = factory.create_animal('dog')
cat = factory.create_animal('cat')
print(dog.speak())  # Woof!
print(cat.speak())  # Meow!

Observer Pattern

PYTHON
class Subject:
    def __init__(self):
        self._observers = []
        self._state = None
    
    def attach(self, observer):
        self._observers.append(observer)
    
    def detach(self, observer):
        self._observers.remove(observer)
    
    def notify(self):
        for observer in self._observers:
            observer.update(self._state)
    
    @property
    def state(self):
        return self._state
    
    @state.setter
    def state(self, value):
        self._state = value
        self.notify()

class Observer:
    def update(self, state):
        pass

class ConcreteObserver(Observer):
    def __init__(self, name):
        self.name = name
    
    def update(self, state):
        print(f"{self.name} received update: {state}")

# Usage
subject = Subject()
observer1 = ConcreteObserver("Observer1")
observer2 = ConcreteObserver("Observer2")

subject.attach(observer1)
subject.attach(observer2)

subject.state = "New State"  # Both observers get notified

Real-World Example: E-Commerce System

PYTHON
from datetime import datetime
from typing import List, Optional
from abc import ABC, abstractmethod
import uuid

class Product:
    def __init__(self, name: str, price: float, stock: int):
        self.id = str(uuid.uuid4())
        self.name = name
        self._price = price
        self._stock = stock
    
    @property
    def price(self):
        return self._price
    
    @price.setter
    def price(self, value):
        if value < 0:
            raise ValueError("Price cannot be negative")
        self._price = value
    
    @property
    def stock(self):
        return self._stock
    
    def reduce_stock(self, quantity):
        if quantity > self._stock:
            raise ValueError("Insufficient stock")
        self._stock -= quantity
    
    def __str__(self):
        return f"{self.name} (${self.price}) - Stock: {self.stock}"

class User:
    def __init__(self, username: str, email: str):
        self.id = str(uuid.uuid4())
        self.username = username
        self.email = email
        self.orders: List['Order'] = []
    
    def place_order(self, order: 'Order'):
        self.orders.append(order)
        return order.process()

class PaymentMethod(ABC):
    @abstractmethod
    def process_payment(self, amount: float) -> bool:
        pass

class CreditCard(PaymentMethod):
    def __init__(self, number: str, cvv: str):
        self.number = number
        self.cvv = cvv
    
    def process_payment(self, amount: float) -> bool:
        # Simulate payment processing
        print(f"Processing ${amount} via Credit Card ending in {self.number[-4:]}")
        return True

class PayPal(PaymentMethod):
    def __init__(self, email: str):
        self.email = email
    
    def process_payment(self, amount: float) -> bool:
        print(f"Processing ${amount} via PayPal ({self.email})")
        return True

class OrderItem:
    def __init__(self, product: Product, quantity: int):
        self.product = product
        self.quantity = quantity
    
    @property
    def subtotal(self):
        return self.product.price * self.quantity

class Order:
    def __init__(self, user: User):
        self.id = str(uuid.uuid4())
        self.user = user
        self.items: List[OrderItem] = []
        self.status = "pending"
        self.created_at = datetime.now()
        self.payment_method: Optional[PaymentMethod] = None
    
    def add_item(self, product: Product, quantity: int):
        if product.stock < quantity:
            raise ValueError(f"Only {product.stock} items available")
        
        # Check if product already in cart
        for item in self.items:
            if item.product.id == product.id:
                item.quantity += quantity
                return
        
        self.items.append(OrderItem(product, quantity))
    
    @property
    def total(self):
        return sum(item.subtotal for item in self.items)
    
    def set_payment_method(self, payment_method: PaymentMethod):
        self.payment_method = payment_method
    
    def process(self):
        if not self.payment_method:
            raise ValueError("Payment method not set")
        
        if not self.items:
            raise ValueError("Order is empty")
        
        # Process payment
        if self.payment_method.process_payment(self.total):
            # Reduce stock
            for item in self.items:
                item.product.reduce_stock(item.quantity)
            
            self.status = "completed"
            return f"Order {self.id} processed successfully! Total: ${self.total}"
        else:
            self.status = "failed"
            return "Payment failed"

# Usage example
# Create products
laptop = Product("Laptop", 999.99, 10)
mouse = Product("Mouse", 29.99, 50)

# Create user
user = User("john_doe", "john@example.com")

# Create order
order = Order(user)
order.add_item(laptop, 1)
order.add_item(mouse, 2)

# Set payment method
payment = CreditCard("1234567812345678", "123")
order.set_payment_method(payment)

# Process order
result = user.place_order(order)
print(result)
print(f"Laptop stock remaining: {laptop.stock}")

Practice Exercises

Exercise 1: Library Management System

PYTHON
# TODO: Create a library management system
"""
Requirements:
- Book class with ISBN, title, author, available copies
- Member class with membership ID, borrowed books
- Library class managing books and members
- Borrowing and returning functionality
- Fine calculation for late returns
- Search functionality
"""

class Book:
    # Your implementation here
    pass

class Member:
    # Your implementation here
    pass

class Library:
    # Your implementation here
    pass

# Test your implementation

Exercise 2: Game Character System

PYTHON
# TODO: Create a game character system with inheritance
"""
Requirements:
- Base Character class with health, attack, defense
- Warrior, Mage, Archer subclasses with unique abilities
- Equipment system affecting stats
- Battle system between characters
- Level up mechanism
"""

class Character:
    # Your implementation here
    pass

# Test with battle simulation

Exercise 3: Banking System

PYTHON
# TODO: Build a banking system with multiple account types
"""
Requirements:
- Account base class
- Savings, Checking, Investment account types
- Transaction history
- Interest calculation
- Transfer between accounts
- Account statements
"""

class Account:
    # Your implementation here
    pass

# Test with various transactions

Best Practices

1. Follow naming conventions: CamelCase for classes, snake_case for methods 2. Keep classes focused: Single Responsibility Principle 3. Use inheritance wisely: Prefer composition over inheritance 4. Document classes with docstrings 5. Make attributes private when appropriate 6. Use properties for controlled access 7. Implement useful magic methods for better usability 8. Follow SOLID principles for maintainable code 9. Use abstract base classes for interfaces 10. Test your classes thoroughly

Common Pitfalls

PYTHON
# Pitfall 1: Mutable class attributes
class BadExample:
    shared_list = []  # Shared by all instances!

# Correct approach
class GoodExample:
    def __init__(self):
        self.instance_list = []  # Unique to each instance

# Pitfall 2: Not calling super().__init__()
class Parent:
    def __init__(self):
        self.important_setup = True

class Child(Parent):
    def __init__(self):
        # super().__init__()  # Forgot this!
        self.child_attr = "value"

# Pitfall 3: Modifying list during iteration
class Collection:
    def __init__(self):
        self.items = []
    
    def remove_items(self, condition):
        # Wrong way
        for item in self.items:
            if condition(item):
                self.items.remove(item)  # Modifies during iteration!
        
        # Correct way
        self.items = [item for item in self.items if not condition(item)]