Skip to content

OOP — Fundamentals

Object-Oriented Programming organizes code around objects — entities that bundle state (data) and behavior (methods) together. Four pillars define the paradigm.


Encapsulation

Hide internal state. Expose only what callers need.

class BankAccount:
    def __init__(self, balance: float) -> None:
        self._balance = balance          # private — not part of public API

    def deposit(self, amount: float) -> None:
        if amount <= 0:
            raise ValueError("Amount must be positive")
        self._balance += amount

    def get_balance(self) -> float:
        return self._balance

Why it matters: - Caller cannot set _balance = -9999 directly - Validation lives in one place - Internal representation can change without breaking callers


Abstraction

Expose what an object does, hide how it does it.

from abc import ABC, abstractmethod


class PaymentGateway(ABC):
    @abstractmethod
    def charge(self, amount: float, token: str) -> str:
        """Returns transaction ID."""


class StripeGateway(PaymentGateway):
    def charge(self, amount: float, token: str) -> str:
        # Stripe-specific HTTP calls hidden here
        return "stripe_txn_abc123"


class PayPalGateway(PaymentGateway):
    def charge(self, amount: float, token: str) -> str:
        return "paypal_txn_xyz789"

The caller works with PaymentGateway.charge() — it doesn't know or care whether Stripe or PayPal is underneath.


Inheritance

A subclass extends or specializes a base class, reusing its behavior.

class Animal:
    def __init__(self, name: str) -> None:
        self.name = name

    def describe(self) -> str:
        return f"I am {self.name}"


class Dog(Animal):
    def speak(self) -> str:
        return "Woof"


class Cat(Animal):
    def speak(self) -> str:
        return "Meow"

When to use: when the relationship is genuinely "is-a" (Dog is an Animal).

When NOT to use: when you only want to reuse methods — use composition instead. Inheritance creates tight coupling. Misuse leads to fragile hierarchies.


Polymorphism

The same interface behaves differently depending on the actual type at runtime.

def make_sound(animals: list[Animal]) -> None:
    for animal in animals:
        print(animal.speak())          # dispatches to Dog.speak or Cat.speak


make_sound([Dog("Rex"), Cat("Whiskers"), Dog("Buddy")])
# Woof
# Meow
# Woof

No if isinstance(animal, Dog) needed. New types can be added without changing make_sound. This is the Open/Closed Principle in action.


Pillar Relationships

Pillar Core Idea Benefit
Encapsulation Hide state, expose interface Safety, changeability
Abstraction Hide implementation details Loose coupling
Inheritance Reuse and extend behavior DRY when appropriate
Polymorphism One interface, many behaviors Extensibility

Composition vs Inheritance

Prefer composition when a class uses another, not is another.

# Inheritance (fragile if Logger changes)
class LoggedService(Logger):
    ...

# Composition (Logger is a dependency, not a parent)
class OrderService:
    def __init__(self, logger: Logger) -> None:
        self._logger = logger

Rule of thumb: if you can describe the relationship as "has-a", use composition.