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.Copy
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
| Benefit | Description |
|---|---|
| Data Protection | Prevents invalid states |
| Flexibility | Change implementation without affecting users |
| Validation | Enforce business rules at access points |
| Debugging | All modifications go through known paths |
2. Abstraction
Abstraction hides complexity by showing only necessary details.Copy
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.Copy
# 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 Inheritance | Use Composition |
|---|---|
| True “is-a” relationship | ”Has-a” relationship |
| Shared behavior across types | Flexible behavior mixing |
| Stable base class | Frequently changing behavior |
| Limited hierarchy depth | Complex variations |
4. Polymorphism
Polymorphism allows objects of different types to be treated uniformly.Method Overriding (Runtime Polymorphism)
Copy
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:Copy
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
Copy
# 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.