Mastering Python OOP
Mastering Python OOP
Encapsulation Bundle data + behaviour; hide internals Classes, _private convention, properties
Abstraction Expose a simple interface, hide complexity Public methods, ABCs, Protocols
# PROCEDURAL: data and behaviour are separate and can drift apart
account = {"owner": "Ali", "balance": 1000}
deposit(account, 500)
account["balance"] = -99999 # Nothing stops this. Data is unprotected.
@property
def balance(self):
return self._balance # read-only from outside
Key idea: An object is responsible for keeping itself in a valid state. That is the single biggest practical win of OOP.
print(type(5)) #
print(type("hi")) #
print(type([1, 2])) #
print(type(print)) #
print(type(int)) # <- classes are objects too!
print(type(type)) # <- type is its own type
Anatomy of a class
class Dog:
"""A simple Dog class.""" # docstring -> Dog.__doc__
d = Dog("Rex", 3)
print([Link]()) # Rex says Woof!
print(d) # Dog(Rex, 3) <- uses __str__
print(repr(d)) # Dog(name='Rex', age=3) <- uses __repr__
print([Link]) # Canis familiaris
print([Link]) # Canis familiaris
print(Dog.__doc__) # A simple Dog class.
What is self ?
self is simply the instance, passed automatically as the first argument. It is a convention, not a keyword — but never rename it.
d = Dog("Rex", 3)
[Link]() # Python translates this to...
[Link](d) # ...exactly this. Same result.
# Proof:
print([Link]) # -> plain function
print([Link]) # -> bound to d
print([Link].__self__ is d) # True
d = Dog("Rex", 3)
print(d.__dict__) # {'name': 'Rex', 'age': 3}
print(Dog.__dict__.keys())
# dict_keys(['__module__', '__doc__', 'species', '__init__', 'bark', ...])
del [Link]
print(hasattr(d, "nickname")) # False
class A:
x = "class value"
a = A()
print(a.x) # class value (found on the class)
a.x = "instance" # creates an entry in a.__dict__
print(a.x) # instance (instance shadows class)
print(A.x) # class value (class untouched)
del a.x
print(a.x) # class value (shadow removed)
Dynamic attribute access
d = Dog("Rex", 3)
print(getattr(d, "name")) # Rex
print(getattr(d, "colour", "n/a")) # n/a (default avoids AttributeError)
setattr(d, "colour", "brown")
print(hasattr(d, "colour")) # True
delattr(d, "colour")
class BadDog:
tricks = [] # SHARED list — one list for ALL dogs!
a = BadDog("Rex")
b = BadDog("Buddy")
a.add_trick("roll over")
print([Link]) # ['roll over'] <-- BUG! Buddy learned it too.
print([Link] is [Link]) # True — literally the same object
class GoodDog:
def __init__(self, name):
[Link] = name
[Link] = [] # NEW list per instance
a = GoodDog("Rex")
b = GoodDog("Buddy")
[Link]("roll over")
print([Link]) # [] correct
print([Link] is [Link]) # False
class Circle:
PI = 3.14159265 # constant shared by all
count = 0 # counter across all instances
registry = [] # deliberate shared state
def area(self):
return [Link] * [Link] ** 2
class Broken:
count = 0
def __init__(self):
[Link] += 1 # WRONG
class Employee:
raise_pct = 1.05
count = 0
@property
def email(self):
return f"{[Link]()}.{[Link]()}@[Link]"
@classmethod
def from_string(cls, s): # ALTERNATIVE CONSTRUCTOR
first, last, pay = [Link]("-")
return cls(first, last, int(pay)) # cls, not Employee -> subclass-safe!
@classmethod
def from_dict(cls, d):
return cls(d["first"], d["last"], d["pay"])
e2 = Employee.from_string("Sara-Ahmed-60000")
print([Link]) # [Link]@[Link]
Employee.set_raise_pct(1.10)
print(Employee.raise_pct) # 1.1
class Manager(Employee):
def __init__(self, first, last, pay, reports=None):
super().__init__(first, last, pay)
[Link] = reports or []
m = Manager.from_string("Zara-Ali-90000")
print(type(m)) # <-- correct subclass!
# If from_string had used `return Employee(...)`, we'd get an Employee. Bug.
__new__ vs __init__
__new__ creates the object; __init__ initializes it. You almost never need __new__ — except for immutable types and singletons.
class Demo:
def __new__(cls, *args, **kwargs):
print("1. __new__ called — creating instance")
instance = super().__new__(cls) # actually allocate
return instance # MUST return it
def __del__(self):
print("3. __del__ called — being destroyed")
d = Demo(42)
# 1. __new__ called — creating instance
# 2. __init__ called — initializing
del d
# 3. __del__ called — being destroyed
If __new__ returns an object that is not an instance of cls , __init__ is never called.
class Weird:
def __new__(cls):
return "I am a string" # not a Weird!
def __init__(self):
print("never runs")
class PositiveInt(int):
def __new__(cls, value):
if value <= 0:
raise ValueError("Must be positive")
return super().__new__(cls, value)
# __init__ can't help: int is immutable, value is fixed at creation
p = PositiveInt(5)
print(p + 10) # 15
print(type(p)) #
# PositiveInt(-3) # ValueError: Must be positive
class Singleton:
_instance = None
a = Singleton("first")
b = Singleton("second")
print(a is b) # True
print([Link]) # first (guard prevented re-init)
__del__ caveats
__del__ is not a destructor you can rely on. It runs when the refcount hits zero — which may be never (reference cycles), or at
interpreter shutdown in an unpredictable order. Use context managers for cleanup, not __del__ .
import weakref
class Resource:
def __init__(self, name):
[Link] = name
def close(self):
print(f"closing {[Link]}")
class Config:
def __init__(self, host="localhost", port=8080, **extras):
[Link] = host
[Link] = port
for key, value in [Link]():
setattr(self, key, value) # absorb arbitrary kwargs
def __repr__(self):
return f"Config({self.__dict__})"
Mutable default arguments: defaults are evaluated once, at function definition time.
class BadCart:
def __init__(self, items=[]): # SHARED across all instances!
[Link] = items
a, b = BadCart(), BadCart()
[Link]("apple")
print([Link]) # ['apple'] BUG
class GoodCart:
def __init__(self, items=None):
[Link] = items if items is not None else []
a, b = GoodCart(), GoodCart()
[Link]("apple")
print([Link]) # [] correct
6. Encapsulation and Name Mangling
class Vault:
def __init__(self):
[Link] = "anyone"
self._internal = "please don't"
self.__secret = "mangled"
def reveal(self):
return self.__secret # works fine inside the class
v = Vault()
print([Link]) # anyone
print(v._internal) # please don't — nothing stops you
# print(v.__secret) # AttributeError!
print([Link]()) # mangled
print(v._Vault__secret) # mangled — mangling, not encryption
print([a for a in vars(v)]) # ['public', '_internal', '_Vault__secret']
class Base:
def __init__(self):
self.__value = "base value" # -> self._Base__value
def show_base(self):
return self.__value # -> self._Base__value
class Child(Base):
def __init__(self):
super().__init__()
self.__value = "child value" # -> self._Child__value (NO CLASH)
def show_child(self):
return self.__value
c = Child()
print(c.show_base()) # base value <- unaffected by the child
print(c.show_child()) # child value
print(vars(c)) # {'_Base__value': 'base value', '_Child__value': 'child value'}
class Child2(Base2):
def __init__(self):
super().__init__()
self._value = "child value" # overwrites!
Rule of thumb: use a single underscore _x for "internal". Reserve double underscore __x for when you genuinely need to
protect an attribute from subclass name collisions — mainly in library base classes and mixins.
s = Strict()
[Link] = "red" # __setattr__ storing 'colour'
print([Link]) # __getattr__ fallback for 'colour' -> red
del [Link] # __delattr__ 'colour'
Infinite recursion trap: inside __setattr__ , writing self.x = v calls __setattr__ again forever. Always use
super().__setattr__(name, value) or self.__dict__[name] = value .
class Logged:
def __init__(self):
self.x = 1
l = Logged()
print(l.x)
# accessing 'x'
# 1
__getattr__ __getattribute__
Start simple
class Temp:
def __init__(self, celsius):
[Link] = celsius # plain attribute — perfectly fine!
t = Temp(25)
[Link] = 30 # no validation... yet
class Temperature:
def __init__(self, celsius=0):
[Link] = celsius # goes through the SETTER below
@property
def celsius(self):
"""Getter."""
return self._celsius
@[Link]
def celsius(self, value):
if not isinstance(value, (int, float)):
raise TypeError("Temperature must be numeric")
if value < -273.15:
raise ValueError("Below absolute zero!")
self._celsius = value
@[Link]
def celsius(self):
print("Resetting temperature")
self._celsius = 0
@[Link]
def fahrenheit(self, value):
[Link] = (value - 32) * 5 / 9 # reuses validation
@property
def kelvin(self):
return self._celsius + 273.15 # read-only: no setter
t = Temperature(25)
print([Link]) # 77.0
[Link] = 212
print([Link]) # 100.0
print([Link]) # 373.15
del [Link] # Resetting temperature
# [Link] = 300 # AttributeError: can't set attribute
# [Link] = -300 # ValueError: Below absolute zero!
@property
def area(self):
return [Link] * [Link] # always in sync
@property
def perimeter(self):
return 2 * ([Link] + [Link])
@property
def is_square(self):
return [Link] == [Link]
r = Rectangle(3, 4)
print([Link]) # 12
[Link] = 10
print([Link]) # 40 — recomputed automatically, never stale
class DataSet:
def __init__(self, values):
[Link] = values
@property
def total(self):
print("computing total...")
return sum([Link]) # recomputed every access
@cached_property
def expensive_stat(self):
print("computing expensive stat...")
[Link](0.1)
return sum(x ** 2 for x in [Link])
d = DataSet([1, 2, 3, 4])
print([Link]); print([Link]) # computes TWICE
print(d.expensive_stat) # computes once
print(d.expensive_stat) # cached — instant
del d.expensive_stat # clear the cache
print(d.expensive_stat) # recomputes
cached_property stores the value in the instance __dict__ , so it needs a mutable __dict__ — it does not work with __slots__ . It is
also not thread-safe by default in 3.12+.
class Manual:
def get_x(self):
return self._x
def set_x(self, value):
self._x = value
def del_x(self):
del self._x
m = Manual()
m.x = 5
print(m.x) # 5
print(Manual.x.__doc__) # The x property.
Do not add properties preemptively. Use public attributes until you actually need validation, computation, or logging. Adding
a property later is a non-breaking change.
8. Inheritance
class Animal:
def __init__(self, name, sound="..."):
[Link] = name
[Link] = sound
def speak(self):
return f"{[Link]} says {[Link]}"
def describe(self):
return f"I am {[Link]}, an {type(self).__name__}"
class Dog(Animal):
def __init__(self, name, breed):
super().__init__(name, sound="Woof") # ALWAYS call the parent init
[Link] = breed
class Cat(Animal):
def __init__(self, name):
super().__init__(name, sound="Meow")
def speak(self):
base = super().speak() # reuse parent logic
return base + " (dismissively)"
d = Dog("Rex", "Husky")
c = Cat("Whiskers")
print([Link]()) # Rex the Husky says Woof!
print([Link]()) # Whiskers says Meow (dismissively)
print([Link]()) # I am Rex, an Dog <- inherited, but sees Dog
print([Link]())
Introspecting relationships
type(x) is C vs isinstance(x, C) : prefer isinstance . It respects inheritance (the Liskov principle). Use exact type() checks only
when you deliberately want to exclude subclasses.
Multi-level inheritance
class LivingThing:
def __init__(self, name):
[Link] = name
[Link] = True
class Animal2(LivingThing):
def __init__(self, name, legs):
super().__init__(name)
[Link] = legs
class Dog2(Animal2):
def __init__(self, name, breed):
super().__init__(name, legs=4)
[Link] = breed
d = Dog2("Rex", "Husky")
print([Link], [Link], [Link], [Link]) # Rex 4 Husky True
print([c.__name__ for c in Dog2.__mro__]) # ['Dog2', 'Animal2', 'LivingThing', 'object']
Forgetting super().__init__()
class Broken(Animal):
def __init__(self, name, breed):
[Link] = breed # forgot super().__init__(name)!
b = Broken("Rex", "Husky")
# print([Link]()) # AttributeError: 'Broken' object has no attribute 'name'
def make_all_speak(animals):
for a in animals:
print([Link]()) # breaks on Bird -> TypeError
# GOOD: subclass may accept MORE but must still work with the parent's call
class Bird2(Animal):
def __init__(self, name):
super().__init__(name, sound="Chirp")
def speak(self, volume=5): # optional arg -> still substitutable
return f"{[Link]} chirps at volume {volume}"
Liskov Substitution Principle: anywhere a Parent works, a Child must work too. Subclasses may weaken preconditions
(accept more) and strengthen postconditions (promise more) — never the reverse.
def stretch(rect):
rect.set_width(5)
rect.set_height(4)
assert [Link]() == 20, f"Expected 20, got {[Link]()}"
class Rectangle3(Shape):
def __init__(self, w, h): self.w, self.h = w, h
def area(self): return self.w * self.h
class Square3(Shape):
def __init__(self, side): [Link] = side
def area(self): return [Link] ** 2
Lesson: "IS-A" in the real world does not guarantee "IS-A" in code. The test is behavioural substitutability, not English grammar.
9. Multiple Inheritance and the MRO
Python allows a class to inherit from several parents. The Method Resolution Order (MRO) decides which method wins. It is
computed by the C3 linearization algorithm.
# A
# / \
# B C
# \ /
# D
class A:
def who(self): return "A"
class B(A):
def who(self): return "B"
class C(A):
def who(self): return "C"
d = D()
print([Link]()) # B
print([c.__name__ for c in D.__mro__]) # ['D', 'B', 'C', 'A', 'object']
print([Link]()) # same thing as a list
C3 linearization rules
The MRO satisfies three constraints simultaneously:
A class never appears before any of its subclasses, and every class appears exactly once.
class X: pass
class Y: pass
class P(X, Y): pass
class Q(Y, X): pass
# class Z(P, Q): pass
# TypeError: Cannot create a consistent method resolution order (MRO)
# for bases X, Y
# Why? P demands X before Y; Q demands Y before X. Contradiction.
class LoggerMixin(Base):
def __init__(self, log_level="INFO", **kwargs):
print(f"LoggerMixin.__init__ (level={log_level})")
self.log_level = log_level
super().__init__(**kwargs) # pass the rest along
class TimestampMixin(Base):
def __init__(self, tz="UTC", **kwargs):
print(f"TimestampMixin.__init__ (tz={tz})")
[Link] = tz
super().__init__(**kwargs)
Mixins
A mixin adds one focused capability. It is not meant to be instantiated alone and usually has no __init__ state of its own.
import json
class JSONSerializableMixin:
"""Adds to_json / from_json. Assumes the host has a plain __dict__."""
def to_json(self, **kw):
return [Link](self.__dict__, default=str, **kw)
@classmethod
def from_json(cls, s):
return cls(**[Link](s))
class ComparableMixin:
"""Adds all comparisons given a _sort_key() method."""
def _sort_key(self):
raise NotImplementedError
def __eq__(self, other):
if not isinstance(other, ComparableMixin): return NotImplemented
return self._sort_key() == other._sort_key()
def __lt__(self, other):
if not isinstance(other, ComparableMixin): return NotImplemented
return self._sort_key() < other._sort_key()
def __hash__(self):
return hash(self._sort_key())
class ReprMixin:
def __repr__(self):
args = ", ".join(f"{k}={v!r}" for k, v in self.__dict__.items())
return f"{type(self).__name__}({args})"
Mixin naming/ordering convention: name them SomethingMixin , and list them before the main base class: class C(Mixin1,
Mixin2, Base) . Mixins come first so their methods take precedence in the MRO.
10. super() in Depth
class A:
def hello(self): print("[Link]")
class B(A):
def hello(self):
print("[Link]")
super().hello() # "next after B in type(self).__mro__"
class C(A):
def hello(self):
print("[Link]")
super().hello()
D().hello()
# [Link]
# [Link]
# [Link] <-- !! B's super() went to C, NOT to A
# [Link]
# MRO: D -> B -> C -> A -> object. After B comes C.
B().hello()
# [Link]
# [Link] <-- same B code, different next class. MRO: B -> A -> object
The same line of code ( super().hello() inside B) called A in one case and C in the other. This is why super() enables
cooperative multiple inheritance — and why hardcoding [Link](self) breaks it.
class BBad(A):
def hello(self):
print("[Link]")
[Link](self) # HARDCODED — skips C entirely
DBad().hello()
# [Link]
# [Link]
# [Link] <-- [Link] was SKIPPED. Cooperative chain broken.
class Base:
def greet(self): return "Base"
class Mid(Base):
def greet(self): return "Mid -> " + super().greet()
class Leaf(Mid):
def greet(self): return "Leaf -> " + super().greet()
l = Leaf()
print([Link]()) # Leaf -> Mid -> Base
print(super(Leaf, l).greet()) # Mid -> Base (start after Leaf)
print(super(Mid, l).greet()) # Base (start after Mid)
class Plugin:
registry = {}
print([Link])
# {'csv': , 'jsonplugin': }
__init_subclass__ is an implicit classmethod that runs whenever a subclass is defined. It is the modern, simpler alternative to a
metaclass for auto-registration.
11. Polymorphism and Duck Typing
"If it walks like a duck and quacks like a duck, it's a duck." Python cares about what an object can do, not what it is.
class Duck:
def speak(self): return "Quack"
class Robot:
def speak(self): return "Beep"
class Human:
def speak(self): return "Hello"
EAFP vs LBYL
print(process_eafp(Duck())) # Quack
print(process_eafp(42)) # cannot speak
EAFP is preferred because it is race-free (no gap between check and use) and usually faster in the success case. But keep the
try block tight — a broad block can swallow an AttributeError raised deep inside speak() itself.
class Shape:
def area(self):
raise NotImplementedError(f"{type(self).__name__} must implement area()")
def __repr__(self):
return f"{type(self).__name__}(area={[Link]():.2f})"
class Circle(Shape):
def __init__(self, r): self.r = r
def area(self): return pi * self.r ** 2
class Rectangle(Shape):
def __init__(self, w, h): self.w, self.h = w, h
def area(self): return self.w * self.h
class Triangle(Shape):
def __init__(self, b, h): self.b, self.h = b, h
def area(self): return 0.5 * self.b * self.h
@singledispatch
def describe(obj):
return f"Some object: {obj!r}"
@[Link]
def _(obj: int):
return f"Integer {obj}, {'even' if obj % 2 == 0 else 'odd'}"
@[Link]
def _(obj: str):
return f"String of length {len(obj)}"
@[Link](list)
def _(obj):
return f"List with {len(obj)} items"
@[Link]
def _(self, arg: int):
return f"int: {arg:,}"
@[Link]
def _(self, arg: float):
return f"float: {arg:.2f}"
@[Link](list)
def _(self, arg):
return "[" + ", ".join([Link](x) for x in arg) + "]"
f = Formatter()
print([Link]([1000000, 3.14159, "x"]))
# [int: 1,000,000, float: 3.14, default: x]
12. Abstract Base Classes
ABCs let you declare a contract that subclasses must fulfil, and the check happens at instantiation time rather than at first use.
class Shape:
def area(self):
raise NotImplementedError
class Blob(Shape):
pass
With abc
class Shape(ABC):
def __init__(self, name):
[Link] = name # ABCs CAN have __init__ and state
@abstractmethod
def area(self):
"""Subclass MUST implement this."""
@abstractmethod
def perimeter(self):
...
@property
@abstractmethod # order matters: @property OUTSIDE
def sides(self):
...
@classmethod
@abstractmethod
def unit(cls):
...
class Square(Shape):
def __init__(self, side):
super().__init__("Square")
[Link] = side
def area(self): return [Link] ** 2
def perimeter(self): return 4 * [Link]
@property
def sides(self): return 4
@classmethod
def unit(cls): return cls(1)
s = Square(3)
print([Link]()) # Square: area=9.00, perim=12.00
print([Link]().area()) # 1
class Incomplete(Shape):
def area(self): return 0
# missing perimeter, sides, unit
# Incomplete("x")
# TypeError: Can't instantiate abstract class Incomplete with abstract
# methods perimeter, sides, unit
print(Shape.__abstractmethods__) # frozenset({'area', 'perimeter', 'sides', 'unit'})
ABC only blocks instantiation if there is at least one unimplemented abstract method. A subclass that implements all of them is
instantiable, and an ABC with no abstract methods is instantiable too.
class Serializer(ABC):
@abstractmethod
def serialize(self, data): ...
t = ThirdPartyJSON()
print(isinstance(t, Serializer)) # True !
print(issubclass(ThirdPartyJSON, Serializer)) # True
print(ThirdPartyJSON.__mro__) # (ThirdPartyJSON, object) — NOT inherited
# NOTE: registration is a promise. Python does NOT verify serialize exists.
class Drawable(ABC):
@abstractmethod
def draw(self): ...
@classmethod
def __subclasshook__(cls, C):
if cls is Drawable:
if any("draw" in B.__dict__ for B in C.__mro__):
return True # anything with draw() counts
return NotImplemented # fall back to normal rules
class Playlist(Sequence):
"""Implement __len__ + __getitem__; get the rest FREE."""
def __init__(self, songs):
self._songs = list(songs)
def __len__(self):
return len(self._songs)
def __getitem__(self, i):
return self._songs[i]
Container __contains__ —
Iterable __iter__ —
Sized __len__ —
MutableSequence + __setitem__ , __delitem__ , insert + append , extend , pop , remove , __iadd__ , reverse
Mapping __getitem__ , __len__ , __iter__ get , keys , items , values , __contains__ , __eq__
class CaseInsensitiveDict(MutableMapping):
def __init__(self, data=None):
self._store = {}
if data: [Link](data) # update() is FREE from the ABC
d = CaseInsensitiveDict({"Content-Type": "text/html"})
print(d["content-type"]) # text/html
print(d["CONTENT-TYPE"]) # text/html
print("Content-TYPE" in d) # True <- free
print([Link]("missing", "-"))# - <- free
print(list([Link]())) # ['Content-Type'] original casing preserved
print(d)
13. Protocols and Structural Typing
ABCs are nominal typing — you must inherit or register. [Link] (PEP 544) gives structural typing: matching shape is enough.
It's "static duck typing".
class Flyer(Protocol):
def fly(self) -> str: ...
altitude: int # attributes can be part of the protocol
class Plane:
def __init__(self):
[Link] = 30000
def fly(self) -> str:
return "jet engines"
def launch(f: Flyer) -> None: # type checker verifies the shape
print(f"{[Link]()} at {[Link]}ft")
Runtime-checkable protocols
@runtime_checkable
class Closeable(Protocol):
def close(self) -> None: ...
class F:
def close(self): print("closed")
ABC Protocol
Best for Your own class hierarchies with shared code Interfaces for code you don't own; loose coupling
from typing import Protocol
# Generic protocol
from typing import TypeVar
T = TypeVar("T")
class Repository(Protocol[T]):
def get(self, id: int) -> T | None: ...
def save(self, item: T) -> None: ...
def delete(self, id: int) -> bool: ...
class User:
def __init__(self, id, name): [Link], [Link] = id, name
repo = InMemoryUserRepo()
register_user(repo, User(1, "Ali"))
print([Link](1).name) # Ali
14. Dunder (Magic) Methods: The Full Tour
Object representation
class Point:
def __init__(self, x, y):
self.x, self.y = x, y
def __repr__(self):
# GOAL: eval(repr(obj)) == obj. For developers/debuggers.
return f"Point({self.x!r}, {self.y!r})"
def __str__(self):
# For end users. Falls back to __repr__ if not defined.
return f"({self.x}, {self.y})"
def __bytes__(self):
return f"{self.x},{self.y}".encode()
p = Point(3, 4)
print(str(p)) # (3, 4)
print(repr(p)) # Point(3, 4)
print(p) # (3, 4) — print uses __str__
print([p]) # [Point(3, 4)] — containers use __repr__!
print(f"{p}") # (3, 4) — f-string uses __format__/__str__
print(f"{p!r}") # Point(3, 4) — !r forces __repr__
print(f"{p:.2f}") # (3.00, 4.00)
print(f"{p:polar}") # 5.00∠53.1°
print(bytes(p)) # b'3,4'
Always define __repr__ . It appears in debuggers, logs, tracebacks, and inside lists/dicts. __str__ is optional — it defaults to
__repr__ .
class Cart:
def __init__(self, items=None):
[Link] = items or []
def __len__(self):
return len([Link])
def __bool__(self):
# If __bool__ is absent, Python uses __len__ != 0
# If both absent, every object is truthy
return len([Link]) > 0
c = Cart()
print(bool(c), len(c)) # False 0
if not c: print("Cart empty")
[Link]("apple")
print(bool(c)) # True
Subtle bug: if you define __len__ but the object can legitimately be "empty yet meaningful" (e.g. an empty Response ), if
response: becomes False unexpectedly. Define __bool__ explicitly to control this.
Comparison methods
from functools import total_ordering
def _key(self):
return ([Link], [Link], [Link])
def __hash__(self):
return hash(self._key()) # needed: defining __eq__ kills hash
def __repr__(self):
return f"v{[Link]}.{[Link]}.{[Link]}"
Return NotImplemented , not False , for unknown types. NotImplemented tells Python to try the reflected operation on the other
operand ( other.__gt__(self) ). Returning False silently claims "definitely not equal" and breaks interop.
== __eq__ __eq__
Container methods
class Matrix:
def __init__(self, rows):
[Link] = rows
def __iter__(self):
return iter([Link])
def __reversed__(self):
return reversed([Link])
def __repr__(self):
return "\n".join(str(r) for r in [Link])
m = Matrix([[1,2,3],[4,5,6],[7,8,9]])
print(m[1]) # [4, 5, 6]
print(m[1, 2]) # 6
m[0, 0] = 99
print(m[0]) # [99, 2, 3]
print(5 in m) # True
print(m[0:2]) # first two rows as a new Matrix
for row in m: pass
Callable objects
class Multiplier:
def __init__(self, factor):
[Link] = factor
def __call__(self, x):
return x * [Link]
double = Multiplier(2)
print(double(5)) # 10
print(callable(double)) # True
print(list(map(double, [1, 2, 3]))) # [2, 4, 6]
c = Counter()
c(); c(); c()
print([Link]) # 3
Hashing
# RULE: if a == b then hash(a) MUST == hash(b)
# RULE: mutable objects should NOT be hashable
class Immutable:
def __init__(self, x, y):
object.__setattr__(self, "x", x) # bypass our own __setattr__
object.__setattr__(self, "y", y)
def __setattr__(self, *args):
raise AttributeError("Immutable")
def __eq__(self, other):
return isinstance(other, Immutable) and (self.x, self.y) == (other.x, other.y)
def __hash__(self):
return hash((self.x, self.y))
class Mutable:
def __init__(self, x): self.x = x
def __eq__(self, other): return self.x == other.x
# __hash__ is set to None automatically when __eq__ is defined
print(Mutable.__hash__) # None
# {Mutable(1)} # TypeError: unhashable type: 'Mutable'
Defining __eq__ automatically sets __hash__ = None , making the class unhashable. If you want it hashable, define __hash__
explicitly. This is deliberate: mutable objects that change their equality would corrupt dicts and sets.
b = Bad(1)
s = {b}
b.x = 2 # hash changed while in the set
print(b in s) # False !! The object is lost inside its own set.
print(list(s)[0].x) # 2 — it's still there, just unfindable
15. Operator Overloading
abs(x) __abs__ — —
round() __round__ — —
class Vector:
def __init__(self, *components):
self.c = tuple(components)
def __repr__(self):
return f"Vector{self.c}"
def normalized(self):
mag = abs(self)
if mag == 0: raise ValueError("Cannot normalize zero vector")
return self / mag
v1 = Vector(1, 2, 3)
v2 = Vector(4, 5, 6)
print(v1 + v2) # Vector(5, 7, 9)
print(v2 - v1) # Vector(3, 3, 3)
print(v1 * 3) # Vector(3, 6, 9)
print(3 * v1) # Vector(3, 6, 9) <- __rmul__ saved us
print(v1 * v2) # 32 <- dot product
print(v1 @ v2) # 32
print(-v1) # Vector(-1, -2, -3)
print(abs(Vector(3, 4))) # 5.0
print(round([Link](), 3)) # Vector(0.267, 0.535, 0.802)
print(bool(Vector(0, 0))) # False
print(v1 + 5) # TypeError: unsupported operand type(s)
1. If type(b) is a proper subclass of type(a) and overrides __radd__ → try b.__radd__(a) first.
2. Try a.__add__(b) . If it returns anything except NotImplemented , done.
3. Try b.__radd__(a) . If not NotImplemented , done.
4. Raise TypeError: unsupported operand type(s) for + .
class Money:
def __init__(self, amount, currency="PKR"):
[Link], [Link] = amount, currency
def __repr__(self): return f"{[Link]:.2f} {[Link]}"
In-place operators
class Basket:
def __init__(self, items=None):
[Link] = list(items or [])
def __repr__(self): return f"Basket({[Link]})"
a = Basket([1, 2])
b = Basket([3])
c = a
a += b
print(a) # Basket([1, 2, 3])
print(c) # Basket([1, 2, 3]) <- same object mutated!
print(a is c) # True
x = Basket([1])
y = x
x = x + Basket([2]) # __add__ creates a new object
print(y) # Basket([1]) <- unchanged
print(x is y) # False
__iadd__ must return self (or a new object). If it returns None , then a += b sets a = None . If __iadd__ is not defined, Python
falls back to a = a + b using __add__ .
16. Composition vs Inheritance
"Favor composition over inheritance." Inheritance is IS-A and couples you to the parent's implementation forever.
Composition is HAS-A and can be swapped at runtime.
Inheritance abuse
c = Car()
print([Link]()) # works, but now Car IS-A Engine — nonsense.
# You can never have an ElectricCar with a different engine.
class ElectricEngine:
def start(self): return "Silent hum"
def fuel_type(self): return "electricity"
class Car:
def __init__(self, brand, engine):
[Link] = brand
[Link] = engine # composition / dependency injection
def start(self):
return f"{[Link]}: {[Link]()}"
c = Car("Toyota", PetrolEngine())
print([Link]()) # Toyota: Vroom vroom
c.swap_engine(ElectricEngine())
print([Link]()) # Toyota: Silent hum
class Addon:
def __init__(self, beverage, name, extra):
[Link], [Link], [Link] = beverage, name, extra
def cost(self): return [Link]() + [Link]
def description(self): return f"{[Link]()} + {[Link]}"
lst = Logger([])
[Link](1) # [LOG] append((1,))
[Link](2) # [LOG] append((2,))
print(lst._wrapped) # [1, 2]
print([Link]) # ['append', 'append']
Decision table
import sys
class Normal:
def __init__(self, x, y):
self.x, self.y = x, y
class Slotted:
__slots__ = ("x", "y") # declare ALL attributes up front
def __init__(self, x, y):
self.x, self.y = x, y
n.z = 3 # fine
# s.z = 3 # AttributeError: 'Slotted' object has no attribute 'z'
Benefit Cost
class Base:
__slots__ = ("a",)
class Child(Base):
pass # NO __slots__ -> Child gets a __dict__ back!
c = Child()
[Link] = 1 # works — slots benefit LOST
print(hasattr(c, "__dict__")) # True
class Child2(Base):
__slots__ = ("b",) # only NEW attributes; don't repeat 'a'
c2 = Child2()
c2.a, c2.b = 1, 2
# c2.c = 3 # AttributeError — benefit preserved
print(Child2.__slots__) # ('b',)
Every class in the chain must define __slots__ , or a __dict__ reappears and all savings vanish. Also: never repeat a parent's
slot name in a child — it wastes space and hides the parent's descriptor.
# Multiple inheritance: you can't have two parents with NONEMPTY slots
class A: __slots__ = ("x",)
class B: __slots__ = ("y",)
# class C(A, B): __slots__ = ()
# TypeError: multiple bases have instance lay-out conflict
Use __slots__ when: you create thousands/millions of instances, or want strict attribute control (value objects, records, graph
nodes). Skip it for ordinary application classes — it's a micro-optimization that costs flexibility.
18. Descriptors
A descriptor is any object defining __get__ , __set__ , or __delete__ . Descriptors power property , classmethod , staticmethod , and
bound methods. They are the low-level mechanism behind attribute access.
The protocol
A validating descriptor
class Validated:
def __set_name__(self, owner, name):
# Called automatically when the class body is created.
# Tells the descriptor which attribute name it was assigned to.
self.public_name = name
self.private_name = "_" + name
class Integer(Validated):
def __init__(self, minvalue=None, maxvalue=None):
[Link], [Link] = minvalue, maxvalue
def validate(self, value):
if not isinstance(value, int):
raise TypeError(f"{self.public_name} must be an int, got {type(value).__name__}")
if [Link] is not None and value < [Link]:
raise ValueError(f"{self.public_name} must be >= {[Link]}")
if [Link] is not None and value > [Link]:
raise ValueError(f"{self.public_name} must be <= {[Link]}")
class String(Validated):
def __init__(self, minlen=0, maxlen=None, predicate=None):
[Link], [Link], [Link] = minlen, maxlen, predicate
def validate(self, value):
if not isinstance(value, str):
raise TypeError(f"{self.public_name} must be a str")
if len(value) < [Link]:
raise ValueError(f"{self.public_name} too short (min {[Link]})")
if [Link] and len(value) > [Link]:
raise ValueError(f"{self.public_name} too long (max {[Link]})")
if [Link] and not [Link](value):
raise ValueError(f"{self.public_name} failed validation")
class OneOf(Validated):
def __init__(self, *options):
[Link] = set(options)
def validate(self, value):
if value not in [Link]:
raise ValueError(f"{self.public_name} must be one of {[Link]}")
# ---- Reuse the validators across ANY class. Zero duplication. ----
class Person:
name = String(minlen=2, maxlen=50, predicate=[Link])
age = Integer(minvalue=0, maxvalue=150)
kind = OneOf("employee", "contractor", "intern")
def __repr__(self):
return f"Person({[Link]!r}, {[Link]}, {[Link]!r})"
Why descriptors beat properties here: with @property you'd write ~8 lines of getter/setter per attribute, per class. The
descriptor is written once and reused everywhere. This is exactly how Django fields, SQLAlchemy columns, and Pydantic work
internally.
class Data:
def __get__(self, obj, objtype=None):
return "from data descriptor"
def __set__(self, obj, value):
print(f"data descriptor intercepted set of {value}")
obj.__dict__["_hidden"] = value
class Demo:
nd = NonData()
d = Data()
x = Demo()
print([Link]) # from non-data descriptor
x.__dict__["nd"] = "instance value"
print([Link]) # instance value <- instance dict WINS
class LazyProperty:
"""Compute once, then let the instance dict take over — zero overhead after."""
def __init__(self, func):
[Link] = func
[Link] = func.__name__
self.__doc__ = func.__doc__
class Config:
def __init__(self, path):
[Link] = path
@LazyProperty
def settings(self):
return {"db": "postgres", "debug": True} # imagine a slow file read
c = Config("[Link]")
print([Link]) # computing settings... then the dict
print([Link]) # instant — descriptor bypassed entirely
print(c.__dict__) # {'path': '[Link]', 'settings': {...}}
import types
class Function:
"""A simplified reimplementation of how Python functions become methods."""
def __init__(self, func):
[Link] = func
def __get__(self, obj, objtype=None):
if obj is None:
return [Link] # [Link] -> plain function
return [Link]([Link], obj) # [Link] -> bound method
class MyClass:
def greet(self):
return f"hello from {self}"
greet = Function(greet)
m = MyClass()
print(type([Link])) #
print(type([Link])) #
# THIS is why self is passed automatically. Functions are non-data descriptors.
class MyStaticMethod:
def __init__(self, f): self.f = f
def __get__(self, obj, objtype=None):
return self.f # no binding at all
class MyClassMethod:
def __init__(self, f): self.f = f
def __get__(self, obj, objtype=None):
if objtype is None: objtype = type(obj)
return [Link](self.f, objtype) # bind to the CLASS
class T:
_v = 1
@MyProperty
def v(self): return self._v
@[Link]
def v(self, val): self._v = val * 2
@MyStaticMethod
def s(a, b): return a + b
@MyClassMethod
def c(cls): return cls.__name__
t = T()
t.v = 5
print(t.v) # 10
print(T.s(1, 2)) # 3
print(t.c()) # T
19. Metaclasses
"Metaclasses are deeper magic than 99% of users should ever worry about. If you wonder whether you need them,
you don't." — Tim Peters
Before reaching for a metaclass, check whether __init_subclass__ , __set_name__ , a class decorator, or a descriptor solves it. They
usually do.
d = Dog2()
print([Link](), [Link]) # Woof canine
print(Dog2.__name__) # Dog2
# With inheritance:
Puppy = type("Puppy", (Dog2,), {"size": "small"})
print(Puppy().bark(), Puppy.__mro__)
Writing a metaclass
class Meta(type):
def __prepare__(name, bases, **kwargs):
# 1. Returns the namespace mapping used for the class body.
# Return an OrderedDict, a logging dict, etc.
print(f"1. __prepare__ for {name}")
return {}
class MyClass(metaclass=Meta):
x = 1
def method(self): pass
# 1. __prepare__ for MyClass
# 2. __new__ creating MyClass, members: ['__module__', '__qualname__', 'x', 'method']
# 3. __init__ for MyClass
obj = MyClass()
# 4. __call__ — making an instance of MyClass
class Plugin(metaclass=PluginMeta):
def run(self): raise NotImplementedError
class EmailPlugin(Plugin):
def run(self): return "sending email"
class SMSPlugin(Plugin):
def run(self): return "sending sms"
print([Link])
# {'emailplugin': , 'smsplugin': }
print([Link]["smsplugin"]().run()) # sending sms
class InterfaceMeta(type):
required = ("save", "load")
class GoodStore(Storage):
def save(self, x): pass
def load(self, k): pass
# class BadStore(Storage):
# def save(self, x): pass
# TypeError: BadStore is missing: load <- fails at IMPORT time!
class SingletonMeta(type):
_instances = {}
def __call__(cls, *args, **kwargs):
if cls not in cls._instances:
cls._instances[cls] = super().__call__(*args, **kwargs)
return cls._instances[cls]
class Database(metaclass=SingletonMeta):
def __init__(self, url):
print(f"Connecting to {url}")
[Link] = url
Metaclass advantage over __new__ -based singletons: because __call__ short-circuits before __init__ , the initializer
genuinely runs only once. No _ready guard needed.
class Form(metaclass=OrderedMeta):
username = "text"
password = "password"
email = "email"
class StrictMeta(type):
@classmethod
def __prepare__(mcs, name, bases, **kw):
return NoDupDict()
def __new__(mcs, name, bases, ns):
return super().__new__(mcs, name, bases, dict(ns))
# class Oops(metaclass=StrictMeta):
# def run(self): pass
# def run(self): pass # TypeError: Duplicate definition of 'run'
Metaclass conflicts
@add_repr
class P:
def __init__(self, x): self.x = x
print(P(1)) # P(x=1)
20. Dataclasses
@dataclass
class Point:
x: int
y: int
z: int = 0 # default
p = Point(1, 2)
print(p) # Point(x=1, y=2, z=0)
print(p == Point(1, 2)) # True
print(p.x) # 1
# __init__, __repr__, __eq__ all generated.
@dataclass(
init=True, # generate __init__ (default True)
repr=True, # generate __repr__ (default True)
eq=True, # generate __eq__ (default True)
order=False, # generate __lt__ __le__ __gt__ __ge__
unsafe_hash=False, # force __hash__ generation
frozen=False, # immutable: block __setattr__/__delattr__
match_args=True, # generate __match_args__ for structural pattern matching
kw_only=False, # ALL fields become keyword-only (3.10+)
slots=False, # generate __slots__ (3.10+)
weakref_slot=False, # add __weakref__ to slots (3.11+)
)
class Config:
name: str
value: int = 0
@dataclass(frozen=True)
class Coordinate:
lat: float
lon: float
c = Coordinate(24.86, 67.01)
# [Link] = 25.0 # FrozenInstanceError: cannot assign to field 'lat'
print(hash(c)) # frozen + eq -> __hash__ generated automatically
print({c: "Karachi"}) # usable as a dict key
print({Coordinate(1,2), Coordinate(1,2)}) # deduplicated — one element
# Frozen is SHALLOW:
@dataclass(frozen=True)
class Box:
items: list = field(default_factory=list)
b = Box()
[Link]("x") # ALLOWED — the list itself is still mutable
print(b) # Box(items=['x'])
@dataclass
class Item:
name: str
# default_factory: for MUTABLE defaults (a bare [] raises ValueError)
tags: list[str] = field(default_factory=list)
meta: dict = field(default_factory=dict)
id: str = field(default_factory=lambda: str(uuid.uuid4())[:8])
def __post_init__(self):
# Runs right after the generated __init__ — validation & derived fields
[Link] = [Link]().replace(" ", "-")
if [Link] < 0:
raise ValueError("price cannot be negative")
[Link] += 1
tags: list = [] raises ValueError: mutable default . Dataclasses detect this classic bug and force you to use default_factory .
This is one of the best things about them.
Ordering
@dataclass(order=True)
class Employee:
# Comparison uses fields IN DECLARATION ORDER, like a tuple
salary: int
name: str = field(compare=False) # exclude name from comparison
Inheritance rules
@dataclass
class Base:
a: int
b: int = 2
@dataclass
class Child(Base):
c: int = 3 # fields APPEND: __init__(self, a, b=2, c=3)
Utility functions
@dataclass
class Inner:
v: int
@dataclass
class Outer:
name: str
inner: Inner
items: list
print(is_dataclass(o)) # True
print([[Link] for f in fields(Outer)]) # ['name', 'inner', 'items']
Slots + dataclass
p = FastPoint(1.0, 2.0)
print(hasattr(p, "__dict__")) # False — memory efficient AND immutable
# NOTE: slots=True creates a NEW class object, so any code holding a
# reference to the pre-decoration class (e.g. in a closure) sees the old one.
Dataclass vs alternatives
Type hints in dataclasses are NOT enforced. Point("hello", "world") works fine at runtime. Use __post_init__ , descriptors,
or Pydantic if you need real validation.
21. NamedTuple and Enum
NamedTuple
class Point(NamedTuple):
x: int
y: int
label: str = "origin"
@property
def quadrant(self) -> int:
if self.x > 0 and self.y > 0: return 1
if self.x < 0 and self.y > 0: return 2
if self.x < 0 and self.y < 0: return 3
return 4
p = Point(3, 4)
print(p) # Point(x=3, y=4, label='origin')
print(p.x, p[0]) # 3 3 — attribute AND index access
x, y, label = p # unpackable
print(p.distance_from_origin())# 5.0
print(p._replace(x=10)) # Point(x=10, y=4, label='origin')
print(p._asdict()) # {'x': 3, 'y': 4, 'label': 'origin'}
print(Point._fields) # ('x', 'y', 'label')
print(Point._make([1, 2, "a"]))# Point(x=1, y=2, label='a')
# p.x = 5 # AttributeError: can't set attribute
# Because it IS a tuple:
print(p == (3, 4, "origin")) # True <- compares equal to a plain tuple!
print(hash(p) == hash((3, 4, "origin"))) # True
print(isinstance(p, tuple)) # True
Enum
class Color(Enum):
RED = 1
GREEN = 2
BLUE = 3
print([Link]) # [Link]
print([Link]) # RED
print([Link]) # 1
print(Color(1)) # [Link] — lookup by value
print(Color["RED"]) # [Link] — lookup by name
print(list(Color)) # [, , ]
print([Link] is Color(1)) # True — enum members are singletons
print([Link] == 1) # False! Plain Enum is NOT its value
class Alias(Enum):
OK = 1
FINE = 1 # alias for OK, NOT a new member
print([Link] is [Link]) # True
print(list(Alias)) # [] — aliases hidden
print(Alias.__members__) # includes both names
@property
def surface_gravity(self):
G = 6.67300E-11
return G * [Link] / ([Link] ** 2)
def __str__(self):
return f"{[Link]()} (g={self.surface_gravity:.2f})"
for p in Planet:
print(p)
# Mercury (g=3.70)
# Earth (g=9.80)
p = [Link] | [Link]
print(p) # [Link]|WRITE
print([Link] in p) # True
print([Link] in p) # False
print(p & [Link]) # [Link]
print(~p) # [Link]
print([Link]) # [Link]|WRITE|EXEC
class OrderState(Enum):
DRAFT = "draft"
PAID = "paid"
SHIPPED = "shipped"
CANCELLED = "cancelled"
class Order:
def __init__(self):
[Link] = [Link]
o = Order()
[Link]([Link]).transition([Link])
print([Link]) # [Link]
# [Link]([Link]) # ValueError: Cannot go shipped -> draft
22. Context Managers
The protocol
class ManagedFile:
def __init__(self, filename, mode="r"):
[Link], [Link] = filename, mode
[Link] = None
def __enter__(self):
print(f"Opening {[Link]}")
[Link] = open([Link], [Link])
return [Link] # this is what `as f` receives
class Inspector:
def __enter__(self):
return self
def __exit__(self, exc_type, exc_value, tb):
print(f"type = {exc_type}")
print(f"value = {exc_value}")
print(f"tb = {tb is not None}")
return False
with Inspector():
pass
# type = None / value = None / tb = False <- clean exit
try:
with Inspector():
raise ValueError("boom")
except ValueError:
print("caught outside")
# type = / value = boom / tb = True
# caught outside
Suppressing exceptions
class Suppress:
def __init__(self, *exceptions):
[Link] = exceptions
def __enter__(self):
return self
def __exit__(self, exc_type, exc_value, tb):
return exc_type is not None and issubclass(exc_type, [Link])
with Suppress(ZeroDivisionError):
print(1 / 0)
print("never reached")
print("continues normally")
Returning a truthy value from __exit__ silently swallows exceptions. Do this only when suppression is the explicit purpose of the
manager. Returning True by accident (e.g. from a print() ... no; from a stray return [Link] ) hides real bugs.
@contextmanager
def timer(label="block"):
start = time.perf_counter()
try:
yield start # everything before yield = __enter__
finally:
elapsed = time.perf_counter() - start # after yield = __exit__
print(f"{label} took {elapsed:.4f}s") # finally -> runs on exception too
with timer("sum"):
sum(range(1_000_000))
# sum took 0.0234s
@contextmanager
def transaction(conn):
try:
yield conn
[Link]()
except Exception:
[Link]()
raise # re-raise after cleanup
finally:
[Link]()
Always wrap the yield in try/finally. Without it, an exception in the with body skips your cleanup code entirely.
@contextmanager
def broken():
print("setup")
yield
print("cleanup") # NEVER RUNS if the body raises!
try:
with broken():
raise ValueError()
except ValueError:
pass
# setup <- 'cleanup' never printed
class NotReusable:
def __init__(self, name): [Link] = name
def __enter__(self):
self.f = open("/tmp/x", "w")
return self.f
def __exit__(self, *a):
[Link]()
cm = NotReusable("a")
with cm: pass
with cm: pass # works here, but many CMs (e.g. generator-based) do NOT
@contextmanager
def once():
yield
c = once()
with c: pass
# with c: pass # RuntimeError: generator didn't yield
# Generator-based context managers are SINGLE-USE.
import asyncio
class AsyncResource:
async def __aenter__(self):
print("async acquiring")
await [Link](0.01)
return self
async def __aexit__(self, exc_type, exc, tb):
print("async releasing")
await [Link](0.01)
return False
[Link](main())
# async acquiring / using / async releasing
Iterable vs Iterator
Iterable Iterator
lst = [1, 2, 3]
it = iter(lst)
print(type(it)) #
print(next(it), next(it), next(it)) # 1 2 3
# next(it) # StopIteration
print(iter(it) is it) # True — an iterator's __iter__ returns itself
for x in lst: pass # each `for` calls iter(lst) -> a FRESH iterator
for x in lst: pass # so lists can be looped many times
class Countdown:
def __init__(self, start):
[Link] = start
def __iter__(self):
# Returns a NEW iterator each time -> reusable
return CountdownIterator([Link])
class CountdownIterator:
def __init__(self, current):
[Link] = current
def __iter__(self):
return self # iterators must return themselves
def __next__(self):
if [Link] <= 0:
raise StopIteration # the ONLY way to stop
[Link] -= 1
return [Link] + 1
c = Countdown(3)
print(list(c)) # [3, 2, 1]
print(list(c)) # [3, 2, 1] — reusable!
b = BadCountdown(3)
print(list(b)) # [3, 2, 1]
print(list(b)) # [] <- EXHAUSTED. Common source of bugs.
class Countdown2:
def __init__(self, start):
[Link] = start
def __iter__(self):
n = [Link]
while n > 0:
yield n # a generator function IS an iterator factory
n -= 1
c = Countdown2(3)
print(list(c), list(c)) # [3, 2, 1] [3, 2, 1] — reusable, 5 lines
# Python calls __iter__ fresh each time -> a new generator each time.
g = echo()
next(g) # starting — must "prime" the generator
[Link]("hello") # got: hello
[Link]("world") # got: world
[Link]() # closing down / cleanup
def resilient():
while True:
try:
x = yield
print(f"processing {x}")
except ValueError as e:
print(f"recovered from: {e}")
r = resilient(); next(r)
[Link](1) # processing 1
[Link](ValueError("bad")) # recovered from: bad
[Link](2) # processing 2 — still alive
class Tree:
def __init__(self, value, children=None):
[Link] = value
[Link] = children or []
def __iter__(self):
yield [Link]
for child in [Link]:
yield from child # delegates — handles nesting elegantly
def __iter__(self):
it = iter([Link])
for kind, arg in [Link]:
if kind == "map": it = (arg(x) for x in it)
elif kind == "filter": it = (x for x in it if arg(x))
elif kind == "take":
it = self._take(it, arg)
return it
@staticmethod
def _take(it, n):
for i, x in enumerate(it):
if i >= n: return
yield x
def naturals():
n = 1
while True:
yield n
n += 1
p = (DataPipeline(naturals())
.filter(lambda x: x % 3 == 0)
.map(lambda x: x ** 2)
.take(5))
print(list(p)) # [9, 36, 81, 144, 225]
# Infinite source, constant memory, nothing computed until iterated.
24. Class Decorators
A class decorator is a function that takes a class and returns a class (usually the same one, modified). It is the simplest tool for cross-
cutting concerns — much lighter than a metaclass.
import functools
def auto_repr(cls):
"""Add a __repr__ built from the instance __dict__."""
def __repr__(self):
args = ", ".join(f"{k}={v!r}" for k, v in vars(self).items())
return f"{cls.__name__}({args})"
cls.__repr__ = __repr__
return cls # MUST return the class
@auto_repr
class User:
def __init__(self, name, email):
[Link], [Link] = name, email
def add_methods(**methods):
"""Inject arbitrary methods into a class."""
def decorator(cls):
for name, func in [Link]():
setattr(cls, name, func)
return cls
return decorator
@add_methods(
shout=lambda self: [Link](),
initials=lambda self: "".join(w[0] for w in [Link]()),
)
class Person:
def __init__(self, name): [Link] = name
def log_methods(prefix="LOG"):
def decorator(cls):
for name, attr in list(vars(cls).items()):
if callable(attr) and not [Link]("__"):
setattr(cls, name, _wrap(attr, cls.__name__, prefix))
return cls
return decorator
@log_methods("TRACE")
class Calculator:
def add(self, a, b): return a + b
def mul(self, a, b): return a * b
c = Calculator()
[Link](2, 3) # [TRACE] [Link](2, 3) -> 5 (0.00ms)
[Link](4, 5) # [TRACE] [Link](4, 5) -> 20 (0.00ms)
Always use [Link] when wrapping. Without it the wrapper loses __name__ , __doc__ , __module__ , and __wrapped__ ,
which breaks introspection, help() , and debuggers.
Registration decorators
class Registry:
_handlers = {}
@classmethod
def register(cls, *event_types):
def decorator(handler_cls):
for et in event_types:
cls._handlers[et] = handler_cls
handler_cls.handles = event_types
return handler_cls
return decorator
@classmethod
def dispatch(cls, event_type, payload):
handler = cls._handlers.get(event_type)
if not handler:
raise KeyError(f"No handler for {event_type!r}")
return handler().handle(payload)
@[Link]("[Link]", "[Link]")
class UserHandler:
def handle(self, payload): return f"user handled: {payload}"
@[Link]("[Link]")
class OrderHandler:
def handle(self, payload): return f"order handled: {payload}"
Applies to Only the decorated class All subclasses, automatically All subclasses, automatically
import copy
class Node:
def __init__(self, value, children=None):
[Link] = value
[Link] = children or []
def __repr__(self):
return f"Node({[Link]}, {[Link]})"
[Link](Node("new"))
print([Link]) # [Node(child), Node(new)] — same object
print([Link]) # [Node(child), Node(new)] — SHARED list!
print([Link]) # [Node(child)] — independent
import copy
class Connection:
def __init__(self, host, cache=None):
[Link] = host
[Link] = cache or {}
[Link] = f"" # NOT copyable
def __copy__(self):
cls = self.__class__
new = cls.__new__(cls) # bypass __init__
new.__dict__.update(self.__dict__) # shallow field copy
return new
def __repr__(self):
return f"Connection({[Link]}, cache={[Link]}, {[Link]})"
The memo dict maps id(original) -> copy . It prevents infinite recursion on cyclic structures and ensures an object referenced
twice is copied once.
Pickling
import pickle
class Session:
def __init__(self, user, token):
[Link] = user
[Link] = token # sensitive — don't persist
[Link] = object() # unpicklable resource
def __getstate__(self):
"""Return what to pickle."""
state = self.__dict__.copy()
del state["token"] # exclude secrets
del state["connection"] # exclude unpicklable objects
return state
def __repr__(self):
return f"Session(user={[Link]!r}, token={[Link]!r})"
s = Session("ali", "secret123")
data = [Link](s)
restored = [Link](data)
print(restored) # Session(user='ali', token=None)
print(b"secret123" in data) # False — secret was excluded
NEVER unpickle untrusted data. __reduce__ can execute arbitrary code — [Link] on hostile input is remote code
execution. Use JSON for data you don't control.
m = Meters(5)
print(m == 5) # True
print(5 == m) # True — int.__eq__ returns NotImplemented, Python tries
# the reflected m.__eq__(5). Symmetric by luck here.
The hierarchy
BaseException
├── SystemExit <- [Link]()
├── KeyboardInterrupt <- Ctrl+C
├── GeneratorExit <- [Link]()
└── Exception <- CATCH THIS, not BaseException
├── ArithmeticError -> ZeroDivisionError, OverflowError
├── LookupError -> IndexError, KeyError
├── OSError -> FileNotFoundError, PermissionError, TimeoutError
├── RuntimeError -> RecursionError, NotImplementedError
├── ValueError -> UnicodeError
├── TypeError
├── AttributeError
├── StopIteration
└── ...
except Exception catches almost everything. except BaseException or a bare except: also swallows Ctrl+C and SystemExit ,
making your program unkillable. Never use a bare except: .
class AppError(Exception):
"""Base for ALL errors in this app — lets callers catch everything of ours."""
class ValidationError(AppError):
def __init__(self, field, value, message=None):
[Link] = field
[Link] = value
[Link] = message or f"Invalid value for {field!r}: {value!r}"
super().__init__([Link]) # sets args, powers str()
def __str__(self):
return [Link]
def to_dict(self):
return {"error": "validation", "field": [Link], "message": [Link]}
class NotFoundError(AppError):
def __init__(self, resource, id):
[Link], [Link] = resource, id
super().__init__(f"{resource} with id {id} not found")
class RateLimitError(AppError):
def __init__(self, retry_after):
self.retry_after = retry_after
super().__init__(f"Rate limited; retry after {retry_after}s")
try:
raise ValidationError("email", "not-an-email")
except ValidationError as e:
print(e) # Invalid value for 'email': 'not-an-email'
print([Link]) # email
print(e.to_dict())
except AppError:
print("some other app error") # catches the whole family
Always define one base exception per package/app. It lets consumers write except AppError to catch everything you raise,
without accidentally catching unrelated ValueError s from the stdlib.
Exception chaining
class DatabaseError(AppError): pass
def fetch_user(uid):
try:
return {1: "Ali"}[uid]
except KeyError as e:
# `from e` sets __cause__ -> "The above exception was the direct cause"
raise NotFoundError("User", uid) from e
try:
fetch_user(99)
except NotFoundError as e:
print(e) # User with id 99 not found
print(repr(e.__cause__)) # KeyError(99)
print(e.__context__) # 99 — set automatically even without `from`
try: parse("abc")
except ValidationError as e: print(e.__cause__) # None
def validate_all(data):
errors = []
for field, value in [Link]():
try:
if not value:
raise ValidationError(field, value, f"{field} is required")
except ValidationError as e:
[Link](e)
if errors:
raise ExceptionGroup("Validation failed", errors)
try:
validate_all({"name": "", "email": "", "age": 30})
except* ValidationError as eg:
for e in [Link]:
print(f"- {e}")
# - name is required
# - email is required
Best practices
else in try/except: code that should run only on success, but that you do not want inside the try (so its own exceptions aren't
caught by your handlers). Underused and very useful.
27. SOLID Principles
# BAD: this class changes if the DB changes, OR the email template changes,
# OR the report format changes. Three reasons.
class UserBad:
def __init__(self, name, email): [Link], [Link] = name, email
def save_to_db(self): ...
def send_welcome_email(self): ...
def generate_pdf_report(self): ...
class UserRepository:
def save(self, user): print(f"INSERT {[Link]}")
class EmailService:
def send_welcome(self, user): print(f"Emailing {[Link]}")
class UserReportGenerator:
def to_pdf(self, user): print(f"PDF for {[Link]}")
u = User("Ali", "a@[Link]")
UserRepository().save(u)
EmailService().send_welcome(u)
O — Open/Closed Principle
Open for extension, closed for modification.
class PaymentMethod(ABC):
@abstractmethod
def pay(self, amount): ...
class Card(PaymentMethod):
def pay(self, amount): return f"Card: {amount}"
class Cash(PaymentMethod):
def pay(self, amount): return f"Cash: {amount}"
class JazzCash(PaymentMethod): # NEW — zero edits elsewhere
def pay(self, amount): return f"JazzCash: {amount}"
class Processor:
def process(self, method: PaymentMethod, amount):
return [Link](amount)
p = Processor()
print([Link](JazzCash(), 500))
def migrate(birds):
for b in birds: print([Link]()) # crashes on Penguin
class Robot(WorkerBad):
def work(self): return "working"
def eat(self): raise NotImplementedError("robots don't eat") # forced!
class UserServiceBad:
def __init__(self):
[Link] = MySQLDatabase() # locked in. Untestable without MySQL.
def register(self, u): [Link](u)
class MySQL(Database):
def save(self, d): print(f"MySQL: {d}")
class Postgres(Database):
def save(self, d): print(f"Postgres: {d}")
class InMemoryDB(Database): # perfect test double
def __init__(self): [Link] = []
def save(self, d): [Link](d)
class UserService:
def __init__(self, db: Database): # DEPENDENCY INJECTION
[Link] = db
def register(self, user):
[Link](user)
Many classic GoF patterns exist to work around limitations Python doesn't have. Strategy is often just a function. Command is
often just a closure. Singleton is often just a module. Use the pattern only when the extra structure earns its keep.
Strategy
class QuickSort(SortStrategy):
def sort(self, data): return sorted(data)
class ReverseSort(SortStrategy):
def sort(self, data): return sorted(data, reverse=True)
class Sorter:
def __init__(self, strategy: SortStrategy):
[Link] = strategy
def sort(self, data):
return [Link](data)
class Notification(ABC):
@abstractmethod
def send(self, msg): ...
class Email(Notification):
def send(self, msg): return f"Email: {msg}"
class SMS(Notification):
def send(self, msg): return f"SMS: {msg}"
class Push(Notification):
def send(self, msg): return f"Push: {msg}"
class NotificationFactory:
_registry = {"email": Email, "sms": SMS, "push": Push}
@classmethod
def create(cls, kind) -> Notification:
try:
return cls._registry[kind]()
except KeyError:
raise ValueError(f"Unknown type {kind!r}. "
f"Available: {list(cls._registry)}") from None
@classmethod
def register(cls, kind, klass):
cls._registry[kind] = klass # extensible at runtime
print([Link]("sms").send("hi")) # SMS: hi
class WhatsApp(Notification):
def send(self, msg): return f"WhatsApp: {msg}"
[Link]("whatsapp", WhatsApp)
print([Link]("whatsapp").send("hello"))
Observer
class Subject:
def __init__(self):
self._observers = []
class Logger:
def update(self, event, data): print(f"[LOG] {event}: {data}")
class Store(Subject):
def __init__(self):
super().__init__()
self._stock = {}
store = Store()
[Link](Logger())
[Link]("pens", 50)
[Link]("pens", 60)
# [LOG] [Link]: {'item': 'pens', 'qty': 50}
# [LOG] [Link]: {'item': 'pens', 'qty': 60}
# [LOG] [Link]: {'item': 'pens'}
# !! ALERT: too much pens
Builder
class Query:
def __init__(self):
self._table = None
self._cols = ["*"]
self._where = []
self._order = None
self._limit = None
def build(self):
if not self._table:
raise ValueError("table is required")
sql = f"SELECT {', '.join(self._cols)} FROM {self._table}"
if self._where: sql += " WHERE " + " AND ".join(self._where)
if self._order: sql += f" ORDER BY {self._order}"
if self._limit: sql += f" LIMIT {self._limit}"
return sql
print(Query().table("users").select("id", "name")
.where("age > 18").where("active = 1")
.order_by("name").limit(10).build())
# SELECT id, name FROM users WHERE age > 18 AND active = 1 ORDER BY name LIMIT 10
Adapter
class ModernLogger: # the interface our app expects
def log(self, level, msg): ...
class LegacyAdapter(ModernLogger):
def __init__(self, legacy):
[Link] = legacy
def log(self, level, msg):
[Link].write_line(f"[{[Link]()}] {msg}")
Template Method
class DataProcessor(ABC):
def run(self, source): # the TEMPLATE — fixed skeleton
data = [Link](source)
data = [Link](data)
data = [Link](data)
return [Link](data)
@abstractmethod
def read(self, source): ...
@abstractmethod
def transform(self, data): ...
class CSVProcessor(DataProcessor):
def read(self, source): return [Link]().split("\n")
def transform(self, data): return [[Link](",") for r in data]
print(CSVProcessor().run("a,1\nb,2"))
# Writing 2 rows
# [['a', '1'], ['b', '2']]
29. Testing OOP Code
import unittest
from [Link] import Mock, MagicMock, patch, call
class PaymentGateway(ABC):
@abstractmethod
def charge(self, amount) -> bool: ...
class OrderService:
def __init__(self, gateway: PaymentGateway, repo):
[Link] = gateway
[Link] = repo
class TestOrderService([Link]):
def setUp(self):
[Link] = Mock(spec=PaymentGateway) # spec -> typo-proof mock
[Link] = Mock()
[Link] = OrderService([Link], [Link])
def test_successful_checkout(self):
[Link].return_value = True
order = [Link]({"total": 100})
[Link](order["status"], "paid")
[Link].assert_called_once_with(100)
[Link].assert_called_once()
def test_declined_payment(self):
[Link].return_value = False
with [Link](RuntimeError):
[Link]({"total": 100})
[Link].assert_not_called() # nothing persisted
def test_invalid_total(self):
with [Link](ValueError, "Invalid total"):
[Link]({"total": 0})
[Link].assert_not_called()
def test_gateway_exception_propagates(self):
[Link].side_effect = ConnectionError("network down")
with [Link](ConnectionError):
[Link]({"total": 50})
Mock essentials
m = Mock()
[Link](1, 2, key="v")
[Link](3)
print([Link]) # True
print([Link].call_count) # 2
print([Link].call_args) # call(3) — the LAST call
print([Link].call_args_list) # [call(1, 2, key='v'), call(3)]
[Link].assert_any_call(1, 2, key="v")
[Link].assert_has_calls([call(1, 2, key="v"), call(3)])
# return_value vs side_effect
m2 = Mock(return_value=42)
print(m2(), m2()) # 42 42 — always the same
m4 = Mock(side_effect=ValueError("boom")) # raise
# m4() # ValueError: boom
m5 = Mock(side_effect=lambda x: x * 2) # compute
print(m5(5)) # 10
# spec: the mock only allows real attributes -> catches typos
real = Mock(spec=PaymentGateway)
# [Link](1) # AttributeError: Mock object has no
# attribute 'charrge'
# Without spec, [Link](1) silently "works" and your test passes wrongly.
Patching
import requests
class WeatherClient:
def get_temp(self, city):
r = [Link](f"[Link]
r.raise_for_status()
return [Link]()["temp"]
class TestWeather([Link]):
@patch("__main__.[Link]") # patch WHERE IT'S USED
def test_get_temp(self, mock_get):
mock_get.return_value.json.return_value = {"temp": 35}
mock_get.return_value.raise_for_status.return_value = None
[Link](WeatherClient().get_temp("Lahore"), 35)
mock_get.assert_called_once_with("[Link]
def test_with_context_manager(self):
with [Link](WeatherClient, "get_temp", return_value=99) as m:
[Link](WeatherClient().get_temp("x"), 99)
m.assert_called_once()
Patch where the object is looked up, not where it's defined. If [Link] does from requests import get , you must patch
[Link] — not [Link] . This is the single most common mocking mistake.
def test_with_fake():
repo = FakeRepo()
svc = OrderService(Mock(spec=PaymentGateway), repo)
[Link].return_value = True
[Link]({"id": 1, "total": 50})
assert len([Link]) == 1
assert [Link](1)["status"] == "paid" # test real behaviour, not calls
test_with_fake()
def test_area_is_positive(self):
[Link](self.shape_class(*[Link]).area(), 0)
def test_implements_interface(self):
[Link](hasattr(self.shape_class(*[Link]), "area"))
# Mistake Fix
# NAMING
class HTTPResponseHandler: # PascalCase, no underscores
MAX_RETRIES = 3 # UPPER_SNAKE for constants
def parse_body(self): # snake_case for methods
self.status_code = 0 # snake_case for attributes
self._cache = {} # single _ = internal
self.__id = 1 # double __ = mangled (rare)
Need Use
The overarching rule: start with the simplest thing — a function, then a plain class, then a dataclass. Add properties, ABCs,
descriptors, and metaclasses only when a concrete problem demands them. Python rewards restraint.
31. Capstone Project
A small inventory/point-of-sale system that exercises nearly everything in this guide: ABCs, dataclasses, enums, descriptors,
properties, dunders, context managers, generators, exceptions, mixins, the observer pattern, and dependency injection.
class OutOfStockError(ShopError):
def __init__(self, sku, requested, available):
[Link], [Link], [Link] = sku, requested, available
super().__init__(
f"{sku}: requested {requested}, only {available} available")
class InvalidTransition(ShopError):
pass
def __post_init__(self):
if [Link] < 0:
raise ValueError("Money cannot be negative")
__rmul__ = __mul__
def __str__(self):
return f"{[Link]:,.2f} {[Link]}"
@property
def label(self) -> str:
return type(self).__name__
class NoDiscount(Discount):
def apply(self, subtotal): return subtotal
class PercentOff(Discount):
def __init__(self, pct):
if not 0 <= pct <= 100:
raise ValueError("pct must be 0-100")
[Link] = pct
def apply(self, subtotal):
return Money([Link] * (1 - [Link] / 100), [Link])
@property
def label(self): return f"{[Link]}% off"
class BulkDiscount(Discount):
def __init__(self, threshold: Money, off: Money):
[Link], [Link] = threshold, off
def apply(self, subtotal):
if [Link] >= [Link]:
return Money([Link] - [Link], [Link])
return subtotal
class InMemoryRepo:
def __init__(self): self._items = []
def save(self, obj): self._items.append(obj)
def all(self): return list(self._items)
@property
def value(self) -> Money:
return Money([Link]) * [Link]
@property
def in_stock(self) -> bool:
return [Link] > 0
@dataclass
class LineItem:
product: Product
qty: int
def __post_init__(self):
if [Link] <= 0:
raise ValueError("qty must be positive")
@property
def total(self) -> Money:
return Money([Link]) * [Link]
order = Order(discount=PercentOff(10))
@[Link]
def audit(event, data):
print(f" [audit] {event} {data}")
repo = InMemoryRepo()
Checkout(repo).complete(order)
print(f"\nstate={[Link]} saved={len([Link]())}")
# rollback demo
order2 = Order()
try:
with atomic(order2):
[Link](pen, 5)
[Link](bag, 99) # OutOfStockError -> rollback
except OutOfStockError as e:
print(f"\nRolled back: {e}")
print(f"order2 items after rollback: {len(order2)}")
try:
[Link]([Link])
except InvalidTransition as e:
print(f"Blocked: {e}")
Expected output
[audit] [Link] {'sku': 'SKU1', 'qty': 10}
[audit] [Link] {'sku': 'SKU2', 'qty': 2}
[audit] [Link] {'sku': 'SKU3', 'qty': 1}
state=PAID saved=1
Concept Where
Mixin ReprMixin
Generator low_stock()
Exercises to extend it
1. Add a StackedDiscount(Discount) that composes several discounts.
2. Make Order picklable — note the observer list holds functions.
3. Add __slots__ to Product — what breaks, and why?
4. Replace Positive with a generic Range(min, max) descriptor.
5. Write unit tests with a FakeRepo ; assert OutOfStockError carries the right attributes.
6. Add __init_subclass__ to Discount that auto-registers each subclass by label.
7. Convert atomic() to a class-based context manager with __enter__ / __exit__ .
8. Add an async checkout using __aenter__ / __aexit__ .
Where to go next
Python docs — Data model: the definitive dunder reference ( [Link]/3/reference/[Link] )
Descriptor HowTo Guide in the official docs — by Raymond Hettinger
Fluent Python (Ramalho) — the best deep dive on the data model
Architecture Patterns with Python (Percival & Gregory) — repositories, unit of work, DDD
Read the stdlib source: [Link] , [Link] , [Link] , [Link] . They are excellent, readable OOP.
Final advice: mastery here is not about using every feature. It's knowing that @dataclass handles 80% of your classes, that
composition beats inheritance most of the time, and that a metaclass is almost never the answer. The advanced machinery exists
so that libraries can be simple for you — you rarely need to write it yourself.