Skip to main content

The Four Pillars of OOP

Encapsulation

Bundling data and methods that operate on that data

Abstraction

Hiding complex implementation details

Inheritance

Creating new classes from existing ones

Polymorphism

Same interface, different implementations

1. Encapsulation

Encapsulation protects object integrity by hiding internal state and requiring all interaction through methods.
class BankAccount:
    def __init__(self, account_number: str, initial_balance: float = 0):
        self._account_number = account_number  # Protected
        self.__balance = initial_balance        # Private
        self.__transactions = []
    
    @property
    def balance(self) -> float:
        """Read-only access to balance"""
        return self.__balance
    
    @property
    def account_number(self) -> str:
        return self._account_number
    
    def deposit(self, amount: float) -> bool:
        """Controlled way to modify balance"""
        if amount <= 0:
            raise ValueError("Deposit amount must be positive")
        
        self.__balance += amount
        self.__record_transaction("DEPOSIT", amount)
        return True
    
    def withdraw(self, amount: float) -> bool:
        """Business rules enforced here"""
        if amount <= 0:
            raise ValueError("Withdrawal amount must be positive")
        if amount > self.__balance:
            raise InsufficientFundsError("Insufficient funds")
        
        self.__balance -= amount
        self.__record_transaction("WITHDRAWAL", amount)
        return True
    
    def __record_transaction(self, type: str, amount: float):
        """Private method - internal use only"""
        self.__transactions.append({
            "type": type,
            "amount": amount,
            "timestamp": datetime.now(),
            "balance_after": self.__balance
        })
    
    def get_statement(self) -> List[dict]:
        """Controlled access to transaction history"""
        return self.__transactions.copy()  # Return copy, not reference

# Usage
account = BankAccount("123456", 1000)
account.deposit(500)    # ✅ Controlled access
account.withdraw(200)   # ✅ Business rules applied
print(account.balance)  # ✅ Read-only property

# These would fail:
# account.__balance = 1000000  # ❌ Can't access private
# account.balance = 1000000    # ❌ No setter defined

Benefits of Encapsulation

BenefitDescription
Data ProtectionPrevents invalid states
FlexibilityChange implementation without affecting users
ValidationEnforce business rules at access points
DebuggingAll modifications go through known paths

2. Abstraction

Abstraction hides complexity by showing only necessary details.
from abc import ABC, abstractmethod

# Abstract class - defines what, not how
class PaymentProcessor(ABC):
    @abstractmethod
    def process_payment(self, amount: float) -> PaymentResult:
        """Process a payment - implementation hidden"""
        pass
    
    @abstractmethod
    def refund(self, transaction_id: str) -> RefundResult:
        """Process a refund"""
        pass
    
    def validate_amount(self, amount: float) -> bool:
        """Concrete method - shared implementation"""
        return amount > 0

# Concrete implementation - the "how"
class StripePaymentProcessor(PaymentProcessor):
    def __init__(self, api_key: str):
        self.__api_key = api_key
        self.__client = StripeClient(api_key)
    
    def process_payment(self, amount: float) -> PaymentResult:
        # Complex Stripe-specific logic hidden
        if not self.validate_amount(amount):
            return PaymentResult(success=False, error="Invalid amount")
        
        try:
            charge = self.__client.charges.create(
                amount=int(amount * 100),
                currency="usd"
            )
            return PaymentResult(
                success=True,
                transaction_id=charge.id
            )
        except StripeError as e:
            return PaymentResult(success=False, error=str(e))
    
    def refund(self, transaction_id: str) -> RefundResult:
        # Stripe-specific refund logic
        pass

class PayPalPaymentProcessor(PaymentProcessor):
    def __init__(self, client_id: str, secret: str):
        self.__client = PayPalClient(client_id, secret)
    
    def process_payment(self, amount: float) -> PaymentResult:
        # PayPal-specific logic - different from Stripe
        pass
    
    def refund(self, transaction_id: str) -> RefundResult:
        pass

# Client code doesn't know which processor is used
class CheckoutService:
    def __init__(self, payment_processor: PaymentProcessor):
        self.processor = payment_processor
    
    def checkout(self, cart: Cart) -> OrderResult:
        # Works with ANY payment processor
        result = self.processor.process_payment(cart.total)
        return OrderResult(success=result.success)

3. Inheritance

Inheritance creates “is-a” relationships, allowing code reuse and specialization.
# Base class
class Vehicle:
    def __init__(self, brand: str, model: str, year: int):
        self.brand = brand
        self.model = model
        self.year = year
        self._mileage = 0
    
    def drive(self, distance: float):
        self._mileage += distance
        print(f"Drove {distance} miles")
    
    def get_info(self) -> str:
        return f"{self.year} {self.brand} {self.model}"

# Derived classes
class Car(Vehicle):
    def __init__(self, brand: str, model: str, year: int, num_doors: int):
        super().__init__(brand, model, year)
        self.num_doors = num_doors
    
    def honk(self):
        print("Beep beep!")

class Motorcycle(Vehicle):
    def __init__(self, brand: str, model: str, year: int, engine_cc: int):
        super().__init__(brand, model, year)
        self.engine_cc = engine_cc
    
    def wheelie(self):
        print("Doing a wheelie!")

class ElectricCar(Car):
    def __init__(self, brand: str, model: str, year: int, 
                 num_doors: int, battery_kwh: float):
        super().__init__(brand, model, year, num_doors)
        self.battery_kwh = battery_kwh
        self._charge_level = 100
    
    def drive(self, distance: float):
        # Override parent method
        energy_used = distance * 0.25  # kWh per mile
        if energy_used > self._charge_level:
            print("Not enough charge!")
            return
        
        self._charge_level -= energy_used
        super().drive(distance)  # Call parent implementation
    
    def charge(self):
        self._charge_level = 100
        print("Fully charged!")

# Usage
tesla = ElectricCar("Tesla", "Model 3", 2024, 4, 75)
tesla.drive(100)     # Uses overridden method
tesla.honk()         # Inherited from Car
print(tesla.get_info())  # Inherited from Vehicle

When to Use Inheritance

Use InheritanceUse Composition
True “is-a” relationship”Has-a” relationship
Shared behavior across typesFlexible behavior mixing
Stable base classFrequently changing behavior
Limited hierarchy depthComplex variations

4. Polymorphism

Polymorphism allows objects of different types to be treated uniformly.

Method Overriding (Runtime Polymorphism)

class Shape(ABC):
    @abstractmethod
    def area(self) -> float:
        pass
    
    @abstractmethod
    def perimeter(self) -> float:
        pass

class Rectangle(Shape):
    def __init__(self, width: float, height: float):
        self.width = width
        self.height = height
    
    def area(self) -> float:
        return self.width * self.height
    
    def perimeter(self) -> float:
        return 2 * (self.width + self.height)

class Circle(Shape):
    def __init__(self, radius: float):
        self.radius = radius
    
    def area(self) -> float:
        return math.pi * self.radius ** 2
    
    def perimeter(self) -> float:
        return 2 * math.pi * self.radius

class Triangle(Shape):
    def __init__(self, a: float, b: float, c: float):
        self.a, self.b, self.c = a, b, c
    
    def area(self) -> float:
        # Heron's formula
        s = self.perimeter() / 2
        return math.sqrt(s * (s-self.a) * (s-self.b) * (s-self.c))
    
    def perimeter(self) -> float:
        return self.a + self.b + self.c

# Polymorphic function - works with ANY shape
def print_shape_info(shape: Shape):
    print(f"Area: {shape.area():.2f}")
    print(f"Perimeter: {shape.perimeter():.2f}")

def total_area(shapes: List[Shape]) -> float:
    return sum(shape.area() for shape in shapes)

# Usage
shapes = [
    Rectangle(10, 5),
    Circle(7),
    Triangle(3, 4, 5)
]

for shape in shapes:
    print_shape_info(shape)  # Same function, different behavior

print(f"Total area: {total_area(shapes)}")

Method Overloading (Compile-time Polymorphism)

Python doesn’t have true overloading, but you can achieve similar results:
from typing import overload, Union

class Calculator:
    # Using default arguments
    def add(self, a: float, b: float, c: float = 0) -> float:
        return a + b + c
    
    # Using *args
    def multiply(self, *args: float) -> float:
        result = 1
        for num in args:
            result *= num
        return result
    
    # Using Union types
    def double(self, value: Union[int, str, list]) -> Union[int, str, list]:
        if isinstance(value, int):
            return value * 2
        elif isinstance(value, str):
            return value + value
        elif isinstance(value, list):
            return value + value

calc = Calculator()
print(calc.add(1, 2))       # 3
print(calc.add(1, 2, 3))    # 6
print(calc.multiply(2, 3, 4))  # 24
print(calc.double(5))       # 10
print(calc.double("hi"))    # "hihi"

Composition vs Inheritance

# Inheritance approach - rigid
class FlyingBird(Bird):
    def fly(self):
        print("Flying")

class SwimmingBird(Bird):
    def swim(self):
        print("Swimming")

# What about a duck that can fly AND swim? Multiple inheritance gets messy.

# Composition approach - flexible
class FlyBehavior(ABC):
    @abstractmethod
    def fly(self):
        pass

class SwimBehavior(ABC):
    @abstractmethod
    def swim(self):
        pass

class CanFly(FlyBehavior):
    def fly(self):
        print("Flying high!")

class CanSwim(SwimBehavior):
    def swim(self):
        print("Swimming gracefully!")

class Duck:
    def __init__(self):
        self.fly_behavior = CanFly()
        self.swim_behavior = CanSwim()
    
    def fly(self):
        self.fly_behavior.fly()
    
    def swim(self):
        self.swim_behavior.swim()

# Can even change behavior at runtime!
duck = Duck()
duck.fly()   # Flying high!
duck.fly_behavior = CantFly()  # Duck got injured
duck.fly()   # Can't fly anymore
Interview Tip: Be ready to explain trade-offs between inheritance and composition. “Favor composition over inheritance” is a common principle, but inheritance has its place for true “is-a” relationships.