Chapter 13 of 14

Object-Oriented Programming

Learn OOP in Python — classes, objects, inheritance, polymorphism, encapsulation, and design principles.

Meritshot48 min read
PythonOOPClassesInheritancePolymorphismEncapsulation
All Python Chapters

What is OOP?

Object-Oriented Programming (OOP) is a programming paradigm that organises code around objects — bundles of related data (attributes) and behaviour (methods). Instead of writing a long sequence of instructions, you model your program as a collection of interacting objects, each responsible for its own piece of logic.

Procedural vs OOP

Before OOP, most programs were written in a procedural style — a series of functions operating on separate data structures. Both approaches work, but they suit different situations.

# --- Procedural Approach ---

def create_student(name, age, grades):
    return {"name": name, "age": age, "grades": grades}

def calculate_average(student):
    if not student["grades"]:
        return 0
    return sum(student["grades"]) / len(student["grades"])

def is_passing(student, threshold=50):
    return calculate_average(student) >= threshold

# Usage
student1 = create_student("Priya", 22, [85, 90, 78])
student2 = create_student("Rahul", 24, [45, 38, 52])

print(calculate_average(student1))  # 84.33
print(is_passing(student2))         # False
# --- OOP Approach ---

class Student:
    def __init__(self, name, age, grades=None):
        self.name = name
        self.age = age
        self.grades = grades if grades is not None else []

    def calculate_average(self):
        if not self.grades:
            return 0
        return sum(self.grades) / len(self.grades)

    def is_passing(self, threshold=50):
        return self.calculate_average() >= threshold

# Usage
student1 = Student("Priya", 22, [85, 90, 78])
student2 = Student("Rahul", 24, [45, 38, 52])

print(student1.calculate_average())  # 84.33
print(student2.is_passing())         # False

In the procedural version, data and functions live separately — any function can modify the dictionary in unexpected ways. In the OOP version, each Student object owns its data and the methods that operate on it, keeping everything tidy and self-contained.

AspectProceduralOOP
OrganisationFunctions + separate dataObjects bundle data + behaviour
Data safetyAny function can modify any dataObjects protect their own data
ReusabilityCopy-paste functionsInherit and extend classes
ModellingAbstract sequences of stepsModels real-world entities
Best forSmall scripts, quick automationLarge applications, team projects

The Four Pillars of OOP

OOP rests on four core principles. We will explore each one in detail throughout this chapter.

  1. Encapsulation — Bundling data and methods together, and restricting direct access to some of an object's internals. Think of a TV remote: you press buttons (public interface) without knowing the circuit board inside (hidden details).

  2. Abstraction — Hiding complex implementation details and exposing only the essential features. A car's steering wheel abstracts away the rack-and-pinion mechanism underneath.

  3. Inheritance — Creating new classes based on existing ones, inheriting their attributes and methods. Just as a "Golden Retriever" inherits traits from the general category "Dog", a child class inherits from a parent class.

  4. Polymorphism — The ability of different objects to respond to the same method call in their own way. When you call .area() on a Circle and a Rectangle, each computes the area using its own formula.

Real-World Analogy

Think of a class as an architectural blueprint for a house. The blueprint specifies how many rooms there are, where the doors go, and what materials to use. An object is an actual house built from that blueprint. You can build many houses from the same blueprint — each house is a separate object with its own paint colour and furniture, but they all share the same structure.


Classes and Objects

Defining a Class

A class is defined using the class keyword. By convention, class names use PascalCase (each word capitalised, no underscores).

class Dog:
    pass  # An empty class — valid but does nothing yet

Even this minimal class can create objects:

d1 = Dog()
d2 = Dog()

print(type(d1))      # <class '__main__.Dog'>
print(d1 is d2)      # False — two distinct objects
print(isinstance(d1, Dog))  # True

The __init__ Constructor and self

Almost every class defines an __init__ method — the constructor. Python calls it automatically whenever you create a new object.

class Dog:
    def __init__(self, name, breed, age):
        self.name = name    # Assign parameter to instance attribute
        self.breed = breed
        self.age = age

buddy = Dog("Buddy", "Golden Retriever", 3)
print(buddy.name)   # Buddy
print(buddy.breed)  # Golden Retriever
print(buddy.age)    # 3

What is self?

self is a reference to the current instance — the specific object being created or acted upon. When you write self.name = name, you are saying "store the value of name as an attribute on this particular object."

Python passes self automatically — you never pass it explicitly when calling a method:

class Dog:
    def __init__(self, name):
        self.name = name

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

buddy = Dog("Buddy")

# Python translates buddy.bark() into Dog.bark(buddy) behind the scenes
print(buddy.bark())       # Buddy says Woof!
print(Dog.bark(buddy))    # Buddy says Woof!  (same thing, explicit form)

Note: self is just a convention — you could technically name it anything — but using self is a universal Python convention. Never change it.

Creating Multiple Objects

Each object is independent. Modifying one does not affect the others.

class Student:
    def __init__(self, name, grade):
        self.name = name
        self.grade = grade

    def promote(self):
        self.grade += 1
        return f"{self.name} promoted to grade {self.grade}"

s1 = Student("Priya", 10)
s2 = Student("Rahul", 10)

print(s1.promote())  # Priya promoted to grade 11
print(s2.grade)      # 10 — s2 is unaffected

Instance Attributes vs Class Attributes

Python has two types of attributes: those belonging to the class (shared by all instances) and those belonging to each instance (unique per object).

Class Attributes

Defined directly inside the class body, outside any method. They are shared across every object of that class.

class Car:
    # Class attribute — shared by all Cars
    wheels = 4
    count = 0

    def __init__(self, brand, model):
        # Instance attributes — unique to each Car
        self.brand = brand
        self.model = model
        Car.count += 1  # Update the shared counter

c1 = Car("Toyota", "Camry")
c2 = Car("Honda", "Civic")
c3 = Car("Tata", "Nexon")

print(Car.count)     # 3
print(c1.wheels)     # 4  (looked up from the class)
print(c2.wheels)     # 4
print(Car.wheels)    # 4

How Attribute Lookup Works

When you access c1.wheels, Python first checks c1's own instance dictionary. If it does not find wheels there, it looks in the class dictionary. This is why all instances appear to "share" the class attribute — they are all reading it from the same place.

print(c1.__dict__)   # {'brand': 'Toyota', 'model': 'Camry'}  — no 'wheels'
print(Car.__dict__)  # {'wheels': 4, 'count': 3, '__init__': ..., ...}

The Mutable Class Attribute Pitfall

Be careful with mutable class attributes (lists, dictionaries). All instances share the same object, so mutating it from one instance affects every other instance.

# WRONG — shared mutable list
class StudentBad:
    grades = []  # This list is shared!

    def __init__(self, name):
        self.name = name

    def add_grade(self, grade):
        self.grades.append(grade)  # Mutates the shared list

s1 = StudentBad("Priya")
s2 = StudentBad("Rahul")

s1.add_grade(95)
s2.add_grade(78)

print(s1.grades)  # [95, 78]  — s1 sees s2's grade too!
print(s2.grades)  # [95, 78]  — same list object
# CORRECT — each instance gets its own list
class StudentGood:
    def __init__(self, name):
        self.name = name
        self.grades = []  # Instance attribute — unique per object

    def add_grade(self, grade):
        self.grades.append(grade)

s1 = StudentGood("Priya")
s2 = StudentGood("Rahul")

s1.add_grade(95)
s2.add_grade(78)

print(s1.grades)  # [95]
print(s2.grades)  # [78]  — separate lists

Comparison Table

FeatureInstance AttributeClass Attribute
Defined in__init__ (via self.x = ...)Class body (outside methods)
Belongs toA single objectThe class itself (shared)
Accessed viaself.x or obj.xClassName.x or obj.x
Common useObject-specific data (name, id)Constants, counters, defaults
Mutable pitfallNo issueShared mutable objects are dangerous

Methods

Methods are functions defined inside a class. They define the behaviour of objects.

Instance Methods

The most common type. They take self as the first parameter and can read/modify instance attributes.

class Rectangle:
    def __init__(self, width, height):
        self.width = width
        self.height = height

    def area(self):
        return self.width * self.height

    def perimeter(self):
        return 2 * (self.width + self.height)

    def is_square(self):
        return self.width == self.height

    def scale(self, factor):
        self.width *= factor
        self.height *= factor

r = Rectangle(5, 3)
print(r.area())       # 15
print(r.perimeter())  # 16
print(r.is_square())  # False

r.scale(2)
print(r.area())       # 60  (10 * 6)

Method Chaining

If a method returns self, you can chain multiple method calls on a single line. This is a common pattern in builder-style APIs.

class QueryBuilder:
    def __init__(self, table):
        self.table = table
        self._columns = "*"
        self._where = ""
        self._order = ""
        self._limit = ""

    def select(self, columns):
        self._columns = columns
        return self  # Return self to enable chaining

    def where(self, condition):
        self._where = f" WHERE {condition}"
        return self

    def order_by(self, column, direction="ASC"):
        self._order = f" ORDER BY {column} {direction}"
        return self

    def limit(self, n):
        self._limit = f" LIMIT {n}"
        return self

    def build(self):
        return f"SELECT {self._columns} FROM {self.table}{self._where}{self._order}{self._limit}"

# Method chaining in action
query = (
    QueryBuilder("students")
    .select("name, grade")
    .where("grade > 80")
    .order_by("grade", "DESC")
    .limit(10)
    .build()
)
print(query)
# SELECT name, grade FROM students WHERE grade > 80 ORDER BY grade DESC LIMIT 10

self in Depth

A common source of confusion: self is not a keyword — it is simply the first parameter that receives the instance. You must include it in every instance method signature.

class Counter:
    def __init__(self):
        self.value = 0

    def increment(self):
        self.value += 1

    def decrement(self):
        self.value -= 1

    def reset(self):
        self.value = 0

    def get(self):
        return self.value

c = Counter()
c.increment()
c.increment()
c.increment()
c.decrement()
print(c.get())  # 2

Every time you call c.increment(), Python passes c as self behind the scenes.


The __init__ Method

Constructor Patterns

The __init__ method is where you set up the initial state of an object. It is called automatically after the object is created in memory.

class Product:
    def __init__(self, name, price, quantity=0):
        self.name = name
        self.price = price
        self.quantity = quantity

    def total_value(self):
        return self.price * self.quantity

# With default quantity
p1 = Product("Laptop", 75000)
print(p1.quantity)       # 0

# With explicit quantity
p2 = Product("Mouse", 500, 50)
print(p2.total_value())  # 25000

Validation in __init__

You should validate data at construction time to prevent invalid objects from existing.

class Age:
    def __init__(self, years):
        if not isinstance(years, int):
            raise TypeError("Age must be an integer")
        if years < 0 or years > 150:
            raise ValueError("Age must be between 0 and 150")
        self.years = years

# Valid
a = Age(25)
print(a.years)  # 25

# Invalid — raises ValueError
try:
    b = Age(-5)
except ValueError as e:
    print(e)  # Age must be between 0 and 150

# Invalid — raises TypeError
try:
    c = Age("twenty")
except TypeError as e:
    print(e)  # Age must be an integer

Calling Parent __init__

When a child class has its own __init__, the parent's __init__ is not called automatically. You must call it explicitly using super().

class Person:
    def __init__(self, name, age):
        self.name = name
        self.age = age

class Employee(Person):
    def __init__(self, name, age, employee_id, department):
        super().__init__(name, age)  # Call parent's __init__
        self.employee_id = employee_id
        self.department = department

e = Employee("Priya", 30, "E001", "Engineering")
print(e.name)          # Priya       (from Person.__init__)
print(e.employee_id)   # E001        (from Employee.__init__)
print(e.department)    # Engineering

If you forget super().__init__(name, age), the Employee object will not have name or age attributes, and accessing them will raise an AttributeError.


Encapsulation

Encapsulation means bundling data and methods together and controlling access to the internal state of an object. Python does not enforce strict access control like Java or C++, but it uses naming conventions to communicate intent.

Public, Protected, and Private

ConventionExampleMeaning
nameself.namePublic — accessible from anywhere
_nameself._nameProtected — "internal use; access with caution" (convention only)
__nameself.__namePrivate — name-mangled; harder to access from outside
class Employee:
    def __init__(self, name, salary, ssn):
        self.name = name          # Public
        self._salary = salary     # Protected (convention)
        self.__ssn = ssn          # Private (name-mangled)

e = Employee("Priya", 80000, "123-45-6789")

# Public — accessible
print(e.name)       # Priya

# Protected — accessible, but signals "internal"
print(e._salary)    # 80000

# Private — raises AttributeError
try:
    print(e.__ssn)
except AttributeError as err:
    print(err)  # 'Employee' object has no attribute '__ssn'

Name Mangling

When you use __name (double underscore prefix), Python internally renames it to _ClassName__name. This is called name mangling. It is not true security — it is a mechanism to avoid accidental name clashes in subclasses.

# You CAN still access it, but you shouldn't
print(e._Employee__ssn)  # 123-45-6789

# Inspect the object's dictionary to see the mangled name
print(e.__dict__)
# {'name': 'Priya', '_salary': 80000, '_Employee__ssn': '123-45-6789'}

Property Decorators — The Pythonic Way

Instead of writing explicit get_balance() and set_balance() methods (Java-style), Python uses the @property decorator to create clean attribute-like access with validation.

class BankAccount:
    def __init__(self, owner, balance=0):
        self.owner = owner
        self._balance = balance  # "Protected" backing attribute

    @property
    def balance(self):
        """Getter — called when you read account.balance"""
        return self._balance

    @balance.setter
    def balance(self, value):
        """Setter — called when you write account.balance = x"""
        if value < 0:
            raise ValueError("Balance cannot be negative")
        self._balance = value

    @balance.deleter
    def balance(self):
        """Deleter — called when you do del account.balance"""
        print("Closing account...")
        self._balance = 0

# Usage looks like normal attribute access
acc = BankAccount("Priya", 1000)
print(acc.balance)     # 1000  (calls getter)

acc.balance = 2000     # Calls setter
print(acc.balance)     # 2000

# Validation works
try:
    acc.balance = -500
except ValueError as e:
    print(e)  # Balance cannot be negative

# Deleter
del acc.balance  # Closing account...
print(acc.balance)  # 0

A More Complete Property Example

class Temperature:
    def __init__(self, celsius=0):
        self.celsius = celsius  # This calls the setter!

    @property
    def celsius(self):
        return self._celsius

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

    @property
    def fahrenheit(self):
        return (self._celsius * 9 / 5) + 32

    @fahrenheit.setter
    def fahrenheit(self, value):
        self.celsius = (value - 32) * 5 / 9  # Delegates to celsius setter

    @property
    def kelvin(self):
        return self._celsius + 273.15

    @kelvin.setter
    def kelvin(self, value):
        self.celsius = value - 273.15

temp = Temperature(100)
print(f"{temp.celsius}°C = {temp.fahrenheit}°F = {temp.kelvin}K")
# 100°C = 212.0°F = 373.15K

temp.fahrenheit = 72
print(f"{temp.celsius:.2f}°C")  # 22.22°C

temp.kelvin = 0
print(f"{temp.celsius}°C")  # -273.15°C

Inheritance

Inheritance lets you create a child class (subclass) that inherits attributes and methods from a parent class (superclass). This promotes code reuse and models "is-a" relationships.

Single Inheritance

class Animal:
    def __init__(self, name, species):
        self.name = name
        self.species = species

    def speak(self):
        return f"{self.name} makes a sound"

    def info(self):
        return f"{self.name} is a {self.species}"

class Dog(Animal):
    def __init__(self, name, breed):
        super().__init__(name, "Dog")  # Call parent constructor
        self.breed = breed

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

    def fetch(self):  # New method, only in Dog
        return f"{self.name} fetches the ball!"

class Cat(Animal):
    def __init__(self, name, color):
        super().__init__(name, "Cat")
        self.color = color

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

# Usage
dog = Dog("Buddy", "Golden Retriever")
cat = Cat("Whiskers", "orange")

print(dog.speak())   # Buddy says Woof!
print(dog.info())    # Buddy is a Dog (inherited from Animal)
print(dog.fetch())   # Buddy fetches the ball!
print(cat.speak())   # Whiskers says Meow!

super() Explained

super() returns a proxy object that delegates method calls to the parent class. It is most commonly used in __init__, but it works with any method.

class Shape:
    def __init__(self, color="black"):
        self.color = color

    def describe(self):
        return f"A {self.color} shape"

class Circle(Shape):
    def __init__(self, radius, color="black"):
        super().__init__(color)  # Set color via parent
        self.radius = radius

    def describe(self):
        # Extend (not just replace) the parent's method
        base = super().describe()
        return f"{base} — circle with radius {self.radius}"

c = Circle(5, "red")
print(c.describe())  # A red shape — circle with radius 5

Method Overriding

When a child class defines a method with the same name as the parent, it overrides the parent's version. The child's version is called instead.

class Vehicle:
    def fuel_type(self):
        return "Petrol"

class ElectricCar(Vehicle):
    def fuel_type(self):  # Overrides Vehicle.fuel_type
        return "Electric"

class HybridCar(Vehicle):
    def fuel_type(self):
        return "Petrol + Electric"

v = Vehicle()
e = ElectricCar()
h = HybridCar()

print(v.fuel_type())  # Petrol
print(e.fuel_type())  # Electric
print(h.fuel_type())  # Petrol + Electric

isinstance() and issubclass()

These built-in functions check object types and class relationships.

class Animal:
    pass

class Dog(Animal):
    pass

class Cat(Animal):
    pass

d = Dog()

# isinstance checks if an object is an instance of a class (or its parents)
print(isinstance(d, Dog))     # True
print(isinstance(d, Animal))  # True  — Dog IS an Animal
print(isinstance(d, Cat))     # False

# issubclass checks class relationships
print(issubclass(Dog, Animal))  # True
print(issubclass(Dog, Cat))     # False
print(issubclass(Animal, object))  # True — everything inherits from object

What Gets Inherited

A child class inherits all public and protected attributes and methods. Private (name-mangled) attributes are technically inherited but are harder to access due to mangling.

class Parent:
    class_var = "I am shared"

    def __init__(self):
        self.public = "Accessible"
        self._protected = "Use carefully"
        self.__private = "Mangled"

    def public_method(self):
        return "Parent public method"

    def _protected_method(self):
        return "Parent protected method"

    def __private_method(self):
        return "Parent private method"

class Child(Parent):
    pass

c = Child()
print(c.public)              # Accessible
print(c._protected)          # Use carefully
print(c.public_method())     # Parent public method
print(c._protected_method()) # Parent protected method
print(c.class_var)           # I am shared

# Private — name-mangled under PARENT's name
print(c._Parent__private)          # Mangled
print(c._Parent__private_method()) # Parent private method

Method Resolution Order (MRO)

When Python looks up a method, it searches in a specific order: the class itself first, then its parents, left to right. You can inspect this order using ClassName.__mro__ or ClassName.mro().

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

# See the full resolution order
print(D.__mro__)
# (<class 'D'>, <class 'B'>, <class 'C'>, <class 'A'>, <class 'object'>)

Python searches D -> B -> C -> A -> object. Since B has greet(), that is the one used.


Multiple Inheritance

Python supports inheriting from more than one parent class.

Basic Syntax

class Flyable:
    def fly(self):
        return f"{self.name} is flying"

class Swimmable:
    def swim(self):
        return f"{self.name} is swimming"

class Duck(Flyable, Swimmable):
    def __init__(self, name):
        self.name = name

    def quack(self):
        return f"{self.name} says Quack!"

d = Duck("Donald")
print(d.fly())    # Donald is flying
print(d.swim())   # Donald is swimming
print(d.quack())  # Donald says Quack!

The Diamond Problem and C3 Linearization

When two parent classes share a common grandparent, which version of a method should be called? This is the diamond problem. Python solves it with C3 linearization — a deterministic algorithm that computes the MRO.

class A:
    def who_am_i(self):
        return "A"

class B(A):
    def who_am_i(self):
        return "B"

class C(A):
    def who_am_i(self):
        return "C"

class D(B, C):  # Diamond: D -> B -> C -> A
    pass

d = D()
print(d.who_am_i())  # B  (B comes before C in the MRO)
print(D.__mro__)
# (<class 'D'>, <class 'B'>, <class 'C'>, <class 'A'>, <class 'object'>)

super() in Multiple Inheritance

With multiple inheritance, super() follows the MRO — it does not always call the immediate parent. This enables cooperative multiple inheritance.

class A:
    def __init__(self):
        print("A.__init__")
        super().__init__()

class B(A):
    def __init__(self):
        print("B.__init__")
        super().__init__()

class C(A):
    def __init__(self):
        print("C.__init__")
        super().__init__()

class D(B, C):
    def __init__(self):
        print("D.__init__")
        super().__init__()

d = D()
# Output (follows MRO: D -> B -> C -> A):
# D.__init__
# B.__init__
# C.__init__
# A.__init__

Notice that super() in B.__init__ calls C.__init__ (not A.__init__), because C is next in the MRO of D.

Mixins

A mixin is a class that provides specific functionality meant to be combined with other classes. Mixins are not meant to stand alone.

class JsonMixin:
    """Mixin that adds JSON serialisation to any class with a __dict__."""
    def to_json(self):
        import json
        return json.dumps(self.__dict__, indent=2, default=str)

class LogMixin:
    """Mixin that adds logging capability."""
    def log(self, message):
        print(f"[{self.__class__.__name__}] {message}")

class User(JsonMixin, LogMixin):
    def __init__(self, name, email):
        self.name = name
        self.email = email

class Product(JsonMixin, LogMixin):
    def __init__(self, title, price):
        self.title = title
        self.price = price

# User gets both mixins
u = User("Priya", "priya@example.com")
print(u.to_json())
# {
#   "name": "Priya",
#   "email": "priya@example.com"
# }
u.log("Profile updated")  # [User] Profile updated

# Product also gets both mixins
p = Product("Laptop", 75000)
print(p.to_json())
# {
#   "title": "Laptop",
#   "price": 75000
# }
p.log("Price changed")  # [Product] Price changed

Polymorphism

Polymorphism means "many forms" — the ability of different objects to respond to the same method call in their own way.

Method Overriding as Polymorphism

class Shape:
    def area(self):
        return 0

    def describe(self):
        return f"Shape with area {self.area():.2f}"

class Rectangle(Shape):
    def __init__(self, width, height):
        self.width = width
        self.height = height

    def area(self):
        return self.width * self.height

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

    def area(self):
        import math
        return math.pi * self.radius ** 2

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

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

# Polymorphism in action — same method name, different behaviour
shapes = [Rectangle(5, 3), Circle(4), Triangle(6, 8)]

for shape in shapes:
    print(shape.describe())

# Output:
# Shape with area 15.00
# Shape with area 50.27
# Shape with area 24.00

Duck Typing

Python follows the principle: "If it walks like a duck and quacks like a duck, it is a duck." You do not need to inherit from a common class — if an object has the right method, it works.

class Duck:
    def sound(self):
        return "Quack!"

class Person:
    def sound(self):
        return "I'm quacking like a duck!"

class Radio:
    def sound(self):
        return "♪ Music playing ♪"

# No shared parent class, no interface — just the same method name
def make_sound(thing):
    print(thing.sound())

make_sound(Duck())    # Quack!
make_sound(Person())  # I'm quacking like a duck!
make_sound(Radio())   # ♪ Music playing ♪

Duck typing is fundamental to Python. Built-in functions like len(), iter(), and str() all rely on duck typing — they call the corresponding dunder method on whatever object you pass.

# len() works on anything that has __len__
class Playlist:
    def __init__(self, songs):
        self.songs = songs

    def __len__(self):
        return len(self.songs)

p = Playlist(["Song A", "Song B", "Song C"])
print(len(p))  # 3

Operator Overloading Basics

Python lets you define how operators (+, -, ==, <, etc.) work on your custom objects by implementing dunder methods.

class Money:
    def __init__(self, amount, currency="INR"):
        self.amount = amount
        self.currency = currency

    def __add__(self, other):
        if self.currency != other.currency:
            raise ValueError("Cannot add different currencies")
        return Money(self.amount + other.amount, self.currency)

    def __sub__(self, other):
        if self.currency != other.currency:
            raise ValueError("Cannot subtract different currencies")
        return Money(self.amount - other.amount, self.currency)

    def __eq__(self, other):
        return self.amount == other.amount and self.currency == other.currency

    def __lt__(self, other):
        if self.currency != other.currency:
            raise ValueError("Cannot compare different currencies")
        return self.amount < other.amount

    def __str__(self):
        return f"{self.currency} {self.amount:,.2f}"

a = Money(1000)
b = Money(500)

print(a + b)    # INR 1,500.00
print(a - b)    # INR 500.00
print(a == b)   # False
print(b < a)    # True

Polymorphism with Functions

You can write functions that work with any object implementing the expected interface.

class CSVExporter:
    def export(self, data):
        lines = [",".join(data[0].keys())]
        for row in data:
            lines.append(",".join(str(v) for v in row.values()))
        return "\n".join(lines)

class JSONExporter:
    def export(self, data):
        import json
        return json.dumps(data, indent=2)

class MarkdownExporter:
    def export(self, data):
        if not data:
            return ""
        headers = list(data[0].keys())
        lines = ["| " + " | ".join(headers) + " |"]
        lines.append("| " + " | ".join("---" for _ in headers) + " |")
        for row in data:
            lines.append("| " + " | ".join(str(v) for v in row.values()) + " |")
        return "\n".join(lines)

# Polymorphic function — works with any exporter
def generate_report(exporter, data):
    print(exporter.export(data))

data = [
    {"name": "Priya", "score": 95},
    {"name": "Rahul", "score": 82},
]

generate_report(CSVExporter(), data)
generate_report(JSONExporter(), data)
generate_report(MarkdownExporter(), data)

Abstraction

Abstraction means hiding complex details and exposing only the essential interface. In Python, you implement this with Abstract Base Classes (ABCs).

Abstract Base Classes

The abc module lets you define classes that cannot be instantiated directly and that force subclasses to implement certain methods.

from abc import ABC, abstractmethod

class Shape(ABC):
    @abstractmethod
    def area(self):
        """Subclasses MUST implement this method."""
        pass

    @abstractmethod
    def perimeter(self):
        """Subclasses MUST implement this method."""
        pass

    def describe(self):
        """Concrete method — inherited as-is."""
        return f"{self.__class__.__name__}: area={self.area():.2f}, perimeter={self.perimeter():.2f}"

# Cannot instantiate an abstract class
try:
    s = Shape()
except TypeError as e:
    print(e)
    # Can't instantiate abstract class Shape with abstract methods area, perimeter

Implementing the Abstract Class

import math

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

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

    def perimeter(self):
        return 2 * math.pi * self.radius

class Rectangle(Shape):
    def __init__(self, width, height):
        self.width = width
        self.height = height

    def area(self):
        return self.width * self.height

    def perimeter(self):
        return 2 * (self.width + self.height)

class Triangle(Shape):
    def __init__(self, a, b, c):
        self.a = a
        self.b = b
        self.c = c

    def area(self):
        # Heron's formula
        s = (self.a + self.b + self.c) / 2
        return math.sqrt(s * (s - self.a) * (s - self.b) * (s - self.c))

    def perimeter(self):
        return self.a + self.b + self.c

# If you forget to implement an abstract method, Python raises TypeError
class BadShape(Shape):
    def area(self):
        return 0
    # Missing perimeter()!

try:
    bs = BadShape()
except TypeError as e:
    print(e)
    # Can't instantiate abstract class BadShape with abstract method perimeter

Why Use ABCs?

  • Enforce a contract: Guarantee that every subclass implements required methods.
  • Clear interfaces: Document what any "Shape" must be able to do.
  • Catch errors early: Errors at instantiation time, not when you call the missing method later.
# Using polymorphism with ABCs
shapes = [Circle(5), Rectangle(4, 6), Triangle(3, 4, 5)]

for shape in shapes:
    print(shape.describe())

# Output:
# Circle: area=78.54, perimeter=31.42
# Rectangle: area=24.00, perimeter=20.00
# Triangle: area=6.00, perimeter=12.00

Magic/Dunder Methods

Dunder (double underscore) methods let you define how your objects interact with Python's built-in functions and operators. They are the key to making your classes feel native and Pythonic.

__str__ and __repr__

  • __str__ — User-friendly string (for print() and str())
  • __repr__ — Developer-friendly string (for debugging, repr(), interactive console)
class Point:
    def __init__(self, x, y):
        self.x = x
        self.y = y

    def __str__(self):
        return f"({self.x}, {self.y})"

    def __repr__(self):
        return f"Point(x={self.x}, y={self.y})"

p = Point(3, 7)
print(p)        # (3, 7)        — calls __str__
print(repr(p))  # Point(x=3, y=7) — calls __repr__
print([p])      # [Point(x=3, y=7)] — lists use __repr__ for elements

Rule of thumb: __repr__ should ideally return a string that could recreate the object. __str__ should return something a user would want to read. If you only implement one, implement __repr__ — Python falls back to it when __str__ is missing.

__len__, __getitem__, __setitem__, __contains__

These let your object behave like a collection.

class Deck:
    """A deck of playing cards."""
    SUITS = ["Hearts", "Diamonds", "Clubs", "Spades"]
    RANKS = ["2", "3", "4", "5", "6", "7", "8", "9", "10", "J", "Q", "K", "A"]

    def __init__(self):
        self.cards = [
            f"{rank} of {suit}"
            for suit in self.SUITS
            for rank in self.RANKS
        ]

    def __len__(self):
        return len(self.cards)

    def __getitem__(self, index):
        return self.cards[index]

    def __setitem__(self, index, value):
        self.cards[index] = value

    def __contains__(self, card):
        return card in self.cards

    def __repr__(self):
        return f"Deck({len(self)} cards)"

deck = Deck()
print(len(deck))                # 52
print(deck[0])                  # 2 of Hearts
print(deck[-1])                 # A of Spades
print("A of Spades" in deck)    # True

# Slicing works too (because __getitem__ handles it)
print(deck[:3])  # ['2 of Hearts', '3 of Hearts', '4 of Hearts']

Comparison Methods: __eq__, __lt__, and Others

class Student:
    def __init__(self, name, gpa):
        self.name = name
        self.gpa = gpa

    def __eq__(self, other):
        if not isinstance(other, Student):
            return NotImplemented
        return self.gpa == other.gpa

    def __lt__(self, other):
        if not isinstance(other, Student):
            return NotImplemented
        return self.gpa < other.gpa

    def __le__(self, other):
        return self == other or self < other

    def __gt__(self, other):
        if not isinstance(other, Student):
            return NotImplemented
        return self.gpa > other.gpa

    def __ge__(self, other):
        return self == other or self > other

    def __repr__(self):
        return f"Student('{self.name}', gpa={self.gpa})"

students = [
    Student("Priya", 3.9),
    Student("Rahul", 3.5),
    Student("Anita", 3.9),
    Student("Vijay", 3.7),
]

# Sorting uses __lt__
students.sort()
print(students)
# [Student('Rahul', gpa=3.5), Student('Vijay', gpa=3.7), Student('Priya', gpa=3.9), Student('Anita', gpa=3.9)]

print(students[0] < students[-1])  # True
print(students[2] == students[3])  # True (same GPA)

Tip: You can implement just __eq__ and __lt__, then use @functools.total_ordering to auto-generate the rest.

Arithmetic Operators: __add__, __sub__, __mul__

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

    def __add__(self, other):
        return Vector(self.x + other.x, self.y + other.y)

    def __sub__(self, other):
        return Vector(self.x - other.x, self.y - other.y)

    def __mul__(self, scalar):
        if isinstance(scalar, (int, float)):
            return Vector(self.x * scalar, self.y * scalar)
        return NotImplemented

    def __rmul__(self, scalar):
        return self.__mul__(scalar)  # Support 3 * vector

    def __abs__(self):
        return (self.x ** 2 + self.y ** 2) ** 0.5

    def __eq__(self, other):
        return self.x == other.x and self.y == other.y

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

v1 = Vector(2, 3)
v2 = Vector(4, 1)

print(v1 + v2)      # Vector(6, 4)
print(v1 - v2)      # Vector(-2, 2)
print(v1 * 3)       # Vector(6, 9)
print(3 * v1)       # Vector(6, 9)  — uses __rmul__
print(abs(v1))       # 3.605551275...

__iter__ and __next__ — Making Iterable Objects

class Countdown:
    def __init__(self, start):
        self.start = start

    def __iter__(self):
        self.current = self.start
        return self

    def __next__(self):
        if self.current < 0:
            raise StopIteration
        value = self.current
        self.current -= 1
        return value

# Use in a for loop
for num in Countdown(5):
    print(num, end=" ")
# Output: 5 4 3 2 1 0

# Or convert to list
print(list(Countdown(3)))  # [3, 2, 1, 0]

__call__ — Making Objects Callable

class Multiplier:
    def __init__(self, factor):
        self.factor = factor

    def __call__(self, value):
        return value * self.factor

double = Multiplier(2)
triple = Multiplier(3)

print(double(10))  # 20
print(triple(10))  # 30

# Callable check
print(callable(double))  # True

__enter__ and __exit__ — Context Managers

These dunder methods let you use your class with the with statement.

class FileManager:
    def __init__(self, filename, mode="r"):
        self.filename = filename
        self.mode = mode
        self.file = None

    def __enter__(self):
        print(f"Opening {self.filename}")
        self.file = open(self.filename, self.mode)
        return self.file

    def __exit__(self, exc_type, exc_val, exc_tb):
        print(f"Closing {self.filename}")
        if self.file:
            self.file.close()
        return False  # Do not suppress exceptions

# Usage
# with FileManager("test.txt", "w") as f:
#     f.write("Hello, World!")
# Output:
# Opening test.txt
# Closing test.txt

A practical example — timing a block of code:

import time

class Timer:
    def __enter__(self):
        self.start = time.time()
        return self

    def __exit__(self, *args):
        self.elapsed = time.time() - self.start
        print(f"Elapsed: {self.elapsed:.4f} seconds")

with Timer():
    total = sum(range(1_000_000))
# Elapsed: 0.0312 seconds  (varies)

Table of Common Dunder Methods

MethodTriggered ByPurpose
__init__(self, ...)MyClass()Initialise a new object
__str__(self)str(obj), print(obj)User-friendly string
__repr__(self)repr(obj)Developer-friendly string
__len__(self)len(obj)Return length
__getitem__(self, key)obj[key]Index/key access
__setitem__(self, key, val)obj[key] = valIndex/key assignment
__delitem__(self, key)del obj[key]Delete item
__contains__(self, item)item in objMembership test
__iter__(self)for x in objReturn iterator
__next__(self)next(obj)Next item in iteration
__call__(self, ...)obj()Make object callable
__eq__(self, other)obj == otherEquality
__lt__(self, other)obj < otherLess than
__le__(self, other)obj <= otherLess than or equal
__gt__(self, other)obj > otherGreater than
__ge__(self, other)obj >= otherGreater than or equal
__ne__(self, other)obj != otherNot equal
__add__(self, other)obj + otherAddition
__sub__(self, other)obj - otherSubtraction
__mul__(self, other)obj * otherMultiplication
__truediv__(self, other)obj / otherDivision
__floordiv__(self, other)obj // otherFloor division
__mod__(self, other)obj % otherModulo
__pow__(self, other)obj ** otherPower
__abs__(self)abs(obj)Absolute value
__bool__(self)bool(obj), if obj:Truthiness
__hash__(self)hash(obj)Hash (for dicts/sets)
__enter__/__exit__with obj:Context manager

Class Methods and Static Methods

Python has three types of methods: instance methods, class methods, and static methods.

Instance Methods (Recap)

Take self as the first parameter. They can access and modify both instance and class state.

class Dog:
    species = "Canis familiaris"

    def __init__(self, name, age):
        self.name = name
        self.age = age

    def description(self):  # Instance method
        return f"{self.name} is {self.age} years old"

@classmethod — Works with the Class

Takes cls (the class itself) as the first parameter. Cannot access instance attributes, but can access/modify class attributes. Commonly used as factory methods — alternative constructors.

class Employee:
    raise_rate = 1.05
    employee_count = 0

    def __init__(self, name, salary):
        self.name = name
        self.salary = salary
        Employee.employee_count += 1

    @classmethod
    def set_raise_rate(cls, rate):
        """Modify a class attribute."""
        cls.raise_rate = rate

    @classmethod
    def from_string(cls, emp_string):
        """Factory method — create Employee from a dash-separated string."""
        name, salary = emp_string.split("-")
        return cls(name, int(salary))

    @classmethod
    def get_count(cls):
        return cls.employee_count

# Using the factory method
e1 = Employee("Priya", 80000)
e2 = Employee.from_string("Rahul-75000")  # Alternative constructor

print(e1.name)    # Priya
print(e2.name)    # Rahul
print(e2.salary)  # 75000

Employee.set_raise_rate(1.10)
print(Employee.raise_rate)  # 1.1

print(Employee.get_count())  # 2

@staticmethod — Utility Function

Takes neither self nor cls. It is just a regular function that lives inside the class for organisational purposes. It cannot access or modify class or instance state.

class MathUtils:
    @staticmethod
    def is_even(n):
        return n % 2 == 0

    @staticmethod
    def factorial(n):
        if n < 0:
            raise ValueError("Factorial undefined for negative numbers")
        result = 1
        for i in range(2, n + 1):
            result *= i
        return result

    @staticmethod
    def fibonacci(n):
        a, b = 0, 1
        sequence = []
        for _ in range(n):
            sequence.append(a)
            a, b = b, a + b
        return sequence

print(MathUtils.is_even(4))      # True
print(MathUtils.factorial(5))    # 120
print(MathUtils.fibonacci(8))   # [0, 1, 1, 2, 3, 5, 8, 13]

Comparison Table

FeatureInstance MethodClass MethodStatic Method
First parameterself (instance)cls (class)None
DecoratorNone@classmethod@staticmethod
Access instance attrsYesNoNo
Access class attrsYes (via self.__class__ or class name)Yes (via cls)No
Modify instance stateYesNoNo
Modify class stateYesYesNo
Common useCore behaviourFactory methods, class-level operationsUtility/helper functions
Call syntaxobj.method()Class.method() or obj.method()Class.method() or obj.method()

Factory Method Pattern

A practical use of @classmethod as a factory:

class Date:
    def __init__(self, year, month, day):
        self.year = year
        self.month = month
        self.day = day

    @classmethod
    def from_string(cls, date_string):
        """Create a Date from 'YYYY-MM-DD' format."""
        year, month, day = map(int, date_string.split("-"))
        return cls(year, month, day)

    @classmethod
    def today(cls):
        """Create a Date for today."""
        import datetime
        t = datetime.date.today()
        return cls(t.year, t.month, t.day)

    def __str__(self):
        return f"{self.year:04d}-{self.month:02d}-{self.day:02d}"

# Three ways to create a Date
d1 = Date(2026, 3, 15)
d2 = Date.from_string("2026-03-15")
d3 = Date.today()

print(d1)  # 2026-03-15
print(d2)  # 2026-03-15
print(d3)  # (today's date)

Composition vs Inheritance

Two fundamental ways to build relationships between classes: Inheritance ("is-a") and Composition ("has-a").

Inheritance — "Is-a" Relationship

Use when the child truly is a specialised type of the parent.

class Vehicle:
    def __init__(self, brand, model, year):
        self.brand = brand
        self.model = model
        self.year = year

    def start(self):
        return f"{self.brand} {self.model} is starting"

class Car(Vehicle):  # A Car IS a Vehicle
    def __init__(self, brand, model, year, num_doors=4):
        super().__init__(brand, model, year)
        self.num_doors = num_doors

class Motorcycle(Vehicle):  # A Motorcycle IS a Vehicle
    def __init__(self, brand, model, year, engine_cc):
        super().__init__(brand, model, year)
        self.engine_cc = engine_cc

Composition — "Has-a" Relationship

Use when a class contains or uses another class as a component.

class Engine:
    def __init__(self, horsepower, fuel_type):
        self.horsepower = horsepower
        self.fuel_type = fuel_type
        self.running = False

    def start(self):
        self.running = True
        return f"Engine ({self.horsepower}HP, {self.fuel_type}) started"

    def stop(self):
        self.running = False
        return "Engine stopped"

class Transmission:
    def __init__(self, type_name, gears):
        self.type_name = type_name
        self.gears = gears
        self.current_gear = 0

    def shift_up(self):
        if self.current_gear < self.gears:
            self.current_gear += 1
        return f"Gear: {self.current_gear}"

    def shift_down(self):
        if self.current_gear > 0:
            self.current_gear -= 1
        return f"Gear: {self.current_gear}"

class GPS:
    def __init__(self):
        self.destination = None

    def set_destination(self, dest):
        self.destination = dest
        return f"Navigating to {dest}"

class Car:
    """A Car HAS an Engine, HAS a Transmission, HAS a GPS."""

    def __init__(self, brand, model, engine, transmission):
        self.brand = brand
        self.model = model
        self.engine = engine           # Composition
        self.transmission = transmission  # Composition
        self.gps = GPS()               # Composition

    def start(self):
        return f"{self.brand} {self.model}: {self.engine.start()}"

    def drive_to(self, destination):
        if not self.engine.running:
            self.start()
        self.transmission.shift_up()
        return self.gps.set_destination(destination)

# Build a car from components
engine = Engine(200, "Petrol")
trans = Transmission("Automatic", 6)
car = Car("Toyota", "Camry", engine, trans)

print(car.start())              # Toyota Camry: Engine (200HP, Petrol) started
print(car.drive_to("Mumbai"))   # Navigating to Mumbai
print(car.transmission.shift_up())  # Gear: 2

When to Prefer Composition Over Inheritance

Use Inheritance WhenUse Composition When
There is a clear "is-a" relationshipThere is a "has-a" relationship
The child is a specialised version of the parentThe object is assembled from parts
You want to reuse the parent's interfaceYou want flexibility to swap components
The hierarchy is shallow (2-3 levels max)The relationship may change at runtime

General guideline: Favour composition over inheritance. Deep inheritance hierarchies become rigid and hard to change. Composition gives you more flexibility.

# BAD: Over-using inheritance
class Animal:
    pass
class FlyingAnimal(Animal):
    def fly(self): ...
class SwimmingAnimal(Animal):
    def swim(self): ...
# What about a duck that flies AND swims? Multiple inheritance gets messy.

# GOOD: Composition with behaviours
class FlyBehaviour:
    def fly(self):
        return "Flying!"

class SwimBehaviour:
    def swim(self):
        return "Swimming!"

class Duck:
    def __init__(self):
        self.fly_behaviour = FlyBehaviour()
        self.swim_behaviour = SwimBehaviour()

    def fly(self):
        return self.fly_behaviour.fly()

    def swim(self):
        return self.swim_behaviour.swim()

d = Duck()
print(d.fly())   # Flying!
print(d.swim())  # Swimming!

Dataclasses

The dataclasses module (Python 3.7+) reduces boilerplate for classes that are primarily containers of data.

Basic Usage

from dataclasses import dataclass

@dataclass
class Point:
    x: float
    y: float

# Auto-generated: __init__, __repr__, __eq__
p1 = Point(3, 4)
p2 = Point(3, 4)
p3 = Point(1, 2)

print(p1)          # Point(x=3, y=4)  — auto __repr__
print(p1 == p2)    # True             — auto __eq__
print(p1 == p3)    # False

Compare this with the manual equivalent:

# Without dataclass — much more code for the same result
class PointManual:
    def __init__(self, x, y):
        self.x = x
        self.y = y

    def __repr__(self):
        return f"PointManual(x={self.x}, y={self.y})"

    def __eq__(self, other):
        if not isinstance(other, PointManual):
            return NotImplemented
        return self.x == other.x and self.y == other.y

Default Values

from dataclasses import dataclass

@dataclass
class Product:
    name: str
    price: float
    quantity: int = 0        # Default value
    category: str = "General"

p = Product("Laptop", 75000)
print(p)  # Product(name='Laptop', price=75000, quantity=0, category='General')

p2 = Product("Mouse", 500, 100, "Accessories")
print(p2)  # Product(name='Mouse', price=500, quantity=100, category='Accessories')

The field() Function

For more control over individual fields — especially for mutable defaults.

from dataclasses import dataclass, field

@dataclass
class Student:
    name: str
    age: int
    grades: list = field(default_factory=list)  # Safe mutable default
    student_id: str = field(default="", repr=False)  # Hidden from repr

s = Student("Priya", 22)
s.grades.append(95)
s.grades.append(88)

print(s)  # Student(name='Priya', age=22, grades=[95, 88])
# student_id not shown because repr=False

Important: Never use a mutable default directly (e.g., grades: list = []). Use field(default_factory=list) instead. Dataclasses enforce this — using a mutable default raises a ValueError.

Frozen Dataclasses (Immutable)

from dataclasses import dataclass

@dataclass(frozen=True)
class Coordinate:
    latitude: float
    longitude: float

c = Coordinate(28.6139, 77.2090)
print(c)  # Coordinate(latitude=28.6139, longitude=77.209)

# Cannot modify — raises FrozenInstanceError
try:
    c.latitude = 0
except Exception as e:
    print(type(e).__name__)  # FrozenInstanceError

# Frozen dataclasses are hashable — can be used in sets and as dict keys
locations = {c: "New Delhi"}
print(locations[c])  # New Delhi

Ordered Dataclasses

from dataclasses import dataclass

@dataclass(order=True)
class Student:
    gpa: float
    name: str = field(compare=False)  # Exclude from comparison

students = [
    Student(3.5, "Rahul"),
    Student(3.9, "Priya"),
    Student(3.7, "Vijay"),
]

students.sort()
for s in students:
    print(s)

# Output (sorted by gpa):
# Student(gpa=3.5, name='Rahul')
# Student(gpa=3.7, name='Vijay')
# Student(gpa=3.9, name='Priya')

Post-Init Processing

from dataclasses import dataclass, field

@dataclass
class Rectangle:
    width: float
    height: float
    area: float = field(init=False)  # Excluded from __init__

    def __post_init__(self):
        """Called automatically after __init__."""
        if self.width <= 0 or self.height <= 0:
            raise ValueError("Dimensions must be positive")
        self.area = self.width * self.height

r = Rectangle(5, 3)
print(r)  # Rectangle(width=5, height=3, area=15)

try:
    bad = Rectangle(-1, 3)
except ValueError as e:
    print(e)  # Dimensions must be positive

Practical Examples

Bank Account System

from abc import ABC, abstractmethod

class BankAccount(ABC):
    """Abstract base class for bank accounts."""
    _next_id = 1000

    def __init__(self, owner, balance=0):
        BankAccount._next_id += 1
        self.account_id = BankAccount._next_id
        self.owner = owner
        self._balance = balance
        self._transactions = []

    @property
    def balance(self):
        return self._balance

    def deposit(self, amount):
        if amount <= 0:
            raise ValueError("Deposit amount must be positive")
        self._balance += amount
        self._transactions.append(f"+{amount}")
        return self

    def withdraw(self, amount):
        if amount <= 0:
            raise ValueError("Withdrawal amount must be positive")
        if amount > self._balance:
            raise ValueError("Insufficient funds")
        self._balance -= amount
        self._transactions.append(f"-{amount}")
        return self

    @abstractmethod
    def account_type(self):
        pass

    def statement(self):
        print(f"\n--- {self.account_type()} ---")
        print(f"Account: {self.account_id} | Owner: {self.owner}")
        print(f"Transactions: {', '.join(self._transactions) if self._transactions else 'None'}")
        print(f"Balance: Rs.{self._balance:,.2f}")

    def __str__(self):
        return f"{self.account_type()}({self.owner}, Rs.{self._balance:,.2f})"


class SavingsAccount(BankAccount):
    def __init__(self, owner, balance=0, interest_rate=0.04):
        super().__init__(owner, balance)
        self.interest_rate = interest_rate

    def account_type(self):
        return "Savings Account"

    def apply_interest(self):
        interest = self._balance * self.interest_rate
        self.deposit(interest)
        return interest


class CheckingAccount(BankAccount):
    def __init__(self, owner, balance=0, overdraft_limit=5000):
        super().__init__(owner, balance)
        self.overdraft_limit = overdraft_limit

    def account_type(self):
        return "Checking Account"

    def withdraw(self, amount):
        """Override to allow overdraft."""
        if amount <= 0:
            raise ValueError("Withdrawal amount must be positive")
        if amount > self._balance + self.overdraft_limit:
            raise ValueError(f"Exceeds overdraft limit of Rs.{self.overdraft_limit}")
        self._balance -= amount
        self._transactions.append(f"-{amount}")
        return self


# Usage
savings = SavingsAccount("Priya", 50000, interest_rate=0.06)
savings.deposit(10000).deposit(5000)

interest = savings.apply_interest()
print(f"Interest earned: Rs.{interest:,.2f}")  # Interest earned: Rs.3,900.00
savings.statement()

checking = CheckingAccount("Rahul", 10000, overdraft_limit=5000)
checking.withdraw(12000)  # Allowed — within overdraft limit
checking.statement()

print(f"\n{savings}")   # Savings Account(Priya, Rs.68,900.00)
print(checking)          # Checking Account(Rahul, Rs.-2,000.00)

Library Management System

from dataclasses import dataclass, field
from datetime import date, timedelta

@dataclass
class Book:
    title: str
    author: str
    isbn: str
    copies: int = 1
    available: int = field(init=False)

    def __post_init__(self):
        self.available = self.copies

    def checkout(self):
        if self.available <= 0:
            raise ValueError(f"'{self.title}' is not available")
        self.available -= 1

    def return_book(self):
        if self.available >= self.copies:
            raise ValueError(f"All copies of '{self.title}' already returned")
        self.available += 1

    def __str__(self):
        return f"'{self.title}' by {self.author} ({self.available}/{self.copies} available)"


class Member:
    MAX_BOOKS = 3

    def __init__(self, name, member_id):
        self.name = name
        self.member_id = member_id
        self.borrowed = []  # List of (book, due_date) tuples

    def borrow(self, book, loan_days=14):
        if len(self.borrowed) >= self.MAX_BOOKS:
            raise ValueError(f"{self.name} has reached the borrowing limit ({self.MAX_BOOKS})")
        book.checkout()
        due = date.today() + timedelta(days=loan_days)
        self.borrowed.append((book, due))
        return f"{self.name} borrowed '{book.title}' — due {due}"

    def return_book(self, book):
        for i, (b, due) in enumerate(self.borrowed):
            if b.isbn == book.isbn:
                b.return_book()
                self.borrowed.pop(i)
                overdue = (date.today() - due).days
                if overdue > 0:
                    return f"Returned '{book.title}' — {overdue} days overdue!"
                return f"Returned '{book.title}' on time"
        raise ValueError(f"{self.name} does not have '{book.title}'")

    def __str__(self):
        books = ", ".join(b.title for b, _ in self.borrowed) if self.borrowed else "None"
        return f"Member({self.name}, borrowed: [{books}])"


class Library:
    def __init__(self, name):
        self.name = name
        self.catalog = {}  # isbn -> Book
        self.members = {}  # member_id -> Member

    def add_book(self, book):
        if book.isbn in self.catalog:
            self.catalog[book.isbn].copies += book.copies
            self.catalog[book.isbn].available += book.copies
        else:
            self.catalog[book.isbn] = book
        return f"Added '{book.title}' to {self.name}"

    def register_member(self, member):
        self.members[member.member_id] = member
        return f"Registered {member.name}"

    def search(self, query):
        """Search books by title or author (case-insensitive)."""
        query = query.lower()
        results = [
            book for book in self.catalog.values()
            if query in book.title.lower() or query in book.author.lower()
        ]
        return results

    def status(self):
        print(f"\n=== {self.name} ===")
        print(f"Books: {len(self.catalog)} titles")
        print(f"Members: {len(self.members)}")
        for book in self.catalog.values():
            print(f"  {book}")


# Usage
library = Library("City Central Library")

# Add books
b1 = Book("Python Crash Course", "Eric Matthes", "978-1", 3)
b2 = Book("Clean Code", "Robert Martin", "978-2", 2)
b3 = Book("The Pragmatic Programmer", "David Thomas", "978-3", 1)

for book in [b1, b2, b3]:
    print(library.add_book(book))

# Register members
m1 = Member("Priya", "M001")
m2 = Member("Rahul", "M002")
library.register_member(m1)
library.register_member(m2)

# Borrow and return
print(m1.borrow(b1))  # Priya borrowed 'Python Crash Course' — due ...
print(m2.borrow(b1))  # Rahul borrowed 'Python Crash Course' — due ...

library.status()
# === City Central Library ===
# Books: 3 titles
# Members: 2
#   'Python Crash Course' by Eric Matthes (1/3 available)
#   'Clean Code' by Robert Martin (2/2 available)
#   'The Pragmatic Programmer' by David Thomas (1/1 available)

Employee Hierarchy

from abc import ABC, abstractmethod

class Employee(ABC):
    def __init__(self, name, emp_id, base_salary):
        self.name = name
        self.emp_id = emp_id
        self.base_salary = base_salary

    @abstractmethod
    def calculate_pay(self):
        pass

    def __str__(self):
        return f"{self.__class__.__name__}({self.name}, Rs.{self.calculate_pay():,.0f})"


class FullTimeEmployee(Employee):
    def __init__(self, name, emp_id, base_salary, bonus_pct=0.10):
        super().__init__(name, emp_id, base_salary)
        self.bonus_pct = bonus_pct

    def calculate_pay(self):
        return self.base_salary * (1 + self.bonus_pct)


class PartTimeEmployee(Employee):
    def __init__(self, name, emp_id, hourly_rate, hours_worked):
        super().__init__(name, emp_id, 0)
        self.hourly_rate = hourly_rate
        self.hours_worked = hours_worked

    def calculate_pay(self):
        return self.hourly_rate * self.hours_worked


class Manager(FullTimeEmployee):
    def __init__(self, name, emp_id, base_salary, bonus_pct=0.15, team=None):
        super().__init__(name, emp_id, base_salary, bonus_pct)
        self.team = team or []

    def add_member(self, employee):
        self.team.append(employee)

    def team_cost(self):
        return sum(e.calculate_pay() for e in self.team) + self.calculate_pay()


# Usage
dev1 = FullTimeEmployee("Priya", "E001", 80000)
dev2 = FullTimeEmployee("Rahul", "E002", 75000)
intern = PartTimeEmployee("Anita", "E003", 300, 120)

manager = Manager("Vijay", "M001", 120000)
manager.add_member(dev1)
manager.add_member(dev2)
manager.add_member(intern)

employees = [dev1, dev2, intern, manager]
for e in employees:
    print(e)

# Output:
# FullTimeEmployee(Priya, Rs.88,000)
# FullTimeEmployee(Rahul, Rs.82,500)
# PartTimeEmployee(Anita, Rs.36,000)
# Manager(Vijay, Rs.138,000)

print(f"\nTotal team cost: Rs.{manager.team_cost():,.0f}")
# Total team cost: Rs.344,500

Custom Collection Class

class SortedList:
    """A list that keeps its elements in sorted order."""

    def __init__(self, initial=None):
        self._data = sorted(initial) if initial else []

    def add(self, value):
        """Insert value in the correct sorted position."""
        # Binary search for the insertion point
        lo, hi = 0, len(self._data)
        while lo < hi:
            mid = (lo + hi) // 2
            if self._data[mid] < value:
                lo = mid + 1
            else:
                hi = mid
        self._data.insert(lo, value)

    def remove(self, value):
        self._data.remove(value)

    def __len__(self):
        return len(self._data)

    def __getitem__(self, index):
        return self._data[index]

    def __contains__(self, value):
        # Efficient binary search
        lo, hi = 0, len(self._data) - 1
        while lo <= hi:
            mid = (lo + hi) // 2
            if self._data[mid] == value:
                return True
            elif self._data[mid] < value:
                lo = mid + 1
            else:
                hi = mid - 1
        return False

    def __iter__(self):
        return iter(self._data)

    def __repr__(self):
        return f"SortedList({self._data})"

# Usage
sl = SortedList([5, 2, 8, 1])
print(sl)          # SortedList([1, 2, 5, 8])

sl.add(3)
sl.add(7)
print(sl)          # SortedList([1, 2, 3, 5, 7, 8])

print(len(sl))     # 6
print(sl[2])       # 3
print(5 in sl)     # True
print(99 in sl)    # False

for x in sl:
    print(x, end=" ")
# 1 2 3 5 7 8

SOLID Principles (Brief Overview)

SOLID is a set of five design principles that help you write maintainable, flexible object-oriented code.

S — Single Responsibility Principle

A class should have one reason to change — it should do one thing and do it well.

# BAD: One class doing too many things
class UserBad:
    def __init__(self, name, email):
        self.name = name
        self.email = email

    def save_to_database(self):
        # Database logic mixed with user logic
        print(f"Saving {self.name} to database")

    def send_welcome_email(self):
        # Email logic mixed with user logic
        print(f"Sending email to {self.email}")


# GOOD: Separate responsibilities
class User:
    def __init__(self, name, email):
        self.name = name
        self.email = email

class UserRepository:
    def save(self, user):
        print(f"Saving {user.name} to database")

class EmailService:
    def send_welcome(self, user):
        print(f"Sending welcome email to {user.email}")

user = User("Priya", "priya@example.com")
UserRepository().save(user)
EmailService().send_welcome(user)

O — Open/Closed Principle

Classes should be open for extension but closed for modification. Add new behaviour by adding new code, not changing existing code.

from abc import ABC, abstractmethod

# GOOD: Add new discount types without modifying existing code
class Discount(ABC):
    @abstractmethod
    def calculate(self, price):
        pass

class NoDiscount(Discount):
    def calculate(self, price):
        return price

class PercentageDiscount(Discount):
    def __init__(self, percent):
        self.percent = percent

    def calculate(self, price):
        return price * (1 - self.percent / 100)

class FlatDiscount(Discount):
    def __init__(self, amount):
        self.amount = amount

    def calculate(self, price):
        return max(0, price - self.amount)

# Adding a new discount type requires NO changes to existing classes
class BuyOneGetOneFree(Discount):
    def calculate(self, price):
        return price / 2

def apply_discount(price, discount: Discount):
    return discount.calculate(price)

print(apply_discount(1000, NoDiscount()))             # 1000
print(apply_discount(1000, PercentageDiscount(20)))   # 800.0
print(apply_discount(1000, FlatDiscount(150)))         # 850
print(apply_discount(1000, BuyOneGetOneFree()))       # 500.0

L — Liskov Substitution Principle

Subclasses should be usable in place of their parent class without breaking the program.

class Bird:
    def move(self):
        return "Moving"

class Sparrow(Bird):
    def move(self):
        return "Flying through the air"

class Penguin(Bird):
    def move(self):
        return "Waddling on the ground"

# Both subclasses work correctly when treated as Bird
def make_bird_move(bird: Bird):
    print(bird.move())

make_bird_move(Sparrow())  # Flying through the air
make_bird_move(Penguin())  # Waddling on the ground

# NOTE: If Bird had a fly() method, Penguin would violate LSP
# because penguins cannot fly. Using move() avoids this problem.

I — Interface Segregation Principle

Clients should not be forced to depend on methods they do not use. Prefer many small, specific interfaces over one large interface.

from abc import ABC, abstractmethod

# BAD: One fat interface forces all implementations to handle everything
# class Worker(ABC):
#     @abstractmethod
#     def work(self): ...
#     @abstractmethod
#     def eat(self): ...
#     @abstractmethod
#     def sleep(self): ...
# A Robot would have to implement eat() and sleep() — meaningless.

# GOOD: Separate interfaces
class Workable(ABC):
    @abstractmethod
    def work(self):
        pass

class Eatable(ABC):
    @abstractmethod
    def eat(self):
        pass

class HumanWorker(Workable, Eatable):
    def work(self):
        return "Human working"

    def eat(self):
        return "Human eating lunch"

class RobotWorker(Workable):
    def work(self):
        return "Robot working tirelessly"
    # No need to implement eat() — robots do not eat

h = HumanWorker()
r = RobotWorker()

print(h.work())  # Human working
print(h.eat())   # Human eating lunch
print(r.work())  # Robot working tirelessly

D — Dependency Inversion Principle

High-level modules should depend on abstractions, not concrete implementations.

from abc import ABC, abstractmethod

# Abstraction
class Database(ABC):
    @abstractmethod
    def save(self, data):
        pass

    @abstractmethod
    def find(self, query):
        pass

# Concrete implementations
class PostgresDatabase(Database):
    def save(self, data):
        return f"Saved to PostgreSQL: {data}"

    def find(self, query):
        return f"PostgreSQL found: {query}"

class MongoDatabase(Database):
    def save(self, data):
        return f"Saved to MongoDB: {data}"

    def find(self, query):
        return f"MongoDB found: {query}"

# High-level module depends on the ABSTRACTION (Database), not a specific DB
class UserService:
    def __init__(self, db: Database):  # Accepts any Database implementation
        self.db = db

    def create_user(self, name):
        return self.db.save({"name": name})

    def find_user(self, name):
        return self.db.find({"name": name})

# Easy to swap implementations
service_pg = UserService(PostgresDatabase())
service_mongo = UserService(MongoDatabase())

print(service_pg.create_user("Priya"))     # Saved to PostgreSQL: {'name': 'Priya'}
print(service_mongo.create_user("Priya"))  # Saved to MongoDB: {'name': 'Priya'}

Common Mistakes

1. Forgetting self

The most common beginner mistake — forgetting self in method definitions or when accessing attributes.

# WRONG
class Dog:
    def __init__(name):       # Missing self!
        name = name           # This creates a local variable, not an attribute

    def bark():               # Missing self!
        return f"{name} says Woof"  # name is not defined here

# CORRECT
class Dog:
    def __init__(self, name):
        self.name = name

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

2. Mutable Class Attributes

As covered earlier, mutable class-level defaults are shared across all instances.

# WRONG
class Team:
    members = []  # Shared across ALL teams!

    def add(self, name):
        self.members.append(name)

t1 = Team()
t2 = Team()
t1.add("Priya")
print(t2.members)  # ['Priya'] — t2 sees t1's member!

# CORRECT
class Team:
    def __init__(self):
        self.members = []  # Each team gets its own list

    def add(self, name):
        self.members.append(name)

3. Deep vs Shallow Copy of Objects

When you copy an object, the default is a shallow copy — nested objects are shared, not duplicated.

import copy

class Address:
    def __init__(self, city, state):
        self.city = city
        self.state = state

class Person:
    def __init__(self, name, address):
        self.name = name
        self.address = address

addr = Address("Mumbai", "Maharashtra")
p1 = Person("Priya", addr)

# Shallow copy — address is shared
p2 = copy.copy(p1)
p2.name = "Rahul"
p2.address.city = "Pune"

print(p1.address.city)  # Pune — p1's address was changed too!

# Deep copy — completely independent
p3 = copy.deepcopy(p1)
p3.address.city = "Delhi"

print(p1.address.city)  # Pune — p1 is unaffected by p3's change
print(p3.address.city)  # Delhi

4. Overusing Inheritance

Not every relationship should be modelled with inheritance. If you find yourself creating deep hierarchies or inheriting just to reuse a couple of methods, prefer composition.

# QUESTIONABLE: Stack inherits from list
class Stack(list):
    def push(self, item):
        self.append(item)

    def peek(self):
        return self[-1] if self else None

# Problem: Users can call ALL list methods — insert, sort, reverse, etc.
s = Stack()
s.push(1)
s.push(2)
s.insert(0, 99)  # This should not be allowed for a stack!
print(s)  # [99, 1, 2]

# BETTER: Composition — hide the list
class Stack:
    def __init__(self):
        self._data = []

    def push(self, item):
        self._data.append(item)

    def pop(self):
        if not self._data:
            raise IndexError("Stack is empty")
        return self._data.pop()

    def peek(self):
        if not self._data:
            return None
        return self._data[-1]

    def __len__(self):
        return len(self._data)

    def is_empty(self):
        return len(self._data) == 0

Practice Exercises

Exercise 1: Shape Calculator

Create a Shape abstract base class with area() and perimeter() abstract methods. Implement Circle, Rectangle, and Square (inheriting from Rectangle) classes. Write a function that takes a list of shapes and prints a report of each shape's area and perimeter.

Exercise 2: Bank System

Build a mini banking system with:

  • A BankAccount base class with deposit(), withdraw(), and transfer(other_account, amount) methods.
  • A SavingsAccount that earns interest and has a minimum balance requirement.
  • A CurrentAccount with an overdraft facility.
  • Track all transactions with timestamps.

Exercise 3: Custom Linked List

Implement a LinkedList class with:

  • append(value), prepend(value), remove(value) methods
  • Support for len(), in operator, iteration (for x in linked_list), and indexing (linked_list[2])
  • A __repr__ that shows the list like 1 -> 2 -> 3 -> None

Exercise 4: Deck of Cards

Create a Card class and a Deck class. The deck should support shuffling, dealing cards, and comparing hands. Use dunder methods so that cards can be compared (card1 > card2) and the deck supports len() and iteration.

Exercise 5: Plugin System

Design a plugin system using ABCs:

  • An abstract Plugin class with name, execute(data), and description methods.
  • Implement UpperCasePlugin, ReversePlugin, and CensorPlugin (replaces specified words with ***).
  • A Pipeline class that chains multiple plugins: Pipeline([plugin1, plugin2]).run(data).

Exercise 6: Inventory Management

Create an inventory system with:

  • A Product dataclass with name, price, quantity, and category.
  • An Inventory class that supports adding products, removing products, searching by category, calculating total value, and generating a stock report.
  • Use @property for validation (price and quantity must be non-negative).

Summary

In this chapter, you learned the foundations and advanced features of Object-Oriented Programming in Python:

  • Classes and Objects — A class is a blueprint; an object is an instance. Use __init__ to set up initial state and self to refer to the current instance.
  • Instance vs Class Attributes — Instance attributes are unique per object; class attributes are shared. Avoid mutable class-level defaults.
  • Methods — Instance methods operate on self; class methods operate on cls; static methods are utility functions with no access to instance or class state.
  • Encapsulation — Use naming conventions (_protected, __private) and @property decorators to control access to an object's internals.
  • Inheritance — Child classes inherit from parent classes. Use super() to call parent methods. The MRO determines method lookup order.
  • Multiple Inheritance — Python supports it with C3 linearization. Use mixins for clean, composable functionality.
  • Polymorphism — Different objects responding to the same interface. Python embraces duck typing: if an object has the right methods, it works.
  • Abstraction — Use ABCs from the abc module to define contracts that subclasses must fulfil.
  • Dunder Methods — Implement __str__, __repr__, __len__, __eq__, __add__, __iter__, and others to make your classes work naturally with Python's built-in operations.
  • Composition vs Inheritance — Favour "has-a" (composition) over "is-a" (inheritance) for flexibility.
  • Dataclasses — Use @dataclass to reduce boilerplate for data-centric classes.
  • SOLID Principles — Five design guidelines for writing clean, maintainable OOP code.

OOP is a skill that deepens with practice. Start by modelling simple real-world entities as classes, then gradually apply inheritance, composition, and design principles as your projects grow.