Python PythonOOPIntermediate

Object-Oriented Programming in Python: Classes, Objects, Inheritance and More

What is Object-Oriented Programming?

Object-Oriented Programming (OOP) is a programming paradigm that organises code around objects — self-contained units that bundle together related data (attributes) and behaviour (methods). Instead of writing a long sequence of instructions that operate on separate variables, you model your program as a collection of interacting objects that each manage their own state.

OOP became the dominant paradigm in software engineering because it mirrors how humans naturally think about the world. A banking application has accounts, transactions, and customers — not just arrays and functions. A game has players, enemies, and items. Modelling these as objects makes code more intuitive, reusable, maintainable, and easier to scale in large teams.

Python is a multi-paradigm language that fully supports OOP alongside procedural and functional styles. Everything in Python is already an object — integers, strings, lists, and even functions are all objects under the hood.

Classes and Objects

A class is the blueprint or template. An object (also called an instance) is a concrete entity created from that blueprint. You can create as many objects from one class as you need, each with its own independent data:

class BankAccount:
    """Represents a bank account."""

    # Class attribute — shared by ALL instances
    bank_name = "SwapxBank"

    def __init__(self, owner, initial_balance=0.0):
        """Called automatically when a new BankAccount is created."""
        # Instance attributes — unique to each object
        self.owner   = owner
        self.balance = initial_balance
        self._transactions = []    # underscore prefix = private by convention

    def deposit(self, amount):
        """Add money to the account."""
        if amount <= 0:
            raise ValueError("Deposit amount must be positive")
        self.balance += amount
        self._transactions.append(('deposit', amount))
        print(f"Deposited ₹{amount:.2f}. New balance: ₹{self.balance:.2f}")

    def withdraw(self, amount):
        """Remove money from the account."""
        if amount > self.balance:
            raise ValueError("Insufficient funds")
        self.balance -= amount
        self._transactions.append(('withdrawal', amount))
        print(f"Withdrew ₹{amount:.2f}. New balance: ₹{self.balance:.2f}")

    def get_statement(self):
        """Print all transactions."""
        print(f"\n--- Statement for {self.owner} ---")
        for kind, amt in self._transactions:
            print(f"  {kind.capitalize()}: ₹{amt:.2f}")
        print(f"  Current balance: ₹{self.balance:.2f}\n")

# Create instances (objects) from the class
alice_account = BankAccount("Alice", 5000.0)
bob_account   = BankAccount("Bob")            # starts at 0

alice_account.deposit(2000)
alice_account.withdraw(500)
alice_account.get_statement()

print(BankAccount.bank_name)   # "SwapxBank" — class attribute

Special (Dunder) Methods

Python's special methods (double underscore methods, or "dunder" methods) let your custom classes integrate with Python's built-in operations like printing, comparison, arithmetic, and iteration:

class Vector:
    """2D vector with mathematical operations."""

    def __init__(self, x, y):
        self.x = x
        self.y = y

    def __repr__(self):
        """Unambiguous representation for developers."""
        return f"Vector({self.x}, {self.y})"

    def __str__(self):
        """Human-readable string for print()."""
        return f"({self.x}, {self.y})"

    def __add__(self, other):
        """Enable: v1 + v2"""
        return Vector(self.x + other.x, self.y + other.y)

    def __mul__(self, scalar):
        """Enable: v * 3"""
        return Vector(self.x * scalar, self.y * scalar)

    def __eq__(self, other):
        """Enable: v1 == v2"""
        return self.x == other.x and self.y == other.y

    def __len__(self):
        """Enable: len(v)"""
        return 2

v1 = Vector(1, 2)
v2 = Vector(3, 4)
print(v1 + v2)      # (4, 6)
print(v1 * 3)       # (3, 6)
print(v1 == v2)     # False
print(len(v1))      # 2

Inheritance — Reusing and Extending Classes

Inheritance allows a new class (child/subclass) to inherit all the attributes and methods of an existing class (parent/superclass), and then extend or override them. This avoids code duplication and models real-world "is-a" relationships:

class Animal:
    """Base class for all animals."""

    def __init__(self, name, species):
        self.name    = name
        self.species = species
        self.energy  = 100

    def eat(self, food):
        self.energy += 20
        print(f"{self.name} eats {food}. Energy: {self.energy}")

    def describe(self):
        print(f"{self.name} is a {self.species}")

    def speak(self):
        """Override this in subclasses."""
        raise NotImplementedError("Subclass must implement speak()")


class Dog(Animal):
    """Dog inherits from Animal."""

    def __init__(self, name, breed):
        # Call the parent's __init__ using super()
        super().__init__(name, species="Canis lupus familiaris")
        self.breed = breed

    def speak(self):
        return f"{self.name} says: Woof!"

    def fetch(self, item):
        print(f"{self.name} fetches the {item}!")


class Cat(Animal):
    def __init__(self, name, indoor=True):
        super().__init__(name, species="Felis catus")
        self.indoor = indoor

    def speak(self):
        return f"{self.name} says: Meow!"


# Polymorphism — different objects respond to the same method differently
animals = [Dog("Rex", "German Shepherd"), Cat("Whiskers")]
for animal in animals:
    print(animal.speak())   # each calls its own version of speak()

dog = Dog("Rex", "German Shepherd")
dog.eat("kibble")     # inherited from Animal
dog.fetch("ball")     # Dog-specific method
dog.describe()        # inherited from Animal

Encapsulation — Protecting Data

class Temperature:
    """Demonstrates encapsulation with property decorators."""

    def __init__(self, celsius=0):
        self._celsius = celsius     # single underscore = internal use

    @property
    def celsius(self):
        """Getter — accessed like an attribute: temp.celsius"""
        return self._celsius

    @celsius.setter
    def celsius(self, value):
        """Setter with validation."""
        if value < -273.15:
            raise ValueError("Temperature below absolute zero is not possible!")
        self._celsius = value

    @property
    def fahrenheit(self):
        """Computed property — no setter needed."""
        return (self._celsius * 9/5) + 32

t = Temperature(25)
print(t.celsius)      # 25
print(t.fahrenheit)   # 77.0
t.celsius = 100       # uses setter with validation
t.celsius = -300      # raises ValueError

Class Methods and Static Methods

class Student:
    _count = 0    # class variable tracking total students

    def __init__(self, name, grade):
        self.name  = name
        self.grade = grade
        Student._count += 1

    @classmethod
    def get_count(cls):
        """Access class-level data without an instance."""
        return f"Total students: {cls._count}"

    @staticmethod
    def is_passing_grade(grade):
        """Utility function — no access to instance or class."""
        return grade >= 60

s1 = Student("Alice", 88)
s2 = Student("Bob",   72)
print(Student.get_count())           # Total students: 2
print(Student.is_passing_grade(55))  # False
← Back to Blog 📚 Browse Courses