Introduction to Object-Oriented Programming
Object-Oriented Programming (OOP) is a programming paradigm that organizes software design around objects rather than functions and logic. An object bundles together data (attributes) and the operations that act on that data (methods) into a single, self-contained unit. This is not merely an academic concept — it is the dominant paradigm behind most large-scale, production-grade software systems in the world today.
Why does OOP matter? When a codebase grows beyond a few hundred lines, procedural code tends to become tangled and fragile. OOP addresses this by providing mechanisms to:
Python is a multi-paradigm language — it supports procedural, functional, and object-oriented styles. However, at its core, everything in Python is an object: integers, strings, functions, even classes themselves. Understanding OOP in Python is therefore not optional; it is foundational.
An object has two fundamental characteristics:
A class is a blueprint that defines what attributes and methods its objects will have. No memory is allocated until you instantiate the class to create an object.
class User:
"""Blueprint for a user in the system."""
def __init__(self, name: str, age: int, role: str = "USER"):
self.name = name
self.age = age
self.role = role
def greet(self) -> str:
return f"Hello, my name is {self.name} and I am {self.age} years old."
def __repr__(self) -> str:
return f"User(name='{self.name}', age={self.age}, role='{self.role}')"
# Creating instances (objects)
alice = User("Alice", 30, "ADMIN")
bob = User("Bob", 25)
print(alice.greet()) # Hello, my name is Alice and I am 30 years old.
print(repr(bob)) # User(name='Bob', age=25, role='USER')
The four pillars of OOP are Encapsulation, Inheritance, Polymorphism, and Abstraction. Let us explore each one in depth.
Pillar 1 — Encapsulation
Encapsulation is the practice of bundling data with the methods that operate on it and restricting direct access to some of that data. The goal is to protect an object’s internal state from invalid or unintended modifications. External code interacts with the object through a well-defined public interface, while internal implementation details remain hidden.
Python uses a naming convention for access control rather than strict access modifiers like Java or C++:
public_attr — accessible from anywhere (the default)._protected_attr — a single leading underscore signals “internal use”; it is a convention, not enforced by the interpreter.__private_attr — a double leading underscore triggers name mangling, making accidental access from outside the class harder (the attribute becomes _ClassName__private_attr internally).The Pythonic way to expose controlled access to internal data is through the @property decorator, which lets you define getters and setters that look like simple attribute access to the caller.
Practical Example — BankAccount with Validation
class BankAccount:
"""A bank account with encapsulated balance and validation."""
def __init__(self, owner: str, initial_balance: float = 0.0):
self._owner = owner
self.__balance = initial_balance # name-mangled: _BankAccount__balance
@property
def owner(self) -> str:
"""Read-only access to the account owner."""
return self._owner
@property
def balance(self) -> float:
"""Controlled read access to balance."""
return self.__balance
def deposit(self, amount: float) -> None:
if amount <= 0:
raise ValueError("Deposit amount must be positive.")
self.__balance += amount
print(f"Deposited ${amount:.2f}. New balance: ${self.__balance:.2f}")
def withdraw(self, amount: float) -> None:
if amount <= 0:
raise ValueError("Withdrawal amount must be positive.")
if amount > self.__balance:
raise ValueError("Insufficient funds.")
self.__balance -= amount
print(f"Withdrew ${amount:.2f}. New balance: ${self.__balance:.2f}")
def __repr__(self) -> str:
return f"BankAccount(owner='{self._owner}', balance={self.__balance:.2f})"
account = BankAccount("Alice", 1000.0)
account.deposit(500) # Deposited $500.00. New balance: $1500.00
account.withdraw(200) # Withdrew $200.00. New balance: $1300.00
print(account.balance) # 1300.0 (via @property getter)
# Direct modification is prevented:
# account.__balance = 999999 # This creates a NEW attribute, does NOT touch the real balance
# account.balance = 999999 # AttributeError: can't set attribute (no setter defined)
Notice how balance is exposed as a read-only property. There is no setter, so the only way to change the balance is through deposit() and withdraw(), both of which enforce business rules. This is encapsulation in practice.
Pillar 2 — Inheritance
Inheritance allows a new class (the child or derived class) to acquire the attributes and methods of an existing class (the parent or base class). This promotes code reuse — shared behavior is defined once in the parent, and children only implement what is specific to them.
Key concepts:
super() — calls the parent class’s method, ensuring proper initialization up the chain.Practical Example — Animal Hierarchy
class Animal:
"""Base class for all animals."""
def __init__(self, name: str, species: str):
self.name = name
self.species = species
def sound(self) -> str:
raise NotImplementedError("Subclasses must implement sound()")
def describe(self) -> str:
return f"{self.name} is a {self.species} that says '{self.sound()}'."
class Dog(Animal):
"""A dog is an Animal with specific behavior."""
def __init__(self, name: str, breed: str):
super().__init__(name, species="Dog")
self.breed = breed
def sound(self) -> str:
return "Woof!"
def fetch(self, item: str) -> str:
return f"{self.name} fetches the {item}."
class Cat(Animal):
"""A cat is an Animal with specific behavior."""
def __init__(self, name: str, indoor: bool = True):
super().__init__(name, species="Cat")
self.indoor = indoor
def sound(self) -> str:
return "Meow!"
def purr(self) -> str:
return f"{self.name} purrs contentedly."
# Usage
rex = Dog("Rex", breed="German Shepherd")
whiskers = Cat("Whiskers", indoor=False)
print(rex.describe()) # Rex is a Dog that says 'Woof!'.
print(rex.fetch("ball")) # Rex fetches the ball.
print(whiskers.describe()) # Whiskers is a Cat that says 'Meow!'.
print(whiskers.purr()) # Whiskers purrs contentedly.
Multiple Inheritance and MRO
Python supports multiple inheritance, which means a class can inherit from more than one parent. The Method Resolution Order (MRO) determines the order in which base classes are searched when looking for a method.
class Swimmer:
def move(self) -> str:
return "Swimming"
class Flyer:
def move(self) -> str:
return "Flying"
class Duck(Swimmer, Flyer):
"""A duck can both swim and fly."""
pass
donald = Duck()
print(donald.move()) # "Swimming" - Swimmer comes first in MRO
# Inspect the MRO
print(Duck.__mro__)
# (<class 'Duck'>, <class 'Swimmer'>, <class 'Flyer'>, <class 'object'>)
The MRO follows C3 linearization. In the example above, Swimmer appears before Flyer in the class definition, so Swimmer.move() wins. Understanding MRO is critical when working with multiple inheritance — use ClassName.__mro__ or ClassName.mro() to inspect it.
Pillar 3 — Polymorphism
Polymorphism means “many forms.” It allows objects of different types to be treated through a uniform interface. In Python, polymorphism is pervasive and manifests in several ways:
+, ==, len(), etc.) behave via dunder methods.Practical Example — Shape Classes with area()
import math
class Shape:
"""Base shape class."""
def area(self) -> float:
raise NotImplementedError("Subclasses must implement area()")
def __str__(self) -> str:
return f"{self.__class__.__name__}(area={self.area():.2f})"
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
# Polymorphism in action: same interface, different behavior
shapes = [Rectangle(10, 5), Circle(7), Triangle(8, 6)]
for shape in shapes:
print(shape) # Calls __str__ which calls area() - each shape computes its own
# Output:
# Rectangle(area=50.00)
# Circle(area=153.94)
# Triangle(area=24.00)
Duck Typing Example
class CSVExporter:
def export(self, data: list) -> str:
return "\n".join(",".join(str(v) for v in row) for row in data)
class JSONExporter:
def export(self, data: list) -> str:
import json
return json.dumps(data, indent=2)
def run_export(exporter, data):
"""Works with ANY object that has an export() method.
No inheritance required - this is duck typing."""
print(exporter.export(data))
data = [["name", "age"], ["Alice", 30], ["Bob", 25]]
run_export(CSVExporter(), data)
run_export(JSONExporter(), data)
Notice that CSVExporter and JSONExporter share no common parent class. The function run_export() does not care about the object’s type — it only cares that the object has an export() method. This is duck typing, and it is one of Python’s most powerful features.
Operator Overloading
class Vector:
"""A simple two-dimensional vector with operator overloading."""
def __init__(self, x: float, y: float):
self.x = x
self.y = y
def __add__(self, other: "Vector") -> "Vector":
return Vector(self.x + other.x, self.y + other.y)
def __eq__(self, other: "Vector") -> bool:
return self.x == other.x and self.y == other.y
def __abs__(self) -> float:
return (self.x ** 2 + self.y ** 2) ** 0.5
def __repr__(self) -> str:
return f"Vector({self.x}, {self.y})"
v1 = Vector(3, 4)
v2 = Vector(1, 2)
print(v1 + v2) # Vector(4, 6)
print(abs(v1)) # 5.0
print(v1 == v2) # False
Pillar 4 — Abstraction
Abstraction means exposing only the essential features of an object while hiding the complex implementation details. In Python, we achieve formal abstraction using the abc module (Abstract Base Classes). An abstract class cannot be instantiated directly — it exists only to define a contract that subclasses must fulfill.
Practical Example — Payment Processing System
from abc import ABC, abstractmethod
class AbstractPayment(ABC):
"""Abstract base class defining the payment contract."""
def __init__(self, amount: float):
if amount <= 0:
raise ValueError("Payment amount must be positive.")
self._amount = amount
@abstractmethod
def authorize(self) -> bool:
"""Verify the payment method is valid. Must be implemented by subclasses."""
pass
@abstractmethod
def capture(self) -> str:
"""Process the actual payment. Must be implemented by subclasses."""
pass
def process(self) -> str:
"""Template method: authorize then capture."""
if self.authorize():
return self.capture()
return "Payment authorization failed."
class CreditCardPayment(AbstractPayment):
def __init__(self, amount: float, card_number: str, cvv: str):
super().__init__(amount)
self._card_number = card_number
self._cvv = cvv
def authorize(self) -> bool:
# In production, this would call a payment gateway API
print(f"Authorizing credit card ending in {self._card_number[-4:]}...")
return len(self._card_number) == 16 and len(self._cvv) == 3
def capture(self) -> str:
return f"Charged ${self._amount:.2f} to card ending in {self._card_number[-4:]}."
class PayPalPayment(AbstractPayment):
def __init__(self, amount: float, email: str):
super().__init__(amount)
self._email = email
def authorize(self) -> bool:
print(f"Authorizing PayPal account {self._email}...")
return "@" in self._email
def capture(self) -> str:
return f"Charged ${self._amount:.2f} to PayPal account {self._email}."
# Usage
payments = [
CreditCardPayment(99.99, "4111111111111234", "123"),
PayPalPayment(49.50, "alice@example.com"),
]
for payment in payments:
result = payment.process()
print(result)
# Trying to instantiate the abstract class directly:
# payment = AbstractPayment(100) # TypeError: Can't instantiate abstract class
The AbstractPayment class defines the contract (authorize() and capture()) and provides a template method (process()) that orchestrates the workflow. Subclasses fill in the details. If a subclass forgets to implement an abstract method, Python raises a TypeError at instantiation time — catching the bug early.
SOLID Principles in Python
The SOLID principles are five design guidelines that help you write maintainable, flexible, and scalable object-oriented code. Here is a brief overview of the two most impactful ones.
Single Responsibility Principle (SRP)
A class should have one reason to change. If a class handles both business logic and file I/O, changes to the file format force you to modify a class that also contains business rules — risking unintended breakage.
# BAD: One class doing too many things
class Report:
def __init__(self, data):
self.data = data
def calculate_stats(self):
"""Business logic"""
return {"mean": sum(self.data) / len(self.data)}
def save_to_file(self, path):
"""File I/O - separate concern"""
with open(path, "w") as f:
f.write(str(self.calculate_stats()))
def send_email(self, recipient):
"""Email logic - yet another concern"""
pass
# GOOD: Each class has one responsibility
class StatsCalculator:
def calculate(self, data: list) -> dict:
return {"mean": sum(data) / len(data)}
class ReportSaver:
def save(self, content: dict, path: str) -> None:
with open(path, "w") as f:
f.write(str(content))
class EmailService:
def send(self, recipient: str, body: str) -> None:
pass # email logic here
Open/Closed Principle (OCP)
Classes should be open for extension but closed for modification. You should be able to add new behavior without changing existing, tested code.
from abc import ABC, abstractmethod
class Discount(ABC):
@abstractmethod
def apply(self, price: float) -> float:
pass
class NoDiscount(Discount):
def apply(self, price: float) -> float:
return price
class PercentageDiscount(Discount):
def __init__(self, percent: float):
self.percent = percent
def apply(self, price: float) -> float:
return price * (1 - self.percent / 100)
class FixedDiscount(Discount):
def __init__(self, amount: float):
self.amount = amount
def apply(self, price: float) -> float:
return max(0, price - self.amount)
# Adding a new discount type does NOT require modifying existing classes.
# Just create a new subclass:
class BuyOneGetOneFree(Discount):
def apply(self, price: float) -> float:
return price / 2
Composition vs Inheritance
A common saying among experienced developers is “Favor composition over inheritance.” Inheritance creates a tight coupling between parent and child — changes to the parent ripple down to all descendants. Composition, on the other hand, builds functionality by containing objects rather than extending them, leading to more flexible designs.
When to use inheritance: There is a clear “is-a” relationship (a Dog is an Animal).
When to use composition: There is a “has-a” relationship (a Car has an Engine).
# INHERITANCE approach - tightly coupled
class Engine:
def start(self):
return "Engine started."
class CarBad(Engine): # A Car IS an Engine? That does not make sense.
pass
# COMPOSITION approach - flexible and logical
class Engine:
def start(self) -> str:
return "Engine started."
class ElectricMotor:
def start(self) -> str:
return "Electric motor humming."
class Car:
"""A Car HAS an engine. This is composition."""
def __init__(self, engine):
self._engine = engine # Inject the dependency
def start(self) -> str:
return f"Car: {self._engine.start()}"
# Easily swap implementations without changing Car
gas_car = Car(Engine())
electric_car = Car(ElectricMotor())
print(gas_car.start()) # Car: Engine started.
print(electric_car.start()) # Car: Electric motor humming.
With composition, swapping an Engine for an ElectricMotor requires zero changes to the Car class. With inheritance, you would need a completely separate class hierarchy.
Common Pitfalls
Even experienced developers fall into these traps. Knowing them upfront saves hours of debugging.
1. The Diamond Problem
When a class inherits from two classes that share a common ancestor, method resolution can become confusing. Python handles this via MRO (C3 linearization), but it can still lead to subtle bugs if you are not careful with super() calls.
class A:
def greet(self):
return "Hello from A"
class B(A):
def greet(self):
return "Hello from B"
class C(A):
def greet(self):
return "Hello from C"
class D(B, C):
pass
d = D()
print(d.greet()) # "Hello from B" - follows MRO: D -> B -> C -> A
print(D.__mro__) # Inspect to understand the resolution order
2. Deep Inheritance Hierarchies
If your inheritance chain goes more than two or three levels deep, the code becomes brittle and hard to reason about. A change in a grandparent class can have unexpected effects three levels down. Prefer composition for complex relationships.
3. God Classes
A “God class” is a class that knows too much or does too much. It violates the Single Responsibility Principle and becomes a maintenance nightmare. If your class has more than ten to fifteen methods or its docstring needs a table of contents, it is time to break it apart.
4. Mutable Default Arguments
# BAD: Mutable default argument is shared across all instances
class Team:
def __init__(self, members=[]):
self.members = members
t1 = Team()
t1.members.append("Alice")
t2 = Team()
print(t2.members) # ['Alice'] - Bug! t2 shares t1's list
# GOOD: Use None and create a new list in __init__
class Team:
def __init__(self, members=None):
self.members = members if members is not None else []
Best Practices
UpperCamelCase for class names, snake_case for methods and attributes, _single_underscore for protected, __double_underscore for name-mangled private.__init__ to set all instance attributes: Avoid adding attributes outside of __init__. This makes the class’s state explicit and discoverable.__repr__: Always define __repr__ for your classes. It is invaluable for debugging. A good __repr__ returns a string that could recreate the object.@property instead of getters/setters: Python is not Java. Replace get_name() / set_name() with @property for a cleaner, more Pythonic API.abc.ABC and @abstractmethod rather than raising NotImplementedError.Key Takeaways
_ and __ naming conventions and @property to control access with validation.super() for proper initialization and keep hierarchies shallow.abc.ABC and @abstractmethod, catching missing implementations at instantiation time.