DEV Community

Mary Ngure
Mary Ngure

Posted on

Object-Oriented Programming: The 4 Pillars Explained

Object-Oriented Programming (OOP) is one of the most widely used paradigms in software development. Instead of organizing code as a long list of functions operating on loose data, OOP organizes it around objects: bundles of data (attributes) and behavior (methods) that model things in your problem domain.

Whether you're building a web API, a data pipeline, or a game, understanding OOP helps you write code that is easier to reason about, reuse, and maintain.

In this article, we'll cover the four pillars of OOP with practical Python examples:

  1. Encapsulation
  2. Abstraction
  3. Inheritance
  4. Polymorphism

Quick Refresher: Classes and Objects

A class is a blueprint. An object is an instance created from that blueprint.

class Dog:
    def __init__(self, name: str, age: int):
        self.name = name  # attribute
        self.age = age    # attribute

    def bark(self) -> str:  # method
        return f"{self.name} says woof!"


rex = Dog("Rex", 3)
print(rex.bark())  # Rex says woof!
Enter fullscreen mode Exit fullscreen mode

Dog is the class; rex is an object with its own name and age. With that in place, let's look at the pillars.


1. Encapsulation

Encapsulation means bundling data and the methods that operate on it into one unit, and restricting direct access to the internal state. Outside code interacts with the object through a controlled public interface rather than poking at its internals.

Why does this matter? It protects your object's invariants. If anyone can set balance = -1000000 directly, you can't guarantee your object is ever in a valid state.

Example: A bank account

class BankAccount:
    def __init__(self, owner: str, balance: float = 0.0):
        self.owner = owner
        self._balance = balance  # "protected" by convention

    @property
    def balance(self) -> float:
        """Read-only access to the balance."""
        return self._balance

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

    def withdraw(self, amount: float) -> None:
        if amount <= 0:
            raise ValueError("Withdrawal must be positive.")
        if amount > self._balance:
            raise ValueError("Insufficient funds.")
        self._balance -= amount


account = BankAccount("Amina", 100)
account.deposit(50)
account.withdraw(30)
print(account.balance)  # 120

# account.balance = 1_000_000  -> AttributeError: can't set attribute
Enter fullscreen mode Exit fullscreen mode

A few notes on Python specifically:

  • Python has no strict private keyword. A single leading underscore (_balance) is a convention that says "internal, don't touch."
  • A double underscore (__balance) triggers name mangling, which makes accidental access harder, but it is still not true privacy.
  • The @property decorator lets you expose a value as a read-only attribute, and you can add validation later without changing how callers use the class.

Takeaway: Hide the internals, expose a safe interface, and enforce rules in one place.


2. Abstraction

Abstraction means exposing what an object does while hiding how it does it. Users of your class shouldn't need to know the implementation details, only the contract.

In Python, the abc module lets you define abstract base classes that declare methods subclasses must implement.

Example: Data loaders

Imagine a program that needs to load data from different sources. The calling code shouldn't care whether the data comes from a CSV file or an API.

from abc import ABC, abstractmethod
import csv
import json


class DataLoader(ABC):
    """Contract that every loader must follow."""

    @abstractmethod
    def load(self) -> list[dict]:
        """Return the data as a list of records."""
        ...


class CSVLoader(DataLoader):
    def __init__(self, path: str):
        self.path = path

    def load(self) -> list[dict]:
        with open(self.path, newline="", encoding="utf-8") as f:
            return list(csv.DictReader(f))


class JSONLoader(DataLoader):
    def __init__(self, path: str):
        self.path = path

    def load(self) -> list[dict]:
        with open(self.path, encoding="utf-8") as f:
            return json.load(f)


def count_records(loader: DataLoader) -> int:
    # This function only knows about the abstraction.
    return len(loader.load())
Enter fullscreen mode Exit fullscreen mode

If you try to instantiate the abstract class directly, or a subclass that forgets to implement load, Python stops you:

loader = DataLoader()
# TypeError: Can't instantiate abstract class DataLoader
# with abstract method load
Enter fullscreen mode Exit fullscreen mode

Takeaway: Abstraction defines a clear contract. Code that depends on the contract keeps working no matter how many new implementations you add.

Encapsulation vs. abstraction: Encapsulation is about protecting data by controlling access. Abstraction is about simplifying usage by hiding complexity. They work together but solve different problems.


3. Inheritance

Inheritance lets a class (the child or subclass) acquire the attributes and methods of another class (the parent or superclass). It promotes code reuse and models "is-a" relationships: a Manager is an Employee.

Example: Employees

class Employee:
    def __init__(self, name: str, salary: float):
        self.name = name
        self.salary = salary

    def describe(self) -> str:
        return f"{self.name} earns {self.salary:,.2f} per month."


class Manager(Employee):
    def __init__(self, name: str, salary: float, team: list[Employee] | None = None):
        super().__init__(name, salary)  # reuse the parent's setup
        self.team = team or []

    def add_to_team(self, employee: Employee) -> None:
        self.team.append(employee)

    def describe(self) -> str:
        base = super().describe()  # extend, don't rewrite
        return f"{base} They manage {len(self.team)} people."


dev = Employee("Brian", 4000)
lead = Manager("Wanjiru", 6500)
lead.add_to_team(dev)

print(dev.describe())
print(lead.describe())
Enter fullscreen mode Exit fullscreen mode

Output:

Brian earns 4,000.00 per month.
Wanjiru earns 6,500.00 per month. They manage 1 people.
Enter fullscreen mode Exit fullscreen mode

Key ideas:

  • Manager(Employee) declares the inheritance.
  • super() calls the parent's implementation, so you build on it instead of duplicating it.
  • The child can override methods (like describe) to specialize behavior.

A word of caution

Inheritance is powerful but easy to overuse. Deep hierarchies become rigid and hard to change. A good rule of thumb is "favor composition over inheritance": if the relationship is "has-a" rather than "is-a," pass in another object instead of subclassing.

class Engine:
    def start(self) -> str:
        return "Engine started."


class Car:
    def __init__(self):
        self.engine = Engine()  # Car HAS an Engine (composition)

    def start(self) -> str:
        return self.engine.start()
Enter fullscreen mode Exit fullscreen mode

Python also supports multiple inheritance (class C(A, B)), but use it sparingly. It can introduce confusing method resolution order (MRO) issues.

Takeaway: Use inheritance for genuine "is-a" relationships and shared behavior, and reach for composition when in doubt.


4. Polymorphism

Polymorphism means "many forms." It lets you treat different objects through the same interface, with each object responding in its own way. You write code against a general type, and the correct behavior is chosen at runtime.

Example: Shapes

from abc import ABC, abstractmethod
import math


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


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


class Circle(Shape):
    def __init__(self, radius: float):
        self.radius = radius

    def area(self) -> float:
        return math.pi * self.radius ** 2


class Triangle(Shape):
    def __init__(self, base: float, height: float):
        self.base = base
        self.height = height

    def area(self) -> float:
        return 0.5 * self.base * self.height


shapes: list[Shape] = [Rectangle(4, 5), Circle(3), Triangle(6, 2)]

for shape in shapes:
    print(f"{type(shape).__name__}: {shape.area():.2f}")
Enter fullscreen mode Exit fullscreen mode

Output:

Rectangle: 20.00
Circle: 28.27
Triangle: 6.00
Enter fullscreen mode Exit fullscreen mode

The loop doesn't use if isinstance(...) checks. It just calls .area(), and each object does the right thing. Adding a new Hexagon class later requires no changes to the loop.

Duck typing

Python takes polymorphism a step further with duck typing: "If it walks like a duck and quacks like a duck, it's a duck." An object doesn't need a shared parent class, it just needs the right methods.

class Duck:
    def speak(self) -> str:
        return "Quack!"


class Robot:
    def speak(self) -> str:
        return "Beep boop."


def make_it_speak(thing) -> None:
    print(thing.speak())


for item in (Duck(), Robot()):
    make_it_speak(item)
Enter fullscreen mode Exit fullscreen mode

If you want duck typing with static type checking, Python's typing.Protocol lets you describe the required interface without inheritance.

Takeaway: Write code that depends on behavior, not concrete types, and new functionality becomes a matter of adding classes instead of editing existing code.


Putting It All Together

Here is a small example that uses all four pillars in one place: a notification system.

from abc import ABC, abstractmethod


class Notifier(ABC):                       # Abstraction
    def __init__(self, recipient: str):
        self._recipient = recipient        # Encapsulation (internal state)

    @abstractmethod
    def send(self, message: str) -> str:
        ...


class EmailNotifier(Notifier):             # Inheritance
    def send(self, message: str) -> str:
        return f"Emailing {self._recipient}: {message}"


class SMSNotifier(Notifier):               # Inheritance
    def send(self, message: str) -> str:
        return f"Texting {self._recipient}: {message}"


def alert_all(notifiers: list[Notifier], message: str) -> None:
    for n in notifiers:                    # Polymorphism
        print(n.send(message))


alert_all(
    [EmailNotifier("mary@example.com"), SMSNotifier("+254700000000")],
    "Your report is ready.",
)
Enter fullscreen mode Exit fullscreen mode

Output:

Emailing mary@example.com: Your report is ready.
Texting +254700000000: Your report is ready.
Enter fullscreen mode Exit fullscreen mode
  • Abstraction: Notifier defines what a notifier does.
  • Encapsulation: _recipient is kept internal to the object.
  • Inheritance: Both notifiers reuse the base class's structure.
  • Polymorphism: alert_all works with any notifier, treating them uniformly.

Summary

Pillar Core idea Python tools
Encapsulation Bundle data with behavior and control access _underscore convention, @property
Abstraction Expose what, hide how abc.ABC, @abstractmethod
Inheritance Reuse and specialize via "is-a" relationships class Child(Parent), super()
Polymorphism One interface, many behaviors Method overriding, duck typing, Protocol

OOP isn't about using every feature everywhere. It's about modeling your problem clearly, keeping responsibilities separated, and making code easy to extend without breaking what already works. Start small, practice by modeling real things you know, and the four pillars will start to feel natural.

Top comments (0)