Python – OOP

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:

  • Model real-world entities — classes map naturally to business concepts (users, orders, payments).
  • Manage complexity — encapsulation hides internal details so teams can work on separate modules without stepping on each other.
  • Promote reuse — inheritance and composition let you build new functionality on top of existing, tested code.
  • Enable flexibility — polymorphism lets you swap implementations without rewriting the code that depends on them.

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:

  • State (attributes) — the data the object holds.
  • Behavior (methods) — the actions the object can perform.

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.
  • Method overriding — a child class can redefine a parent’s method to provide specialized behavior.
  • Multiple inheritance — Python supports inheriting from more than one class. The Method Resolution Order (MRO) determines which method is called when there are conflicts (C3 linearization).

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:

  • Method overriding — child classes provide their own implementation of a parent’s method.
  • Duck typing — “If it walks like a duck and quacks like a duck, it is a duck.” Python does not require formal interfaces; any object that implements the expected methods works.
  • Operator overloading — classes can define how built-in operators (+, ==, 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

  • Follow naming conventions: UpperCamelCase for class names, snake_case for methods and attributes, _single_underscore for protected, __double_underscore for name-mangled private.
  • Use __init__ to set all instance attributes: Avoid adding attributes outside of __init__. This makes the class’s state explicit and discoverable.
  • Implement __repr__: Always define __repr__ for your classes. It is invaluable for debugging. A good __repr__ returns a string that could recreate the object.
  • Use type hints: Type annotations make your code self-documenting and enable static analysis tools like mypy to catch bugs before runtime.
  • Prefer composition over inheritance: Use inheritance for genuine “is-a” relationships. Use composition for “has-a” relationships.
  • Keep classes small and focused: Each class should have a single, clear purpose. If you struggle to name a class or describe it in one sentence, it is doing too much.
  • Use @property instead of getters/setters: Python is not Java. Replace get_name() / set_name() with @property for a cleaner, more Pythonic API.
  • Leverage ABCs for interfaces: When you need to enforce that subclasses implement specific methods, use abc.ABC and @abstractmethod rather than raising NotImplementedError.
  • Write docstrings: Every class and public method should have a docstring explaining what it does, not how it does it.

Key Takeaways

  • Encapsulation protects internal state. Use _ and __ naming conventions and @property to control access with validation.
  • Inheritance promotes code reuse through “is-a” relationships. Use super() for proper initialization and keep hierarchies shallow.
  • Polymorphism lets different objects respond to the same interface. Python’s duck typing makes this especially flexible — no formal interface declarations needed.
  • Abstraction defines contracts using abc.ABC and @abstractmethod, catching missing implementations at instantiation time.
  • Favor composition over inheritance for flexible, loosely coupled designs.
  • Follow SOLID principles — especially Single Responsibility and Open/Closed — to write code that is easy to extend and hard to break.
  • Watch for pitfalls: the diamond problem, deep hierarchies, God classes, and mutable default arguments.
  • OOP is not about using classes everywhere — it is about choosing the right tool for the problem. A well-designed system uses OOP where it reduces complexity, not where it adds it.

Source code on Github




Subscribe To Our Newsletter
You will receive our latest post and tutorial.
Thank you for subscribing!

required
required


Leave a Reply

Your email address will not be published. Required fields are marked *