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:
- Encapsulation
- Abstraction
- Inheritance
- 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!
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
A few notes on Python specifically:
- Python has no strict
privatekeyword. 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
@propertydecorator 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())
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
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())
Output:
Brian earns 4,000.00 per month.
Wanjiru earns 6,500.00 per month. They manage 1 people.
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()
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}")
Output:
Rectangle: 20.00
Circle: 28.27
Triangle: 6.00
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)
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.",
)
Output:
Emailing mary@example.com: Your report is ready.
Texting +254700000000: Your report is ready.
-
Abstraction:
Notifierdefines what a notifier does. -
Encapsulation:
_recipientis kept internal to the object. - Inheritance: Both notifiers reuse the base class's structure.
-
Polymorphism:
alert_allworks 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)