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.
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.
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.
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
True
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.
| Convention | Meaning |
|---|---|
| name | Public — part of the intended interface |
| _name | Protected — internal use, accessible but “please don’t” |
| __name | Private — name-mangled to _ClassName__name, hard to access accidentally |
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
3800
4477
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.
| Method | Triggered 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) |
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__
Money(87000, ‘INR’)
True
[Money(12000, ‘INR’), Money(75000, ‘INR’), Money(87000, ‘INR’)]
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.
| Type | First argument | Typical use |
|---|---|---|
| Instance method | self | Reads/modifies the object’s own state |
| @classmethod | cls | Alternative constructors, class-level operations |
| @staticmethod | (none) | Utility logic related to the class but needing no instance/class data |
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))
Pizza(12″, [‘tomato’, ‘mozzarella’, ‘basil’])
True
False
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.
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}")
30 86.0
Rejected: Temperature below absolute zero is impossible
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.
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
Rectangle: area = 15.00
Circle: area = 7.07
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.
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()
Charged ₹1500 via CreditCardProcessor
₹1500 debited via UPI
Charged ₹1500 via UPIProcessor
File “payments.py”, line 20, in <module>
processor = PaymentProcessor()
TypeError: Can’t instantiate abstract class PaymentProcessor without an implementation for abstract method ‘pay’
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.
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
Added 5 x Pen @ ₹0.0
Added 1 x Laptop @ ₹55000
Flat discount of ₹200 applied
Percentage discount of 10.0% applied
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__.
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__])
[‘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.
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.
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())
EV Hatchback: Engine roaring at 300hp
Inheritance & encapsulation
# 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
0000
Magic methods reference
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)
True
5
Point(1, 2)
classmethod, staticmethod & property
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)
50
Polymorphism & abstract base classes
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()
Texted: Deployment complete
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
# 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__])
Good day, Asha.
flies
[‘Duck’, ‘Flyer’, ‘Swimmer’, ‘object’]
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.
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.
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__
[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
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.
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}")
Running query against inventory … is_open=True
Connection to inventory closed
Rejected: Invalid port: 99999
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.
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__])
[LOG] Delivery result: Slack message posted: Deployment finished successfully
[‘LoggedSlackSender’, ‘LoggingMixin’, ‘SlackSender’, ‘NotificationSender’, ‘ABC’, ‘object’]
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.
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}")
12 [18, 9]
15.6
record: 12
record: 18
record: 9
record: 25
record: 14
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.
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
Meera: ₹18000
2
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.
📋 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.
- 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).
- 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.
- Magic methods on LibraryItem: __repr__ showing type, title, and checkout status; __eq__ comparing by item_id; __hash__ so items can go in a set.
- @property is_available on LibraryItem — read-only, returns not self.__is_checked_out.
- 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.
- 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).
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.
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)
