Object-Oriented Programming in Python
Master object-oriented programming in Python: classes and objects, __init__ and self, instance vs class attributes, @classmethod and @staticmethod, inheritance, super() and MRO, dunder methods, @property, encapsulation, abstract base classes, composition and dataclasses, with runnable examples.
Introduction: What Is Object-Oriented Programming in Python and Why It Matters
Object-oriented programming (OOP) is a way of organising code around objects: bundles of data (attributes) and the functions that operate on that data (methods). Instead of passing a dictionary of account details to a dozen loose functions, you create a BankAccount object that *knows* its balance and *knows how* to deposit, withdraw and print a statement.
In Python, everything you have used so far is already an object. The integer 42, the string "Mumbai", the list [1, 2, 3], even functions and modules are objects with a type and attributes. Lecture 5 showed you list.append() and dict.items(): those are methods on built-in classes. This lecture teaches you to build your own.
Why OOP matters in real projects:
- Modelling the domain โ an e-commerce backend has
Customer,Order,PaymentandShipmentobjects. Code that mirrors the business is easier to read and discuss with non-programmers. - Encapsulation โ the rules for changing a balance live inside
BankAccount, so nobody can accidentally set it to a negative number from somewhere else in the codebase. - Reuse through inheritance and composition โ Django models, FastAPI dependencies, pandas DataFrames, PyTorch modules and pytest fixtures are all built on classes. You cannot read framework source code, or write a plugin for it, without understanding OOP.
- Testability โ small objects with clear responsibilities are easy to unit-test and mock.
Python's OOP is deliberately lightweight compared with Java or C++. There is no private keyword, no interfaces in the strict sense, and no requirement to put one class per file. The language trusts you ("we are all consenting adults") and gives you conventions instead of walls. By the end of this lecture you will be able to design a small class hierarchy, override the operators Python uses behind the scenes, protect data with properties, define contracts with abstract base classes and remove boilerplate with dataclasses.
This lecture targets Python 3.13/3.14 (Python 3.14 is the current stable series as of writing, with 3.15 scheduled for October 2026). Version-specific features are labelled as they appear.
Classes and Instances: Defining Your First Python Class with __init__ and self
A class is a blueprint; an instance (or object) is a concrete thing built from that blueprint. class Student: defines the blueprint, and Student("Priya", 21) builds one student.
Key pieces of syntax:
class Name:โ class names useCapWords(PascalCase) by PEP 8. The body is indented like a function body.__init__(self, ...)โ the initialiser. Python calls it automatically right after creating a new, empty object. Its job is to attach the starting attributes. It is often called the "constructor", though strictly the object is created by__new__and *initialised* by__init__.selfโ the first parameter of every instance method. It refers to the specific object the method is being called on. The nameselfis a convention, not a keyword, but every Python developer expects it.- Attribute assignment โ
self.name = namecreates an instance attribute. There is no declaration step; the attribute exists the moment you assign it.
When you write priya.greet(), Python translates it to Student.greet(priya). That is exactly why self must be the first parameter and why forgetting it produces the famous "takes 0 positional arguments but 1 was given" error.
Every instance gets its own __dict__, a dictionary that stores its attributes. vars(priya) shows it. You can even add attributes after construction (priya.city = "Pune"), though in production you should set everything in __init__ so readers know what an object contains.
Type hints on __init__ parameters are strongly recommended. They cost nothing at runtime and let editors and tools such as mypy or pyright catch mistakes before you run the code.
# student.py
class Student:
"""A student enrolled in a course."""
def __init__(self, name: str, age: int, course: str = "Python") -> None:
# Attach data to THIS object (self)
self.name = name
self.age = age
self.course = course
self.marks: list[int] = [] # starts empty for every student
def add_marks(self, score: int) -> None:
self.marks.append(score)
def average(self) -> float:
if not self.marks:
return 0.0
return sum(self.marks) / len(self.marks)
def greet(self) -> str:
return f"Hi, I am {self.name}, {self.age}, studying {self.course}."
priya = Student("Priya", 21)
rahul = Student("Rahul", 23, course="Data Science")
priya.add_marks(88)
priya.add_marks(92)
print(priya.greet()) # Hi, I am Priya, 21, studying Python.
print(priya.average()) # 90.0
print(rahul.average()) # 0.0 (separate marks list)
print(type(priya)) # <class '__main__.Student'>
print(vars(priya)) # {'name': 'Priya', 'age': 21, 'course': 'Python', 'marks': [88, 92]}
print(Student.greet(rahul)) # same as rahul.greet()
print(isinstance(priya, Student)) # TrueInstance Attributes vs Class Attributes in Python
There are two places an attribute can live:
- Instance attributes are stored in the object's own
__dict__. They are created withself.x = ...and are different for every object. Name, balance, email: anything that varies per object. - Class attributes are defined directly in the class body (not inside a method). They are stored once on the class and shared by all instances. Use them for constants, defaults and counters: an interest rate, a species name, a registry of created objects.
When you read obj.attr, Python looks in the instance __dict__ first, then in the class, then in parent classes. This is the attribute lookup chain. When you *write* obj.attr = value, Python always writes to the instance, which creates a new instance attribute that *shadows* the class attribute for that one object. The class attribute itself is untouched.
This lookup rule produces the most famous OOP bug in Python: a mutable class attribute such as a list. If Student.marks = [] is a class attribute and every student does self.marks.append(...), all students share one list because append mutates the shared object in place rather than assigning a new one. The fix is to create mutable containers in __init__, exactly as the previous section did.
Legitimate uses of class attributes:
- Constants:
GST_RATE = 0.18. - A counter of how many objects exist (incremented via
type(self).count += 1orClassName.count += 1). - Default configuration a subclass can override:
timeout = 30.
Note that self.count += 1 would not update the class counter: it reads the class value, adds one and writes an *instance* attribute. Always modify class attributes through the class.
# attributes.py
class BankAccount:
bank_name = "State Bank of Bharat" # class attribute: shared
interest_rate = 0.04 # class attribute: shared
total_accounts = 0 # class attribute: counter
def __init__(self, owner: str, balance: float = 0.0) -> None:
self.owner = owner # instance attribute
self.balance = balance # instance attribute
BankAccount.total_accounts += 1 # modify via the class, not self
a = BankAccount("Asha", 50_000)
b = BankAccount("Bilal", 12_000)
print(a.bank_name, b.bank_name) # State Bank of Bharat State Bank of Bharat
print(BankAccount.total_accounts) # 2
BankAccount.interest_rate = 0.05 # change for everyone
print(a.interest_rate, b.interest_rate) # 0.05 0.05
a.interest_rate = 0.07 # creates an INSTANCE attribute on a only
print(a.interest_rate, b.interest_rate) # 0.07 0.05
print("interest_rate" in vars(a)) # True
print("interest_rate" in vars(b)) # False
# The classic bug: mutable class attribute
class BuggyCart:
items = [] # shared by ALL carts!
def add(self, item: str) -> None:
self.items.append(item)
c1, c2 = BuggyCart(), BuggyCart()
c1.add("laptop")
print(c2.items) # ['laptop'] <- c2 never added anything
class FixedCart:
def __init__(self) -> None:
self.items: list[str] = [] # one list per cart
def add(self, item: str) -> None:
self.items.append(item)
f1, f2 = FixedCart(), FixedCart()
f1.add("laptop")
print(f2.items) # []Instance Methods, @classmethod and @staticmethod in Python
Python classes support three kinds of methods, distinguished by what they receive as their first argument.
Instance methods receive self. They are the default and the most common. They read or change the state of one specific object.
Class methods are decorated with @classmethod and receive the class as the first argument, conventionally named cls. They cannot see any individual instance, but they can read class attributes and, crucially, create new instances with cls(...). The dominant use case is alternative constructors: Date.from_iso("2026-10-09"), Config.from_env(), User.from_dict(payload). Because they use cls rather than the hard-coded class name, they work correctly for subclasses: Employee.from_dict(d) returns an Employee, not a Person. Real examples: dict.fromkeys(), datetime.fromtimestamp(), int.from_bytes().
Static methods are decorated with @staticmethod and receive nothing special: no self, no cls. They are ordinary functions that happen to live inside the class namespace because they logically belong with it. Use them for validators and helpers such as Account.is_valid_ifsc(code) or unit conversions. If a function never touches self or cls, make it static (or move it to module level). Linters such as pylint will warn about "method could be a function" otherwise.
Decision rule:
- Needs this object's data โ instance method.
- Needs the class (to build an instance or read class config) โ
@classmethod. - Needs neither, just belongs here conceptually โ
@staticmethod.
Version note: combining @classmethod with @property to make "class properties" was deprecated in Python 3.11 and removed in Python 3.13. Use a plain class method or a module-level constant instead.
# methods.py
from datetime import date
class Employee:
company = "Infosys"
_next_id = 1001
def __init__(self, name: str, salary: float, joined: date) -> None:
self.name = name
self.salary = salary
self.joined = joined
self.emp_id = Employee._next_id
Employee._next_id += 1
# --- instance method: needs self ---
def years_of_service(self, today: date | None = None) -> int:
today = today or date.today()
return (today - self.joined).days // 365
# --- class method: alternative constructor ---
@classmethod
def from_dict(cls, data: dict) -> "Employee":
return cls(
name=data["name"],
salary=float(data["salary"]),
joined=date.fromisoformat(data["joined"]),
)
@classmethod
def intern(cls, name: str) -> "Employee":
"""Another alternative constructor with fixed defaults."""
return cls(name, salary=15_000, joined=date.today())
# --- static method: pure helper, no self/cls ---
@staticmethod
def is_valid_pan(pan: str) -> bool:
return (
len(pan) == 10
and pan[:5].isalpha()
and pan[5:9].isdigit()
and pan[9].isalpha()
)
payload = {"name": "Meera", "salary": "85000", "joined": "2021-06-15"}
meera = Employee.from_dict(payload)
print(meera.emp_id, meera.name, meera.salary) # 1001 Meera 85000.0
print(meera.years_of_service(date(2026, 10, 9))) # 5
arjun = Employee.intern("Arjun")
print(arjun.emp_id, arjun.salary) # 1002 15000
print(Employee.is_valid_pan("ABCDE1234F")) # True
print(meera.is_valid_pan("1234")) # False (works on instances too)
class Manager(Employee):
pass
boss = Manager.from_dict(payload)
print(type(boss).__name__) # Manager <- cls, not EmployeeInheritance and super() in Python
Inheritance lets a new class (the child or subclass) reuse and extend an existing class (the parent, base or superclass). Write class Dog(Animal): and Dog automatically gets every attribute and method of Animal. The relationship should read as "is-a": a Dog *is an* Animal, a SavingsAccount *is a* BankAccount.
Three things a subclass can do:
- Inherit a method unchanged: it simply uses the parent's version.
- Override a method: define a method with the same name, and the child's version wins when called on a child instance.
- Extend a method: override it, but call the parent's version inside using
super(), then add extra behaviour.
super() returns a proxy that lets you call methods of the parent class *without naming it*. In Python 3, the zero-argument form super().__init__(...) is all you need inside a method. The most common use is in __init__: the child accepts the parent's parameters plus its own, passes the parent's ones up with super().__init__(...), and then sets its own attributes. If you forget to call super().__init__(), the parent's attributes are never created and you get AttributeError later.
Why super() rather than Animal.__init__(self, ...)? Two reasons: if you rename or re-parent the class, you change one line instead of many; and with multiple inheritance, super() follows the method resolution order (next section), which a hard-coded parent name cannot do.
Useful built-ins:
isinstance(obj, Cls)โ True for the class or any subclass. Prefer it overtype(obj) == Cls.issubclass(Child, Parent).Cls.__bases__โ tuple of direct parents.
Every class in Python 3 implicitly inherits from object, which provides defaults for __init__, __repr__, __eq__ and friends. Keep hierarchies shallow (two or three levels). Deep trees are hard to follow and usually signal that composition (section 11) would be better.
# inheritance.py
class Vehicle:
wheels = 4
def __init__(self, brand: str, price: float) -> None:
self.brand = brand
self.price = price
def describe(self) -> str:
return f"{self.brand} ({self.wheels} wheels) - Rs {self.price:,.0f}"
def road_tax(self) -> float:
return self.price * 0.10
class Car(Vehicle):
# inherits __init__, describe, road_tax unchanged
pass
class Bike(Vehicle):
wheels = 2 # override a class attribute
def road_tax(self) -> float: # override a method
return self.price * 0.06
class ElectricCar(Car):
def __init__(self, brand: str, price: float, range_km: int) -> None:
super().__init__(brand, price) # let Vehicle set brand and price
self.range_km = range_km # then add our own attribute
def describe(self) -> str: # extend a method
base = super().describe()
return f"{base}, range {self.range_km} km"
def road_tax(self) -> float:
return 0.0 # EV incentive
garage = [
Car("Maruti Swift", 7_50_000),
Bike("Royal Enfield", 2_10_000),
ElectricCar("Tata Nexon EV", 15_00_000, range_km=465),
]
for v in garage:
print(f"{v.describe():<55} tax: Rs {v.road_tax():,.0f}")
# Maruti Swift (4 wheels) - Rs 750,000 tax: Rs 75,000
# Royal Enfield (2 wheels) - Rs 210,000 tax: Rs 12,600
# Tata Nexon EV (4 wheels) - Rs 1,500,000, range 465 km tax: Rs 0
ev = garage[2]
print(isinstance(ev, Vehicle), isinstance(ev, Car), isinstance(ev, Bike)) # True True False
print(issubclass(ElectricCar, Vehicle)) # True
print(ElectricCar.__bases__) # (<class '__main__.Car'>,)Multiple Inheritance and Method Resolution Order (MRO) in Python
Python allows a class to inherit from more than one parent: class Child(Mother, Father):. When several parents define the same method, which one runs? The answer is the Method Resolution Order (MRO): a linear list of classes that Python searches, in order, until it finds the attribute.
Python computes the MRO using the C3 linearisation algorithm. The rules you need to remember:
- A class always comes before its parents.
- Parents are searched in the order listed in the class statement (left to right).
- Each class appears only once, and
objectis always last. - If no consistent ordering exists, Python raises
TypeError: Cannot create a consistent method resolution orderat class creation time.
Inspect it with ClassName.__mro__ or ClassName.mro(). The classic shape is the diamond: D(B, C) where both B and C inherit from A. The MRO is D โ B โ C โ A โ object. Notice that A appears *after* both B and C, not between them. This is what makes super() cooperative: when B.__init__ calls super().__init__(), Python calls the next class in the MRO of the actual instance, which is C, not A. Every class in the chain runs exactly once. This only works if *every* class in the chain calls super(), which is why framework base classes always do.
The practical application of multiple inheritance in Python is the mixin: a small class that provides one piece of reusable behaviour (JSON serialisation, logging, timestamps) and is not meant to be instantiated alone. Django's class-based views (LoginRequiredMixin, ListView) are built entirely this way. Mixins should be listed first in the parent list so their methods take precedence over the main base class.
Rule of thumb: one real base class plus zero or more mixins. If you find yourself drawing a diamond on purpose, stop and consider composition.
# mro.py
class A:
def __init__(self) -> None:
print("A.__init__")
super().__init__()
def who(self) -> str:
return "A"
class B(A):
def __init__(self) -> None:
print("B.__init__")
super().__init__() # goes to the NEXT class in the MRO, not always A
def who(self) -> str:
return "B"
class C(A):
def __init__(self) -> None:
print("C.__init__")
super().__init__()
def who(self) -> str:
return "C"
class D(B, C):
def __init__(self) -> None:
print("D.__init__")
super().__init__()
d = D()
# D.__init__
# B.__init__
# C.__init__ <- B's super() reached C, because of the MRO
# A.__init__ <- A runs exactly once
print(d.who()) # B (first match in the MRO)
print([cls.__name__ for cls in D.__mro__])
# ['D', 'B', 'C', 'A', 'object']
# --- Mixin pattern ---
import json
class JSONMixin:
def to_json(self) -> str:
return json.dumps(vars(self), default=str)
class Product:
def __init__(self, name: str, price: float) -> None:
self.name = name
self.price = price
class SerializableProduct(JSONMixin, Product): # mixin first
pass
p = SerializableProduct("Keyboard", 1499.0)
print(p.to_json()) # {"name": "Keyboard", "price": 1499.0}Dunder (Magic) Methods in Python: __str__, __repr__, __eq__, __len__ and __lt__
Methods whose names start and end with double underscores are called dunder methods (from "double underscore") or magic methods. You never call them directly; Python calls them when you use an operator or a built-in function. len(obj) calls obj.__len__(), a == b calls a.__eq__(b), print(obj) calls obj.__str__(). Implementing them makes your objects feel like built-in types. This is Python's version of operator overloading, and it is the mechanism behind pandas, NumPy and pathlib (Path("a") / "b" is just __truediv__).
The ones every class should consider:
__repr__โ the *developer* representation, shown in the REPL, in debuggers and inside lists. Convention: return something that looks like the constructor call, e.g.Money(1500, 'INR'). If you define only one of the two, define this one;__str__falls back to it.__str__โ the *user-friendly* representation used byprint(),str()and f-strings. Example:Rs 1,500.00.__eq__โ defines==. By default, two objects are equal only if they are the *same* object. Override it to compare by value. Always check the type first and returnNotImplemented(notFalse) for foreign types so Python can try the other operand's__eq__.__hash__โ once you define__eq__, Python sets__hash__toNone, so your objects can no longer be dict keys or set members. If you want that, define__hash__too, based on the same fields as__eq__, and keep those fields immutable.__lt__(and__le__,__gt__,__ge__) โ ordering, which unlockssorted(),min(),max(). Define__eq__and__lt__, then decorate the class with@functools.total_orderingand the remaining four are generated for you.__len__โ makeslen()work and also makes the object falsy when empty, becausebool()falls back to__len__when__bool__is absent.
Others you will meet: __add__ (+), __getitem__ (indexing and iteration), __iter__, __contains__ (in), __enter__/__exit__ (with), __call__ (makes an instance callable).
# money.py
from functools import total_ordering
@total_ordering
class Money:
def __init__(self, paise: int, currency: str = "INR") -> None:
self.paise = paise # store in smallest unit: no float errors
self.currency = currency
def __repr__(self) -> str: # for developers
return f"Money({self.paise}, {self.currency!r})"
def __str__(self) -> str: # for users
return f"{self.currency} {self.paise / 100:,.2f}"
def __eq__(self, other: object) -> bool:
if not isinstance(other, Money):
return NotImplemented # let Python try the other side
return (self.paise, self.currency) == (other.paise, other.currency)
def __hash__(self) -> int: # keep hashable since we defined __eq__
return hash((self.paise, self.currency))
def __lt__(self, other: "Money") -> bool:
if not isinstance(other, Money) or other.currency != self.currency:
return NotImplemented
return self.paise < other.paise
def __add__(self, other: "Money") -> "Money":
if not isinstance(other, Money) or other.currency != self.currency:
return NotImplemented
return Money(self.paise + other.paise, self.currency)
class Wallet:
def __init__(self) -> None:
self._notes: list[Money] = []
def add(self, m: Money) -> None:
self._notes.append(m)
def __len__(self) -> int:
return len(self._notes)
def __getitem__(self, index: int) -> Money: # enables indexing AND for-loops
return self._notes[index]
a = Money(150_000) # Rs 1,500.00
b = Money(150_000)
c = Money(99_900)
print(repr(a)) # Money(150000, 'INR')
print(a) # INR 1,500.00
print(a == b, a is b) # True False
print(a + c) # INR 2,499.00
print(sorted([a, c, Money(5)])) # [Money(5, 'INR'), Money(99900, 'INR'), Money(150000, 'INR')]
print(a >= c, max(a, c)) # True Money(150000, 'INR') (from total_ordering)
print(len({a, b})) # 1 (equal objects hash the same)
w = Wallet()
print(bool(w)) # False (empty -> __len__ == 0)
w.add(a); w.add(c)
print(len(w), bool(w)) # 2 True
for note in w: # works because of __getitem__
print(" ", note)Properties in Python: @property, Setters and Computed Attributes
A property lets you expose a method as if it were a plain attribute. The caller writes account.balance with no parentheses, but behind the scenes a function runs. Properties solve two problems:
- Validation on assignment. You want
temp.celsius = -300to raise an error. A plain attribute cannot do that; a property with a setter can. - Computed, read-only values.
rect.areashould always equalwidth * heightwithout you remembering to update a stored field. A property with only a getter is automatically read-only; assigning to it raisesAttributeError: property 'area' of 'Rectangle' object has no setter.
Syntax: decorate the getter with @property. To add a setter, define a second function with the same name decorated with @<name>.setter. A @<name>.deleter exists too, but is rarely used. The backing data is usually stored in a single-underscore attribute such as self._celsius.
The big design win is that properties let you start simple and add logic later without breaking callers. In Java you write getters and setters for everything on day one "just in case". In Python you start with a plain attribute, and if validation becomes necessary in year two, you convert it to a property. Every obj.attr in the codebase keeps working. This is why PEP 8 says: do not write trivial get_x() / set_x() methods.
Guidelines:
- Property access should be cheap and side-effect free. If it hits the database or takes seconds, make it a method (
fetch_orders()) so the cost is visible to the caller. - Use
functools.cached_property(Python 3.8+) for an expensive value that should be computed once per instance and then stored. - In
__init__, assign through the property (self.celsius = value) so the validation runs on construction too.
# properties.py
from functools import cached_property
class Temperature:
ABSOLUTE_ZERO_C = -273.15
def __init__(self, celsius: float) -> None:
self.celsius = celsius # goes through the setter -> validated
@property
def celsius(self) -> float:
return self._celsius
@celsius.setter
def celsius(self, value: float) -> None:
if value < self.ABSOLUTE_ZERO_C:
raise ValueError(f"{value} C is below absolute zero")
self._celsius = float(value)
@property
def fahrenheit(self) -> float: # computed from celsius
return self._celsius * 9 / 5 + 32
@fahrenheit.setter
def fahrenheit(self, value: float) -> None:
self.celsius = (value - 32) * 5 / 9 # reuse celsius validation
t = Temperature(37)
print(t.celsius, t.fahrenheit) # 37.0 98.6
t.fahrenheit = 212
print(t.celsius) # 100.0
try:
t.celsius = -500
except ValueError as e:
print("Error:", e) # Error: -500 C is below absolute zero
class Rectangle:
def __init__(self, width: float, height: float) -> None:
self.width = width
self.height = height
@property
def area(self) -> float: # read-only: no setter defined
return self.width * self.height
r = Rectangle(4, 5)
print(r.area) # 20
r.width = 10
print(r.area) # 50 (always in sync)
try:
r.area = 99
except AttributeError as e:
print("Error:", e) # Error: property 'area' of 'Rectangle' object has no setter
class Report:
def __init__(self, rows: list[int]) -> None:
self.rows = rows
@cached_property
def total(self) -> int:
print(" computing total once...")
return sum(self.rows)
rep = Report(list(range(1_000_000)))
print(rep.total) # computing total once... / 499999500000
print(rep.total) # 499999500000 (cached, no recomputation)Encapsulation Conventions in Python: Public, _protected, __private and Name Mangling
Encapsulation means keeping an object's internal state hidden behind a well-defined interface, so that other code cannot depend on details that may change. Java enforces this with private and protected keywords. Python has no access modifiers at all; it uses naming conventions that every Python developer respects:
name(no underscore) โ public. Part of the official interface. Changing or removing it is a breaking change._name(single leading underscore) โ "protected" / internal by convention. The message is: "this is an implementation detail; use it only inside this class and its subclasses". Python does not stop you from accessing it from outside, but linters flag it, IDE autocomplete hides it, andfrom module import *skips it. This is the convention you will use 95% of the time for backing fields behind properties.__name(two leading underscores, no trailing underscores) โ triggers name mangling. Inside classAccount, Python rewritesself.__pintoself._Account__pin. This is not real privacy (you can still reachobj._Account__pin); its purpose is to avoid accidental clashes in subclasses. If a parent and child both define__cache, they get different mangled names and do not overwrite each other. Use it rarely and deliberately.__name__(two leading *and* trailing) โ reserved for Python's dunder protocol. Never invent your own.
The Python philosophy is summarised as "we are all consenting adults here": the language trusts developers to respect the underscore. The practical benefits of encapsulation in Python come from properties (section 8), which validate changes, and from offering methods that express intent (account.withdraw(500)) instead of letting callers poke at account._balance directly.
A related tool is __slots__: declare __slots__ = ("owner", "_balance") and instances no longer get a __dict__. Attribute creation is restricted to the listed names, access is faster and memory use drops by roughly 30 to 40 percent per instance. It is used in performance-sensitive code that creates millions of small objects.
# encapsulation.py
class Account:
__slots__ = ("owner", "_balance", "__pin") # optional: fixed attributes (__pin is mangled)
def __init__(self, owner: str, pin: str, balance: float = 0) -> None:
self.owner = owner # public
self._balance = balance # internal by convention
self.__pin = pin # name-mangled to _Account__pin
@property
def balance(self) -> float: # read-only view of internal state
return self._balance
def withdraw(self, amount: float, pin: str) -> None:
if pin != self.__pin:
raise PermissionError("Wrong PIN")
if amount > self._balance:
raise ValueError("Insufficient funds")
self._balance -= amount
acc = Account("Kiran", pin="4321", balance=10_000)
acc.withdraw(2_500, pin="4321")
print(acc.balance) # 7500
try:
acc.balance = 1_00_000 # no setter -> blocked
except AttributeError as e:
print("Blocked:", e)
print(acc._balance) # 7500 -> works, but linters warn: internal!
try:
print(acc.__pin) # AttributeError: no attribute '__pin'
except AttributeError as e:
print("Mangled:", e)
print(acc._Account__pin) # 4321 -> "private" is only a convention
try:
acc.nickname = "KK" # __slots__ forbids new attributes
except AttributeError as e:
print("Slots:", e)
# Why name mangling exists: subclass does not clobber parent's __cache
class Base:
def __init__(self) -> None:
self.__cache = "base-cache"
def base_cache(self) -> str:
return self.__cache
class Child(Base):
def __init__(self) -> None:
super().__init__()
self.__cache = "child-cache" # becomes _Child__cache, separate
c = Child()
print(c.base_cache()) # base-cache (untouched)
print(vars(c)) # {'_Base__cache': 'base-cache', '_Child__cache': 'child-cache'}Abstract Base Classes in Python with abc and typing.Protocol
An abstract base class (ABC) defines a contract: a set of methods that every subclass *must* implement. It cannot be instantiated itself. Use it when you have several interchangeable implementations of the same idea: payment gateways (Razorpay, PayU, Stripe), storage backends (local disk, S3), notification channels (SMS, email, WhatsApp).
Python provides this through the abc module:
- Inherit from
abc.ABC(or setmetaclass=ABCMeta). - Decorate required methods with
@abstractmethod. The body can be...,pass, a docstring, or even a default implementation that subclasses call viasuper(). - Trying to instantiate the ABC, or a subclass that has not implemented *every* abstract method, raises
TypeError: Can't instantiate abstract class ... without an implementation for abstract method .... The error happens at object-creation time, which is far earlier and clearer than aNotImplementedErrorburied deep in a request handler.
@abstractmethod can be stacked with @property, @classmethod and @staticmethod (put @abstractmethod innermost). ABCs can also contain concrete methods, so they double as a place for shared helper code.
The standard library uses ABCs heavily: collections.abc.Sequence, Mapping, Iterable describe what built-in containers do, and you can register or inherit from them to make your own class behave like a list or dict.
Protocols (Python 3.8+) are the structural alternative. typing.Protocol says "any object that *has* these methods counts", without requiring inheritance. This is duck typing made explicit for type checkers: a class that defines .area() satisfies a Shape protocol even if it never heard of it. Choose a Protocol when you cannot or do not want to modify the implementing classes (third-party objects, built-ins), and an ABC when you own the hierarchy and want runtime enforcement plus shared code.
# payments.py
from abc import ABC, abstractmethod
from typing import Protocol, runtime_checkable
class PaymentGateway(ABC):
"""Contract every gateway must satisfy."""
def __init__(self, merchant_id: str) -> None:
self.merchant_id = merchant_id
@property
@abstractmethod
def name(self) -> str: ...
@abstractmethod
def charge(self, amount_paise: int, upi_or_card: str) -> str:
"""Return a transaction id."""
# Concrete helper shared by all gateways
def receipt(self, txn_id: str, amount_paise: int) -> str:
return f"[{self.name}] txn {txn_id}: Rs {amount_paise / 100:,.2f}"
class RazorpayGateway(PaymentGateway):
@property
def name(self) -> str:
return "Razorpay"
def charge(self, amount_paise: int, upi_or_card: str) -> str:
return f"rzp_{abs(hash((amount_paise, upi_or_card))) % 10**8:08d}"
class BrokenGateway(PaymentGateway): # forgot to implement charge()
@property
def name(self) -> str:
return "Broken"
gw = RazorpayGateway("MERCHANT42")
txn = gw.charge(49_900, "kiran@upi")
print(gw.receipt(txn, 49_900)) # [Razorpay] txn rzp_xxxxxxxx: Rs 499.00
try:
PaymentGateway("X")
except TypeError as e:
print("ABC:", e)
# ABC: Can't instantiate abstract class PaymentGateway without an implementation
# for abstract methods 'charge', 'name'
try:
BrokenGateway("Y")
except TypeError as e:
print("Incomplete:", str(e)[:60], "...")
# --- Protocol: structural typing, no inheritance needed ---
@runtime_checkable
class Shape(Protocol):
def area(self) -> float: ...
class Circle: # never mentions Shape
def __init__(self, r: float) -> None:
self.r = r
def area(self) -> float:
return 3.14159 * self.r ** 2
def total_area(shapes: list[Shape]) -> float:
return sum(s.area() for s in shapes)
print(round(total_area([Circle(1), Circle(2)]), 2)) # 15.71
print(isinstance(Circle(1), Shape)) # True (runtime_checkable)Composition vs Inheritance in Python: Has-a Beats Is-a
Composition means building an object *out of* other objects: a Car has an Engine, an Order has a list of OrderLines and has a Customer. The outer object stores the inner ones as attributes and delegates work to them.
Compare with inheritance, which is is-a. Beginners reach for inheritance because it feels like free code reuse, but it creates the tightest coupling in OOP: the child depends on the parent's internal details, and any change in the parent ripples down. Deep hierarchies such as Animal โ Mammal โ Pet โ Dog โ GuideDog become impossible to modify safely. The widely-quoted design principle is "favour composition over inheritance" (from the Gang of Four book).
When to use which:
- Inheritance fits when the subclass truly *is* a specialised version of the parent, needs most of its behaviour unchanged, and the hierarchy is shallow. Exceptions (
class PaymentError(Exception)), ABC implementations, and framework base classes (class MyView(APIView)) are good examples. - Composition fits when you are combining capabilities. A
NotificationServicethat has anSMSSenderand anEmailSendercan swap either one at runtime, mock them in tests, or add aWhatsAppSenderwithout touching any class hierarchy.
Composition also gives you dependency injection for free: pass the collaborators into __init__ instead of creating them inside. Tests can then inject fakes. This is the pattern FastAPI's Depends() and most production services use.
A frequent middle ground is delegation: the outer class forwards a few methods to the inner object (def start(self): return self.engine.start()). If you need to forward *many* methods, implementing __getattr__ to delegate unknown attributes is a known trick, but explicit forwarding is clearer.
Litmus test: if you would be embarrassed to say "a Car is an Engine" out loud, do not inherit.
# composition.py
from dataclasses import dataclass, field
class Engine:
def __init__(self, hp: int, fuel: str) -> None:
self.hp = hp
self.fuel = fuel
self.running = False
def start(self) -> str:
self.running = True
return f"{self.fuel} engine ({self.hp} hp) started"
class GPS:
def locate(self) -> str:
return "19.0760 N, 72.8777 E (Mumbai)"
class Car:
"""A Car HAS an Engine and HAS a GPS: composition."""
def __init__(self, model: str, engine: Engine, gps: GPS | None = None) -> None:
self.model = model
self.engine = engine # injected collaborator
self.gps = gps or GPS()
def start(self) -> str: # delegation
return f"{self.model}: {self.engine.start()}"
def where_am_i(self) -> str:
return self.gps.locate()
petrol = Engine(hp=90, fuel="Petrol")
swift = Car("Swift", petrol)
print(swift.start()) # Swift: Petrol engine (90 hp) started
print(swift.where_am_i()) # 19.0760 N, 72.8777 E (Mumbai)
# Swap a part without touching the Car class
swift.engine = Engine(hp=150, fuel="Electric")
print(swift.start()) # Swift: Electric engine (150 hp) started
# Composition in tests: inject a fake
class FakeGPS:
def locate(self) -> str:
return "TEST-LOCATION"
test_car = Car("TestCar", Engine(1, "Test"), gps=FakeGPS())
assert test_car.where_am_i() == "TEST-LOCATION"
print("test passed")
# A richer example: Order HAS lines and HAS a customer
@dataclass
class OrderLine:
sku: str
qty: int
unit_price: float
@property
def total(self) -> float:
return self.qty * self.unit_price
@dataclass
class Order:
customer: str
lines: list[OrderLine] = field(default_factory=list)
def add(self, line: OrderLine) -> None:
self.lines.append(line)
@property
def grand_total(self) -> float:
return sum(l.total for l in self.lines)
o = Order("Deepa")
o.add(OrderLine("MOUSE-01", 2, 799))
o.add(OrderLine("PAD-03", 1, 349))
print(f"Rs {o.grand_total:,.2f}") # Rs 1,947.00Introduction to Python Dataclasses: Less Boilerplate, Same Classes
Look back at Money in section 7: __init__, __repr__, __eq__, __hash__, __lt__ were all written by hand just to store two fields. For classes whose main job is to hold data, Python 3.7 introduced dataclasses (PEP 557) to generate that boilerplate automatically.
Decorate a class with @dataclass and declare fields as annotated class attributes. The decorator reads the annotations and writes __init__, __repr__ and __eq__ for you. Fields with a default value become optional parameters; fields without one are required and must be listed first (unless you use kw_only).
Important options and features:
field(default_factory=list)โ for mutable defaults. Writingitems: list = []directly raisesValueError: mutable default ... use default_factory, which is the dataclass module protecting you from the shared-list bug of section 3.frozen=Trueโ makes instances immutable (assignment raisesFrozenInstanceError) and automatically hashable, so they can be dict keys and set members. Ideal for value objects such as coordinates, money and config.order=Trueโ generates__lt__,__le__,__gt__,__ge__comparing fields in declaration order, sosorted()works.slots=True(Python 3.10+) โ generates__slots__for memory and speed.kw_only=True(Python 3.10+) โ all fields must be passed by keyword; also lets fields with defaults precede fields without.__post_init__โ runs after the generated__init__; use it for validation or derived fields.dataclasses.asdict(),astuple(),replace()โ convert to a dict (handy for JSON), to a tuple, or create a modified copy of a frozen instance.- Dataclasses automatically define
__match_args__, so they work with positional patterns inmatchstatements (Python 3.10+).
Dataclasses are still ordinary classes: you can add methods, properties, class methods and inheritance. They are the right default for DTOs, configuration, records read from CSV or an API, and test fixtures. When you also need parsing and validation of external input (JSON from an HTTP request), Pydantic v2's BaseModel is the production choice; its syntax is intentionally similar. For pure in-memory data, dataclasses have zero dependencies and are faster.
# dataclasses_demo.py
from dataclasses import dataclass, field, asdict, replace
@dataclass(order=True)
class Student:
roll_no: int
name: str
city: str = "Delhi"
marks: list[int] = field(default_factory=list) # never list = []
def __post_init__(self) -> None:
if self.roll_no <= 0:
raise ValueError("roll_no must be positive")
self.name = self.name.strip().title()
@property
def average(self) -> float:
return sum(self.marks) / len(self.marks) if self.marks else 0.0
s1 = Student(7, " anjali ", marks=[90, 85])
s2 = Student(3, "Vikram", "Jaipur")
print(s1) # Student(roll_no=7, name='Anjali', city='Delhi', marks=[90, 85])
print(s1 == Student(7, "Anjali", "Delhi", [90, 85])) # True (generated __eq__)
print(sorted([s1, s2])[0].name) # Vikram (order=True, by roll_no)
print(s1.average) # 87.5
print(asdict(s2)) # {'roll_no': 3, 'name': 'Vikram', 'city': 'Jaipur', 'marks': []}
@dataclass(frozen=True, slots=True) # immutable value object
class Coordinate:
lat: float
lon: float
home = Coordinate(28.6139, 77.2090)
try:
home.lat = 0.0
except Exception as e:
print(type(e).__name__) # FrozenInstanceError
visited = {home, Coordinate(28.6139, 77.2090)}
print(len(visited)) # 1 (frozen -> hashable, equal by value)
moved = replace(home, lon=77.3)
print(moved) # Coordinate(lat=28.6139, lon=77.3)
@dataclass(kw_only=True) # Python 3.10+
class Config:
debug: bool = False
db_url: str # allowed after a default because kw_only
timeout: int = 30
cfg = Config(db_url="postgresql://localhost/app")
print(cfg) # Config(debug=False, db_url='postgresql://localhost/app', timeout=30)
# Pattern matching with dataclasses (Python 3.10+)
match home:
case Coordinate(lat, lon) if lat > 20:
print(f"Northern India: {lat}, {lon}")
case _:
print("elsewhere")Real-World Use Cases: How Python OOP Is Used in Production
Every major Python framework is a class hierarchy you plug into. Recognising the patterns from this lecture makes those frameworks far less mysterious.
- Django and SQLAlchemy models โ
class Order(models.Model):uses inheritance from a framework base class. Fields declared in the class body are class attributes that a metaclass turns into database columns.Order.objects.filter()is a class-level API;order.save()is an instance method. Custom@propertymethods such asorder.totalare everywhere in real Django code. - FastAPI and Pydantic v2 โ request and response schemas are classes (
class UserCreate(BaseModel):) that look exactly like dataclasses. Validation happens in generated__init__logic;@field_validatoris the Pydantic cousin of__post_init__. Dependency injection viaDepends()is composition in action. - PyTorch โ every neural network is
class Net(nn.Module):withsuper().__init__()in its initialiser and an overriddenforward(). Layers are composed as attributes;model(x)works becausenn.Moduleimplements__call__. - pandas and NumPy โ
df["price"] * 1.18anddf[df.city == "Pune"]work through__mul__,__getitem__and__eq__.len(df)is__len__. - pathlib โ
Path("data") / "sales.csv"is__truediv__;Pathobjects compare and hash by value. - Exceptions โ custom exception hierarchies (
class PaymentError(AppError)) let callers catch broad or narrow categories; this is inheritance used exactly as intended. - Context managers and iterators โ
with open(...)relies on__enter__/__exit__; custom iterators implement__iter__/__next__. - Strategy pattern with ABCs โ a payments service that supports Razorpay, PayU and Cashfree defines a
PaymentGatewayABC and selects a concrete class from configuration. New providers are added without modifying existing code (the Open/Closed principle). - Configuration objects โ a frozen dataclass or Pydantic
BaseSettingsloaded once at startup replaces a mess of global variables. - Testing โ pytest fixtures often return small objects or fakes that implement the same Protocol as a real dependency, which is only possible because the production code accepts collaborators through composition.
Interviewers at product companies routinely ask candidates to model something small (parking lot, library, splitwise) using exactly these building blocks. The hands-on exercise at the end of this lecture is a practice run.
Common Mistakes with Python Classes and How to Fix Them
- Forgetting
selfโdef greet():inside a class raisesTypeError: greet() takes 0 positional arguments but 1 was given. Every instance method's first parameter is the instance. Fix:def greet(self):. - Mutable class attributes โ
items = []in the class body is shared by every instance. Fix: create lists, dicts and sets inside__init__(self.items = []), or usefield(default_factory=list)in dataclasses. - Not calling
super().__init__()โ the child defines its own__init__and the parent's attributes are never created, causingAttributeErrorin inherited methods. Fix: callsuper().__init__(...)first, passing the parent's parameters. - Calling the class instead of creating an instance โ
Student.greet()fails with a missingself. Fix:Student("Asha").greet(). - Comparing with
type(obj) == Clsโ breaks for subclasses. Fix:isinstance(obj, Cls). - Defining
__eq__without__hash__โ objects silently become unhashable; putting them in a set raisesTypeError: unhashable type. Fix: define__hash__on the same immutable fields, or use@dataclass(frozen=True). - Returning
Falseinstead ofNotImplementedfrom__eq__/__lt__for unrelated types โ prevents Python from trying the reflected operation on the other operand. Fix:return NotImplemented. - Writing Java-style getters and setters โ
get_name()/set_name()everywhere is noise. Fix: plain attributes first;@propertywhen validation is needed. - Infinite recursion in a property โ the getter does
return self.balanceinstead ofreturn self._balance, calling itself forever (RecursionError). Fix: store the backing value under a different (underscored) name. - Overusing
__double_underscore"private" names โ name mangling makes subclasses and tests awkward and gives no real security. Fix: use a single underscore unless you specifically need clash protection. - Deep inheritance for code reuse โ five-level hierarchies that nobody can modify. Fix: compose objects; use mixins sparingly.
- Side effects in
__repr__or properties โ logging, I/O or network calls inside them make debugging print statements change behaviour. Keep them pure. - Abstract class without
ABCโ decorating with@abstractmethodhas no effect unless the class inherits fromABC(or usesABCMeta). Fix:class Base(ABC):. - Mutable default argument in a method (
def add(self, tags=[])) โ same shared-object bug as class attributes. Fix:tags=Nonethentags = tags or [].
# mistakes_fixed.py
from dataclasses import dataclass, field
# 1. Property recursion -> fixed with a backing field
class Wallet:
def __init__(self, balance: float) -> None:
self._balance = balance # NOT self.balance = ... inside getter
@property
def balance(self) -> float:
return self._balance # returning self.balance would recurse
# 2. Missing super().__init__() -> fixed
class Person:
def __init__(self, name: str) -> None:
self.name = name
class Employee(Person):
def __init__(self, name: str, emp_id: int) -> None:
super().__init__(name) # without this, self.name never exists
self.emp_id = emp_id
print(Employee("Neha", 7).name) # Neha
# 3. __eq__ without __hash__ -> fixed with frozen dataclass
@dataclass(frozen=True)
class Point:
x: int
y: int
print(len({Point(1, 2), Point(1, 2)})) # 1
# 4. Mutable default argument -> fixed
class Post:
def __init__(self, title: str, tags: list[str] | None = None) -> None:
self.title = title
self.tags = tags if tags is not None else []
p1, p2 = Post("A"), Post("B")
p1.tags.append("python")
print(p2.tags) # []
# 5. isinstance instead of type() ==
class Base: ...
class Derived(Base): ...
d = Derived()
print(type(d) == Base, isinstance(d, Base)) # False TrueFrequently Asked Questions about Object-Oriented Programming in Python
What is the difference between a class and an object in Python?
A class is the blueprint: it defines what attributes and methods a type of thing has. An object (instance) is a concrete value created from that blueprint with its own attribute values. Student is a class; Student("Priya", 21) is an object. One class can produce any number of objects.
What is self in Python and why is it needed?
self is the first parameter of an instance method and refers to the specific object the method is operating on. Python passes it automatically when you call obj.method(), which is equivalent to Class.method(obj). Without it, a method would have no way to know which object's data to read or modify. The name is a convention; the position is what matters.
What is the difference between __init__ and __new__ in Python?
__new__ is the static method that actually allocates and returns a new instance; __init__ then initialises that instance's attributes. You override __init__ almost always and __new__ almost never, except for immutable types (subclassing int or tuple), singletons or metaprogramming.
What is the difference between @classmethod and @staticmethod in Python?
A class method receives the class (cls) as its first argument and is typically used as an alternative constructor or to access class-level state; it respects subclassing. A static method receives no implicit first argument at all and is just a plain function stored in the class namespace for organisational reasons, such as a validator.
Does Python support multiple inheritance?
Yes. A class can list several parents, and Python resolves conflicts using the C3 method resolution order, viewable via ClassName.__mro__. In practice it is used mostly for mixins: small classes that add one capability. Diamond hierarchies work correctly as long as every class in the chain calls super().
How do you make a private variable in Python?
Python has no true private variables. A single leading underscore (_balance) signals "internal, do not touch" by convention. A double leading underscore (__pin) triggers name mangling to _ClassName__pin, which prevents accidental clashes in subclasses but can still be accessed. Real protection comes from exposing properties and methods rather than raw attributes.
What is the difference between __str__ and __repr__ in Python?
__repr__ is the unambiguous developer representation shown in the REPL and inside containers, ideally resembling the constructor call. __str__ is the readable form used by print() and f-strings. If you define only __repr__, str() falls back to it, so define __repr__ first.
When should I use a dataclass instead of a regular class?
Use a dataclass when the class's primary purpose is to hold data and you want __init__, __repr__ and __eq__ generated automatically: records, DTOs, configuration, value objects. Use a regular class when behaviour dominates and the constructor needs custom logic beyond what __post_init__ can handle, or when you are inheriting from a framework base class. For validating external input, Pydantic models are the production choice.
Interview Questions and Answers on Python OOP
Q1. What are the four pillars of OOP, and how does Python implement each?
Encapsulation: bundling data with methods and hiding internals through underscore conventions and properties. Abstraction: exposing only what callers need, enforced with abstract base classes and Protocols. Inheritance: class Child(Parent) with super() and the MRO. Polymorphism: the same method name behaving differently per class, achieved through method overriding and duck typing, and extended to operators through dunder methods.
Q2. Explain method resolution order. What would D(B, C) with a shared base A print for D.__mro__?
MRO is the ordered list of classes Python searches for an attribute, computed by C3 linearisation. For the diamond it is [D, B, C, A, object]. A class always precedes its parents, parents keep their declared order, and each class appears once, which guarantees super() visits every class exactly once.
Q3. Why does defining __eq__ make a class unhashable, and how do you fix it?
Equal objects must have equal hashes. The default __hash__ is identity-based, which would violate that rule once __eq__ compares by value, so Python sets __hash__ = None. Fix it by defining __hash__ over the same fields used in __eq__ (which should be immutable), or use @dataclass(frozen=True).
Q4. What is the difference between composition and inheritance, and when do you prefer each?
Inheritance models "is-a" and shares implementation through the class hierarchy; composition models "has-a" by storing collaborator objects as attributes. Prefer composition by default because it has looser coupling, supports swapping collaborators at runtime and simplifies testing through dependency injection. Use inheritance for genuine specialisation, exception hierarchies and framework base classes.
Q5. How does super() work with multiple inheritance?
super() does not mean "my parent"; it means "the next class after me in the MRO of the instance being used". In a diamond, B's super().__init__() may call C.__init__, not A.__init__. This cooperative behaviour requires every class in the chain to call super() and to accept and forward arguments it does not use, usually with **kwargs.
Q6. What does @property do and when is it better than a method?
It turns a method into an attribute-style accessor, optionally with a setter for validation. Use it for cheap, side-effect-free values that conceptually are attributes (area, full name, formatted balance). Use a regular method when the operation is expensive, has side effects or takes arguments, so the cost is explicit to the caller.
Q7. What is an abstract base class and what happens if a subclass does not implement all abstract methods?
An ABC (from the abc module) declares methods that subclasses must implement with @abstractmethod. Python refuses to instantiate the ABC itself or any subclass that leaves an abstract method unimplemented, raising TypeError at construction time, which catches missing implementations early.
Q8. What are __slots__ and why would you use them?
__slots__ is a class attribute listing the allowed instance attribute names. Instances then have no __dict__, which lowers memory per object significantly and speeds up attribute access. Use it for classes instantiated in very large numbers, such as rows, nodes or events. Trade-offs: you cannot add attributes dynamically, and multiple inheritance with slots needs care.
Q9. What is duck typing, and how does typing.Protocol relate to it?
Duck typing means Python cares about what an object can do, not what class it belongs to: if it has .read(), it can be used as a file. Protocol makes this explicit for static type checkers: a class satisfies a Protocol by having the right methods, without inheriting from it. @runtime_checkable additionally allows isinstance() checks.
Q10. How would you implement a singleton in Python, and should you?
Override __new__ to return a stored instance, or use a module-level object, which is the simplest and most Pythonic approach because modules are imported once. Singletons are often an anti-pattern because they hide dependencies and complicate testing; injecting a shared instance through composition is usually better.
Hands-On Exercise: Build a Library Management System with Python Classes
Build a small library system that exercises everything from this lecture: an abstract LibraryItem base class with concrete Book and DVD subclasses, dunder methods for printing, equality and sorting, a @property with validation, a @classmethod alternative constructor, a @staticmethod validator, a frozen Member dataclass, and a Library class that uses composition to hold items and members and expose len() and in.
Requirements:
LibraryItem(ABC): fieldsitem_id,title,year; abstract propertyloan_days; abstract methoddescribe(); concrete methodscheckout(member)andreturn_item();__eq__/__hash__byitem_id;__lt__by title so items sort alphabetically.Book(LibraryItem): addsauthorandisbn, validates the ISBN with a static method,loan_days = 14, and afrom_csv_row()class method.DVD(LibraryItem): addsminutes,loan_days = 7.Member: a frozen dataclass withmember_id,name,city.Library: stores items in a dict keyed byitem_id; supportsadd(),find(item_id),available(),len(library),item in libraryand iteration in sorted order.
Run the file with python library.py and compare your output with the comments at the bottom. Then extend it: add an EBook class with unlimited copies, or a Fine calculation using datetime.
# library.py -- run: python library.py
from __future__ import annotations
from abc import ABC, abstractmethod
from dataclasses import dataclass
from datetime import date, timedelta
from functools import total_ordering
@dataclass(frozen=True)
class Member:
member_id: int
name: str
city: str = "Bengaluru"
@total_ordering
class LibraryItem(ABC):
"""Anything the library can lend. Subclasses decide the loan period."""
def __init__(self, item_id: str, title: str, year: int) -> None:
self.item_id = item_id
self.title = title
self.year = year # validated by the property setter
self._borrower: Member | None = None
self._due: date | None = None
# ----- validated attribute -----
@property
def year(self) -> int:
return self._year
@year.setter
def year(self, value: int) -> None:
if not 1450 <= value <= date.today().year:
raise ValueError(f"Invalid publication year: {value}")
self._year = value
# ----- contract for subclasses -----
@property
@abstractmethod
def loan_days(self) -> int: ...
@abstractmethod
def describe(self) -> str: ...
# ----- shared behaviour -----
@property
def is_available(self) -> bool:
return self._borrower is None
def checkout(self, member: Member, today: date | None = None) -> date:
if not self.is_available:
raise RuntimeError(f"'{self.title}' is already with {self._borrower.name}")
today = today or date.today()
self._borrower = member
self._due = today + timedelta(days=self.loan_days)
return self._due
def return_item(self) -> None:
self._borrower, self._due = None, None
# ----- dunder methods -----
def __repr__(self) -> str:
return f"{type(self).__name__}({self.item_id!r}, {self.title!r}, {self.year})"
def __str__(self) -> str:
status = "available" if self.is_available else f"due {self._due}"
return f"{self.describe()} [{status}]"
def __eq__(self, other: object) -> bool:
if not isinstance(other, LibraryItem):
return NotImplemented
return self.item_id == other.item_id
def __hash__(self) -> int:
return hash(self.item_id)
def __lt__(self, other: LibraryItem) -> bool:
if not isinstance(other, LibraryItem):
return NotImplemented
return self.title.lower() < other.title.lower()
class Book(LibraryItem):
def __init__(self, item_id: str, title: str, year: int, author: str, isbn: str) -> None:
if not self.is_valid_isbn(isbn):
raise ValueError(f"Invalid ISBN-13: {isbn}")
super().__init__(item_id, title, year)
self.author = author
self.isbn = isbn
@property
def loan_days(self) -> int:
return 14
def describe(self) -> str:
return f"Book: {self.title} by {self.author} ({self.year})"
@staticmethod
def is_valid_isbn(isbn: str) -> bool:
digits = isbn.replace("-", "")
if len(digits) != 13 or not digits.isdigit():
return False
total = sum(int(d) * (1 if i % 2 == 0 else 3) for i, d in enumerate(digits))
return total % 10 == 0
@classmethod
def from_csv_row(cls, row: str) -> Book:
item_id, title, year, author, isbn = (part.strip() for part in row.split(","))
return cls(item_id, title, int(year), author, isbn)
class DVD(LibraryItem):
def __init__(self, item_id: str, title: str, year: int, minutes: int) -> None:
super().__init__(item_id, title, year)
self.minutes = minutes
@property
def loan_days(self) -> int:
return 7
def describe(self) -> str:
return f"DVD: {self.title} ({self.year}, {self.minutes} min)"
class Library:
"""Composition: a Library HAS items and HAS members."""
def __init__(self, name: str) -> None:
self.name = name
self._items: dict[str, LibraryItem] = {}
self._members: dict[int, Member] = {}
def add(self, item: LibraryItem) -> None:
if item.item_id in self._items:
raise ValueError(f"Duplicate item id {item.item_id}")
self._items[item.item_id] = item
def register(self, member: Member) -> None:
self._members[member.member_id] = member
def find(self, item_id: str) -> LibraryItem:
return self._items[item_id]
def available(self) -> list[LibraryItem]:
return sorted(i for i in self._items.values() if i.is_available)
def __len__(self) -> int:
return len(self._items)
def __contains__(self, item: object) -> bool:
return isinstance(item, LibraryItem) and item.item_id in self._items
def __iter__(self):
return iter(sorted(self._items.values()))
if __name__ == "__main__":
lib = Library("Koramangala Public Library")
lib.add(Book.from_csv_row("B1, Fluent Python, 2022, Luciano Ramalho, 978-1-4920-5635-5"))
lib.add(Book("B2", "Automate the Boring Stuff", 2019, "Al Sweigart", "978-1-59327-992-9"))
lib.add(DVD("D1", "3 Idiots", 2009, minutes=170))
try:
lib.add(Book("B3", "Fake", 2020, "Nobody", "123-4-56-789"))
except ValueError as e:
print("Rejected:", e)
ananya = Member(1, "Ananya", "Mysuru")
lib.register(ananya)
due = lib.find("B1").checkout(ananya, today=date(2026, 10, 9))
print("Due date:", due)
try:
lib.find("B1").checkout(Member(2, "Rohan"))
except RuntimeError as e:
print("Error:", e)
print(f"{lib.name}: {len(lib)} items, {len(lib.available())} available")
for item in lib: # sorted by title via __lt__
print(" -", item)
print(lib.find("D1") in lib, DVD("D9", "X", 2000, 1) in lib) # True False
print(repr(lib.find("D1")))
print(lib.find("B2") == Book("B2", "Automate the Boring Stuff", 2019, "x", "978-1-59327-992-9"))
# Expected output:
# Rejected: Invalid ISBN-13: 123-4-56-789
# Due date: 2026-10-23
# Error: 'Fluent Python' is already with Ananya
# Koramangala Public Library: 3 items, 2 available
# - DVD: 3 Idiots (2009, 170 min) [available]
# - Book: Automate the Boring Stuff by Al Sweigart (2019) [available]
# - Book: Fluent Python by Luciano Ramalho (2022) [due 2026-10-23]
# True False
# DVD('D1', '3 Idiots', 2009)
# TrueSummary
- A class is a blueprint; an instance is an object built from it.
__init__initialises attributes andselfis the instance being operated on. - Instance attributes (
self.x) are per object; class attributes are shared. Never use a mutable class attribute as a per-instance default. - Instance methods take
self;@classmethodtakesclsand is ideal for alternative constructors;@staticmethodtakes neither and holds helpers. - Inheritance models "is-a";
super()calls the next class in the MRO, which Python computes with C3 linearisation and exposes via__mro__. Use mixins sparingly. - Dunder methods (
__repr__,__str__,__eq__,__hash__,__lt__,__len__,__add__,__getitem__) let your objects work with operators and built-ins;@total_orderingfills in comparison gaps. @propertygives attribute syntax with validation and computed values;cached_propertymemoises expensive ones.- Encapsulation in Python is by convention:
_internal,__mangled, and__slots__for fixed, memory-efficient attributes. - Abstract base classes (
ABC,@abstractmethod) enforce contracts at instantiation time;Protocoldoes so structurally for type checkers. - Prefer composition ("has-a") over deep inheritance; inject collaborators for flexibility and testability.
- Dataclasses (Python 3.7+) generate
__init__,__repr__and__eq__; usefield(default_factory=...),frozen=True,order=True,slots=Trueandkw_only=True(3.10+) as needed.
Next lecture: Modules, Packages, Dependencies & Project Structure