Advanced Object-Oriented Programming

M7: Advanced Object-Oriented Programming — Python for Corporate Professionals | OTLMS
M7 · Advanced OOP Python for Corporate Professionals

Modeling Real Systems with Advanced OOP

Basic classes and inheritance let you group data and behaviour together. Advanced OOP is what makes that code feel like idiomatic Python instead of a translation from another language — objects that print themselves sensibly, support + and == naturally, expose validated attributes without ugly getter/setter calls, and enforce a contract on every subclass. This module covers the toolkit professional Python codebases rely on daily: magic methods, classmethod/staticmethod, properties, polymorphism, abstract classes, overloading patterns, and multiple inheritance with MRO.

10 topics ~130 min read Advanced level All roles
📖
Concept — The Advanced OOP Toolkit
Magic methods, decorators, properties, polymorphism, abstract classes, overloading, and MRO

You already know how to write a class with an __init__ and a few methods. That covers the mechanics — but professional Python code does more: objects that print themselves cleanly, support + and ==, validate their own attributes, and expose a contract every subclass must honour. That’s what this module builds, topic by topic, each with runnable code.

Topic 1

Quick Recap — Classes, Objects & Inheritance

A class is a blueprint; an object is a specific instance built from it. Inheritance lets a subclass reuse and extend a parent class’s behaviour, calling into the parent with super() rather than duplicating code.

Python
class Vehicle:
    def __init__(self, make, model):
        self.make  = make
        self.model = model

    def describe(self):
        return f"{self.make} {self.model}"

class Car(Vehicle):
    def __init__(self, make, model, seats):
        super().__init__(make, model)   # reuse Vehicle's setup
        self.seats = seats

    def describe(self):
        base = super().describe()        # extend, don't duplicate
        return f"{base} ({self.seats}-seater)"

my_car = Car("Toyota", "Innova", 7)
print(my_car.describe())
print(isinstance(my_car, Vehicle))   # True — a Car IS-A Vehicle
Output
Toyota Innova (7-seater)
True
Topic 2

Encapsulation — Public, Protected & Private Members

Python has no true “private” keyword — it uses naming conventions that other developers (and tools) respect. A single leading underscore (_balance) signals “internal, don’t touch from outside.” A double leading underscore (__pin) triggers name mangling, making accidental external access much harder.

ConventionMeaning
namePublic — part of the intended interface
_nameProtected — internal use, accessible but “please don’t”
__namePrivate — name-mangled to _ClassName__name, hard to access accidentally
Python
class BankAccount:
    def __init__(self, owner, balance, pin):
        self.owner    = owner            # public
        self._balance = balance          # protected — internal bookkeeping
        self.__pin    = pin              # private — name-mangled

    def withdraw(self, amount, pin):
        if pin != self.__pin:
            raise PermissionError("Incorrect PIN")
        if amount > self._balance:
            raise ValueError("Insufficient funds")
        self._balance -= amount
        return self._balance

acc = BankAccount("Priya", 5000, pin="4477")
print(acc.withdraw(1200, "4477"))
print(acc._balance)                    # accessible, but a convention violation
print(acc._BankAccount__pin)           # the mangled name — works, but ugly on purpose
Output
3800
3800
4477
💡 Name mangling exists to prevent accidental collisions in subclasses, not to provide real security. Anyone can still access acc._BankAccount__pin if they choose to — the underscore is a strong social contract, not a lock.
Topic 3

Magic Methods (Dunder Methods)

Magic methods — named with double underscores, like __init__ — let your objects hook into Python’s built-in syntax and functions. Define __add__ and your objects support +; define __len__ and len(obj) works. This is how built-in types like list and int work under the hood too.

MethodTriggered by
__init__Object creation: MyClass(…)
__str__str(obj), print(obj) — user-friendly display
__repr__repr(obj) — unambiguous, developer-facing display
__eq__obj1 == obj2
__lt__obj1 < obj2 (also powers sorted())
__add__obj1 + obj2
__len__len(obj)
Python
class Money:
    def __init__(self, amount, currency="INR"):
        self.amount   = amount
        self.currency = currency

    def __repr__(self):
        return f"Money({self.amount}, {self.currency!r})"   # unambiguous, for debugging

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

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

    def __lt__(self, other):
        return self.amount < other.amount

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

salary  = Money(75000)
bonus   = Money(12000)
total   = salary + bonus

print(total)                          # uses __str__
print(repr(total))                    # uses __repr__
print(salary == Money(75000))          # uses __eq__
print(sorted([salary, bonus, total]))  # uses __lt__
Output
INR 87000.00
Money(87000, ‘INR’)
True
[Money(12000, ‘INR’), Money(75000, ‘INR’), Money(87000, ‘INR’)]
Topic 4

classmethod and staticmethod Decorators

A normal method receives self — the instance. A @classmethod receives cls — the class itself — and is commonly used for alternative constructors. A @staticmethod receives neither; it’s a plain function that simply lives inside the class namespace because it’s conceptually related.

TypeFirst argumentTypical use
Instance methodselfReads/modifies the object’s own state
@classmethodclsAlternative constructors, class-level operations
@staticmethod(none)Utility logic related to the class but needing no instance/class data
Python
class Pizza:
    def __init__(self, size, toppings):
        self.size     = size
        self.toppings = toppings

    def __repr__(self):
        return f"Pizza({self.size}\", {self.toppings})"

    @classmethod
    def margherita(cls, size=12):
        """Alternative constructor — a preset pizza."""
        return cls(size, ["tomato", "mozzarella", "basil"])

    @staticmethod
    def is_valid_size(size):
        """Utility check — doesn't need self or cls."""
        return size in (8, 10, 12, 14, 16)

custom = Pizza(14, ["pepperoni", "olives"])
preset = Pizza.margherita()             # built via the classmethod, no direct __init__ call

print(custom)
print(preset)
print(Pizza.is_valid_size(14))         # called on the class, not an instance
print(Pizza.is_valid_size(13))
Output
Pizza(14″, [‘pepperoni’, ‘olives’])
Pizza(12″, [‘tomato’, ‘mozzarella’, ‘basil’])
True
False
Topic 5

Property Decorators

Other languages need explicit getX()/setX() methods to validate attribute access. Python’s @property lets you keep the clean obj.attribute syntax while still running validation code behind the scenes — callers never know it’s a method.

Python
class Temperature:
    def __init__(self, celsius):
        self._celsius = celsius             # stored internally

    @property
    def celsius(self):
        """Getter — runs when you READ .celsius"""
        return self._celsius

    @celsius.setter
    def celsius(self, value):
        """Setter — runs when you WRITE to .celsius"""
        if value < -273.15:
            raise ValueError("Temperature below absolute zero is impossible")
        self._celsius = value

    @property
    def fahrenheit(self):
        """Read-only computed property — no setter defined"""
        return (self._celsius * 9/5) + 32

t = Temperature(25)
print(t.celsius, t.fahrenheit)     # reads like plain attributes

t.celsius = 30                    # goes through the setter's validation
print(t.celsius, t.fahrenheit)

try:
    t.celsius = -300               # triggers the validation error
except ValueError as e:
    print(f"Rejected: {e}")
Output
25 77.0
30 86.0
Rejected: Temperature below absolute zero is impossible
Topic 6

Polymorphism

Polymorphism means objects of different classes respond to the same method call in their own way. Code that calls shape.area() doesn’t need to know or care whether shape is a Circle or a Rectangle — it just trusts that every shape knows how to compute its own area.

Python
import math

class Shape:
    def area(self):
        raise NotImplementedError

    def describe(self):
        # same method name, but behaviour depends on which subclass calls area()
        return f"{type(self).__name__}: area = {self.area():.2f}"

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

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

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

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

shapes = [Circle(4), Rectangle(3, 5), Circle(1.5)]

for shape in shapes:
    print(shape.describe())   # one call site, different behaviour per object
Output
Circle: area = 50.27
Rectangle: area = 15.00
Circle: area = 7.07
Topic 7

Abstract Base Classes

Polymorphism above relies on every subclass remembering to implement area() — nothing enforces it. An abstract base class (from the abc module) turns that expectation into a hard rule: the class cannot be instantiated at all until every @abstractmethod is implemented.

Python
from abc import ABC, abstractmethod

class PaymentProcessor(ABC):
    @abstractmethod
    def pay(self, amount):
        """Every subclass MUST implement this."""
        ...

    def receipt(self, amount):
        # concrete methods are still allowed and inherited normally
        return f"Charged ₹{amount} via {type(self).__name__}"

class CreditCardProcessor(PaymentProcessor):
    def pay(self, amount):
        return f"₹{amount} charged to credit card"

class UPIProcessor(PaymentProcessor):
    def pay(self, amount):
        return f"₹{amount} debited via UPI"

processors = [CreditCardProcessor(), UPIProcessor()]
for p in processors:
    print(p.pay(1500))
    print(p.receipt(1500))

# Trying to instantiate the abstract class directly fails immediately:
processor = PaymentProcessor()
Output
₹1500 charged to credit card
Charged ₹1500 via CreditCardProcessor
₹1500 debited via UPI
Charged ₹1500 via UPIProcessor
Traceback
Traceback (most recent call last):
  File “payments.py”, line 20, in <module>
    processor = PaymentProcessor()
TypeError: Can’t instantiate abstract class PaymentProcessor without an implementation for abstract method ‘pay’
Topic 8

Method Overloading — Python’s Approach

Languages like Java let you define multiple methods with the same name but different parameter lists. Python does not support that — defining greet() twice simply makes the second definition overwrite the first. Instead, Python achieves the same flexibility with default arguments, *args/**kwargs, or — for genuinely type-based dispatch — functools.singledispatchmethod.

Python
from functools import singledispatchmethod

class Invoice:
    def __init__(self):
        self.items = []

    # Approach 1: default arguments cover the common "overload" cases
    def add_item(self, name, qty=1, price=0.0):
        self.items.append((name, qty, price))
        return f"Added {qty} x {name} @ ₹{price}"

    # Approach 2: dispatch on the TYPE of the first real argument
    @singledispatchmethod
    def apply_discount(self, value):
        raise TypeError(f"Unsupported discount type: {type(value).__name__}")

    @apply_discount.register
    def _(self, value: int):
        return f"Flat discount of ₹{value} applied"

    @apply_discount.register
    def _(self, value: float):
        return f"Percentage discount of {value}% applied"

inv = Invoice()
print(inv.add_item("Notebook"))                 # uses both defaults
print(inv.add_item("Pen", qty=5))
print(inv.add_item("Laptop", qty=1, price=55000))

print(inv.apply_discount(200))       # int → flat discount
print(inv.apply_discount(10.0))      # float → percentage discount
Output
Added 1 x Notebook @ ₹0.0
Added 5 x Pen @ ₹0.0
Added 1 x Laptop @ ₹55000
Flat discount of ₹200 applied
Percentage discount of 10.0% applied
Topic 9

Multiple Inheritance & Method Resolution Order (MRO)

Python allows a class to inherit from more than one parent. When several parents define the same method, Python needs a deterministic rule for which one wins — that rule is the Method Resolution Order, computed with the C3 linearization algorithm and inspectable via ClassName.__mro__.

Diamond inheritance
Base — defines greet()
Left(Base) — overrides greet()
Right(Base) — overrides greet()
Child(Left, Right) — which greet() runs?
Python
class Base:
    def greet(self):
        return "Hello from Base"

class Left(Base):
    def greet(self):
        return f"Hello from Left, then: {super().greet()}"

class Right(Base):
    def greet(self):
        return f"Hello from Right, then: {super().greet()}"

class Child(Left, Right):
    pass   # doesn't define greet() itself — resolution follows the MRO

c = Child()
print(c.greet())
print([cls.__name__ for cls in Child.__mro__])
Output
Hello from Left, then: Hello from Right, then: Hello from Base
[‘Child’, ‘Left’, ‘Right’, ‘Base’, ‘object’]

Notice that super() inside Left.greet() doesn’t jump straight to Base — it follows the MRO chain to Right next, and only then to Base. This is what lets cooperative multiple inheritance work correctly instead of skipping a sibling class.

Topic 10

Composition Over Inheritance

Inheritance models an “is-a” relationship (a Car is a Vehicle). Composition models a “has-a” relationship (a Car has an Engine) by storing another object as an attribute instead of inheriting from it. It’s usually the more flexible choice — you can swap the engine without restructuring the class hierarchy.

Python
class Engine:
    def __init__(self, horsepower):
        self.horsepower = horsepower

    def start(self):
        return f"Engine roaring at {self.horsepower}hp"

class Car:
    def __init__(self, model, engine):
        self.model  = model
        self.engine = engine          # composed, not inherited — Car HAS-A Engine

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

petrol_engine = Engine(150)
electric_engine = Engine(300)

car1 = Car("Sedan", petrol_engine)
car2 = Car("EV Hatchback", electric_engine)

print(car1.start())
print(car2.start())
Output
Sedan: Engine roaring at 150hp
EV Hatchback: Engine roaring at 300hp
✏️
Syntax Reference
Every advanced OOP pattern from the Concept section, condensed and runnable

Inheritance & encapsulation

Python
# super() to reuse a parent's __init__ and methods
class Animal:
    def __init__(self, name):
        self.name = name

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

d = Dog("Rex", "Labrador")
print(d.name, d.breed)

# Encapsulation conventions
class Wallet:
    def __init__(self):
        self.currency  = "INR"     # public
        self._history  = []          # protected — internal use
        self.__pin     = "0000"   # private — name-mangled to _Wallet__pin

w = Wallet()
print(w._Wallet__pin)   # still reachable — mangling is a convention, not a lock
Output
Rex Labrador
0000

Magic methods reference

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

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

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

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

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

p1, p2 = Point(1, 2), Point(3, 4)
print(p1 + p2)          # __add__
print(p1 == Point(1, 2))  # __eq__
print(len(p2))         # __len__
print(p1)              # __repr__ (used when no __str__ is defined)
Output
Point(4, 6)
True
5
Point(1, 2)

classmethod, staticmethod & property

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

    @classmethod
    def square(cls, side):
        return cls(side, side)              # alternative constructor

    @staticmethod
    def is_positive(value):
        return value > 0                      # no self/cls needed

    @property
    def area(self):
        return self._width * self._height   # read like an attribute

    @property
    def width(self):
        return self._width

    @width.setter
    def width(self, value):
        if not Rectangle.is_positive(value):
            raise ValueError("width must be positive")
        self._width = value

r = Rectangle.square(5)
print(r.area)          # property — no parentheses
r.width = 10            # goes through the setter
print(r.area)
Output
25
50

Polymorphism & abstract base classes

Python
from abc import ABC, abstractmethod

class Notifier(ABC):
    @abstractmethod
    def send(self, message):
        ...

class EmailNotifier(Notifier):
    def send(self, message):
        return f"Emailed: {message}"

class SMSNotifier(Notifier):
    def send(self, message):
        return f"Texted: {message}"

# Polymorphism — same call, different behaviour per subclass
for notifier in [EmailNotifier(), SMSNotifier()]:
    print(notifier.send("Deployment complete"))

# Abstract classes cannot be instantiated directly
Notifier()
Output
Emailed: Deployment complete
Texted: Deployment complete
Traceback
Traceback (most recent call last):
  File “notify.py”, line 15, in <module>
    Notifier()
TypeError: Can’t instantiate abstract class Notifier without an implementation for abstract method ‘send’

Overloading patterns & MRO

Python
# Overloading via default arguments
class Greeter:
    def greet(self, name="there", formal=False):
        return f"Good day, {name}." if formal else f"Hey {name}!"

g = Greeter()
print(g.greet())
print(g.greet("Asha", formal=True))

# Multiple inheritance and MRO
class Flyer:
    def move(self):
        return "flies"

class Swimmer:
    def move(self):
        return "swims"

class Duck(Flyer, Swimmer):   # Flyer listed first — its move() wins
    pass

print(Duck().move())
print([cls.__name__ for cls in Duck.__mro__])
Output
Hey there!
Good day, Asha.
flies
[‘Duck’, ‘Flyer’, ‘Swimmer’, ‘object’]
💡
Examples — Advanced OOP in Real Scenarios
Five programs combining magic methods, decorators, polymorphism, and abstract classes

Each example combines several topics from this module — that’s deliberate. In real codebases, magic methods, properties, and abstract classes are rarely used in isolation; they work together on the same class.

Example 1 — IT Support: Polymorphic ticket hierarchy with sorting
IT SupportAll Roles

Models different ticket types with a shared interface. Magic methods (__repr__, __lt__) make tickets print cleanly and sort by priority automatically, while polymorphism lets each ticket type calculate its own SLA deadline.

Python
from abc import ABC, abstractmethod

class Ticket(ABC):
    PRIORITY_RANK = {"critical": 0, "high": 1, "normal": 2, "low": 3}

    def __init__(self, ticket_id, title, priority):
        self.ticket_id = ticket_id
        self.title     = title
        self.priority  = priority

    @abstractmethod
    def sla_hours(self):
        """Each ticket type defines its own SLA window."""
        ...

    def __repr__(self):
        return f"[{self.ticket_id}] {type(self).__name__}({self.priority}): {self.title} — SLA {self.sla_hours()}h"

    def __lt__(self, other):
        # lower rank number = more urgent = sorts first
        return self.PRIORITY_RANK[self.priority] < self.PRIORITY_RANK[other.priority]

class BugTicket(Ticket):
    def sla_hours(self):
        return 4 if self.priority == "critical" else 24

class FeatureRequest(Ticket):
    def sla_hours(self):
        return 120   # features are never urgent

# ── Simulated ticket queue ──
queue = [
    FeatureRequest("T-101", "Add dark mode", "low"),
    BugTicket("T-102", "Login page 500 error", "critical"),
    BugTicket("T-103", "Typo in footer", "low"),
    FeatureRequest("T-104", "Export to CSV", "normal"),
]

for ticket in sorted(queue):    # uses __lt__ — most urgent first
    print(ticket)              # uses __repr__
Output
[T-102] BugTicket(critical): Login page 500 error — SLA 4h
[T-104] FeatureRequest(normal): Export to CSV — SLA 120h
[T-101] FeatureRequest(low): Add dark mode — SLA 120h
[T-103] BugTicket(low): Typo in footer — SLA 24h
Example 2 — Database Developer: Connection object with validated properties
Database Dev

A connection settings class that uses a @classmethod factory to parse a connection string, @property to validate the port on assignment, and __enter__/__exit__ so the object works with Python’s with statement — a very common pattern for anything needing guaranteed cleanup.

Python
class DBConnection:
    def __init__(self, host, port, database):
        self.host      = host
        self.port      = port          # goes through the setter below
        self.database  = database
        self._is_open  = False

    @classmethod
    def from_connection_string(cls, conn_str):
        """Alternative constructor: 'host:port/database' → DBConnection"""
        host_port, database = conn_str.split("/")
        host, port = host_port.split(":")
        return cls(host, int(port), database)

    @property
    def port(self):
        return self._port

    @port.setter
    def port(self, value):
        if not 1 <= value <= 65535:
            raise ValueError(f"Invalid port: {value}")
        self._port = value

    def __enter__(self):
        self._is_open = True
        print(f"Connected to {self.database}@{self.host}:{self.port}")
        return self

    def __exit__(self, exc_type, exc_val, exc_tb):
        self._is_open = False
        print(f"Connection to {self.database} closed")
        return False   # don't suppress exceptions

conn = DBConnection.from_connection_string("db-prod-01:5432/inventory")

with conn as c:
    print(f"Running query against {c.database} ... is_open={c._is_open}")

try:
    conn.port = 99999            # triggers the property validation
except ValueError as e:
    print(f"Rejected: {e}")
Output
Connected to inventory@db-prod-01:5432
Running query against inventory … is_open=True
Connection to inventory closed
Rejected: Invalid port: 99999
Example 3 — DevOps/Automation: Notification system with a logging mixin and MRO
DevOpsAutomation

An abstract NotificationSender defines the contract; a LoggingMixin is combined in via multiple inheritance to add logging to every sender without repeating code. The MRO decides the exact call order when super() is used.

Python
from abc import ABC, abstractmethod

class NotificationSender(ABC):
    @abstractmethod
    def send(self, message):
        ...

class LoggingMixin:
    """Mixin — adds logging around send(), meant to be combined with a sender."""
    def send(self, message):
        print(f"[LOG] Sending via {type(self).__name__}: {message!r}")
        result = super().send(message)   # hands off to the next class in the MRO
        print(f"[LOG] Delivery result: {result}")
        return result

class SlackSender(NotificationSender):
    def send(self, message):
        return f"Slack message posted: {message}"

class LoggedSlackSender(LoggingMixin, SlackSender):
    pass   # mixin listed first so its send() runs before SlackSender's

sender = LoggedSlackSender()
sender.send("Deployment finished successfully")
print([cls.__name__ for cls in LoggedSlackSender.__mro__])
Output
[LOG] Sending via LoggedSlackSender: ‘Deployment finished successfully’
[LOG] Delivery result: Slack message posted: Deployment finished successfully
[‘LoggedSlackSender’, ‘LoggingMixin’, ‘SlackSender’, ‘NotificationSender’, ‘ABC’, ‘object’]
Example 4 — AI/Data: A custom iterable Dataset class
AI/Data

Implements __len__, __getitem__, and __iter__ so a plain class behaves like a built-in sequence — supporting len(), indexing, slicing, and for loops. A read-only @property exposes a computed statistic without recalculating it on every access mistake.

Python
class Dataset:
    def __init__(self, records):
        self._records = list(records)

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

    def __getitem__(self, index):
        return self._records[index]      # enables dataset[0] and dataset[1:3]

    def __iter__(self):
        return iter(self._records)      # enables "for record in dataset"

    @property
    def mean_value(self):
        return sum(self._records) / len(self)

ds = Dataset([12, 18, 9, 25, 14])

print(len(ds))            # uses __len__
print(ds[0], ds[1:3])     # uses __getitem__ — single item and a slice
print(ds.mean_value)      # computed property

for value in ds:          # uses __iter__
    print(f"  record: {value}")
Output
5
12 [18, 9]
15.6
  record: 12
  record: 18
  record: 9
  record: 25
  record: 14
Example 5 — Automation: Employee bonus calculator with overloaded dispatch
AutomationAll Roles

Uses functools.singledispatchmethod to calculate bonuses differently depending on the type of employee object passed in — the closest Python equivalent to method overloading — plus __eq__ and __hash__ so employees can be safely stored in a set for deduplication.

Python
from functools import singledispatchmethod

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

    def __eq__(self, other):
        return self.emp_id == other.emp_id     # two records are "the same" if IDs match

    def __hash__(self):
        return hash(self.emp_id)              # required to be usable in a set

    def __repr__(self):
        return f"Employee({self.emp_id}, {self.name!r})"

class Manager(Employee):
    pass

class BonusCalculator:
    @singledispatchmethod
    def calculate(self, employee):
        raise TypeError(f"No bonus rule for {type(employee).__name__}")

    @calculate.register
    def _(self, employee: Manager):
        return employee.salary * 0.20          # managers get 20%

    @calculate.register
    def _(self, employee: Employee):
        return employee.salary * 0.10          # regular staff get 10%

calc = BonusCalculator()
staff = Employee(101, "Rahul", 50000)
lead  = Manager(102, "Meera", 90000)

print(f"{staff.name}: ₹{calc.calculate(staff):.0f}")
print(f"{lead.name}: ₹{calc.calculate(lead):.0f}")

# Deduplication via __eq__/__hash__
duplicate = Employee(101, "Rahul K.", 50000)   # same ID, slightly different name
unique_staff = {staff, lead, duplicate}
print(len(unique_staff))   # 2, not 3 — duplicate collapsed by emp_id
Output
Rahul: ₹5000
Meera: ₹18000
2
🏋️
Practice Exercises
Five exercises covering magic methods through MRO

Run each exercise and actually print the results — with magic methods especially, it’s easy to assume __eq__ or __repr__ is working when it isn’t wired up correctly.

1
Magic methods on a Fraction class. Write a class Fraction(numerator, denominator) that implements __repr__ (e.g. “3/4”), __eq__ (two fractions are equal if they reduce to the same value — compare n1*d2 == n2*d1 to avoid floating point), and __add__ (add two fractions using (n1*d2 + n2*d1, d1*d2)). Test with Fraction(1,2) + Fraction(1,3) and Fraction(1,2) == Fraction(2,4).
__add__ should return Fraction(n1*d2 + n2*d1, d1*d2) — don’t reduce the fraction unless you want an extra challenge. Remember __eq__ receives other as its second parameter, just like self.
2
classmethod factory + staticmethod validator. Write a class Employee(name, id_number) with a @classmethod from_full_name(cls, full_name, id_number) that splits “Rahul Sharma” into first/last and stores name as just the first name. Add a @staticmethod is_valid_id(id_number) that returns True only if the ID is a 4-digit number. Use the staticmethod inside __init__ to reject invalid IDs by raising ValueError.
Checking a 4-digit number: len(str(id_number)) == 4 and str(id_number).isdigit(). Call it as Employee.is_valid_id(…) from inside __init__ — a staticmethod is still reachable via the class name even from inside an instance method.
3
Property-validated Stack. Write a class Stack(max_size) with a private list __items. Add a read-only @property size returning the current item count, and a push(item) method that raises OverflowError if adding the item would exceed max_size. Add __len__ so len(my_stack) also works, and __bool__ so an empty stack is falsy in an if check.
__bool__ should return len(self.__items) > 0. Since size is a property with no setter, trying my_stack.size = 5 should raise AttributeError automatically — try it to confirm.
4
Abstract shape hierarchy with polymorphism. Define an abstract class Shape(ABC) with abstract methods area() and perimeter(). Implement Square(side) and Triangle(base, height, side_a, side_b, side_c). Write a function total_area(shapes) that sums .area() across a mixed list, using polymorphism — it should not check isinstance anywhere. Confirm that Shape() on its own raises TypeError.
Triangle’s perimeter is just side_a + side_b + side_c; area uses the standard 0.5 * base * height formula. total_area should be a one-line sum(shape.area() for shape in shapes) — that’s the whole point of polymorphism.
5
Diagnose the MRO. Given class A: def who(self): return “A”, class B(A): def who(self): return “B”, class C(A): def who(self): return “C”, and class D(B, C): pass — without running it, write down what D().who() returns and what D.__mro__ looks like. Then run it to check yourself, and change class D(C, B) to see how the order flips.
The MRO always follows the order parents are listed in the class definition, then depth-first but keeping each class after all its subclasses. For D(B, C) the order is D, B, C, A, object — so who() resolves to B’s version.
📋
Assignment — M7
A small library inventory system — estimated 60–75 minutes

📋 Library Inventory System

Build a script called library_system.py that models a small library’s catalogue using the full advanced OOP toolkit from this module — abstract classes, magic methods, properties, classmethods, and polymorphism working together.

  1. Abstract base class LibraryItem(ABC) with: title, item_id, a private __is_checked_out flag; an abstract method loan_period_days() that each subclass must implement; and concrete methods check_out() / check_in() that toggle the flag (raise ValueError if checking out an already-checked-out item).
  2. Two subclasses: Book(title, item_id, author, pages) with loan_period_days() returning 14, and DVD(title, item_id, runtime_minutes) with loan_period_days() returning 7.
  3. Magic methods on LibraryItem: __repr__ showing type, title, and checkout status; __eq__ comparing by item_id; __hash__ so items can go in a set.
  4. @property is_available on LibraryItem — read-only, returns not self.__is_checked_out.
  5. Catalogue class with a @classmethod from_seed_data(cls, records) that builds a Catalogue from a list of dicts (each with a “type” key of “book” or “dvd”), constructing the right subclass for each — this is polymorphism at the construction level. Implement __len__ (total items) and __iter__ (iterate over items) on Catalogue.
  6. Report function — given a Catalogue, print every item using its __repr__, then print total items, how many are available, and the combined loan-days if every item were checked out today (using loan_period_days() polymorphically — no isinstance checks allowed anywhere in this function).
Your script passes when Catalogue.from_seed_data() correctly builds a mix of Books and DVDs from plain dicts, the report function runs with zero isinstance checks, and attempting to check out an already-checked-out item raises a clear error instead of silently succeeding. Share your script and terminal output in the comments.
🧠
Quiz — Check Your Understanding
6 questions · instant feedback · retake as many times as you need

Two of these questions trace through exact output — the best way to build real intuition for magic methods and MRO is predicting what a class will print before you run it.

1. What is the main difference between a @classmethod and a @staticmethod?
2. What will this code print?
class Box:
    def __init__(self, size):
        self.size = size
    def __eq__(self, other):
        return self.size == other.size

a = Box(5)
b = Box(5)
print(a == b)
print(a is b)
3. Why does PaymentProcessor() raise a TypeError when PaymentProcessor is an abstract class with an @abstractmethod pay(), even though the class itself has no syntax errors?
4. What is the property decorator (@property / @x.setter) primarily used for?
5. Given class A: pass, class B(A): pass, class C(A): pass, class D(B, C): pass — what does D.__mro__ look like?
6. Why does Python not support true method overloading (multiple methods with the same name but different parameters, like Java)?
Your classes now behave like first-class Python citizens — printing cleanly, validating themselves, and enforcing contracts across subclasses. Next, you’ll connect your programs to the outside world.
M8: Python For Automation — How to automate services using Python.
Start M8 →
Scroll to Top