Excellent.
DAY 21 is where Python’s elegance comes from. Dunder (magic) methods are protocols, not tricks. Master them, and Python becomes predictable, extensible, and interview-friendly.


🧠 DAY 21 — Dunder Methods (Magic Methods & Python Protocols)


🔑 CORE IDEA OF DAY 21

Dunder methods are how Python asks objects to behave.
Operators, printing, iteration, context managers — all are method calls under the hood.


1️⃣ What Are Dunder Methods?

Dunder = double underscore methods like __add__, __str__, __len__.

They are hooks Python calls automatically when you:

  • Use operators (+, ==)
  • Print objects
  • Iterate
  • Enter with blocks
  • Access attributes

They define protocols (behavior contracts).


2️⃣ Operators Are Just Method Calls

a + b

Is really:

a.__add__(b)

If that fails, Python may try:

b.__radd__(a)

Same idea for:

  • ==__eq__
  • <__lt__
  • []__getitem__
  • len()__len__

🧠 Syntax is sugar; methods do the work.


3️⃣ String Representation: __str__ vs __repr__ (INTERVIEW FAVORITE)

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

__str__ → user-friendly

def __str__(self):
    return f"User({self.name})"

__repr__ → developer/debug-friendly

def __repr__(self):
    return f"User(name={self.name!r})"

Rules:

  • print(obj)__str__
  • Interactive shell → __repr__
  • If __str__ missing → falls back to __repr__

Best practice:

Make __repr__ unambiguous and, if possible, evaluable.


4️⃣ Truthiness Protocol: __bool__ & __len__

When Python evaluates:

if obj:

Order:

  1. Call obj.__bool__() if exists
  2. Else call obj.__len__()
  3. Else → True

Example:

class Box:
    def __len__(self):
        return 0

bool(Box())  # False

This explains truthy/falsy behavior precisely.


5️⃣ Container Protocols: __len__, __getitem__, __contains__

class Bag:
    def __init__(self, items):
        self.items = items

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

    def __getitem__(self, i):
        return self.items[i]

Now:

b = Bag([1, 2, 3])
len(b)      # works
b[0]        # works
for x in b: # works (iteration via __getitem__)

🧠 Implementing a few methods unlocks many behaviors.


6️⃣ Iteration Protocol (Clean Way)

Preferred iteration uses:

  • __iter__
  • __next__
class Counter:
    def __init__(self, n):
        self.n = n

    def __iter__(self):
        self.i = 0
        return self

    def __next__(self):
        if self.i >= self.n:
            raise StopIteration
        self.i += 1
        return self.i

This is how for-loops really work.


7️⃣ Attribute Access Hooks (Advanced but Powerful)

__getattr__ — fallback lookup

def __getattr__(self, name):
    return "missing"

__getattribute__ — intercept all access (dangerous)

def __getattribute__(self, name):
    ...

⚠️ Misuse can cause infinite recursion.
Use only when necessary (ORMs, proxies).


8️⃣ Context Managers: with Statement (VERY IMPORTANT)

with open("file.txt") as f:
    data = f.read()

Under the hood:

f = open(...)
f.__enter__()
try:
    ...
finally:
    f.__exit__(...)

Custom context manager:

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

    def __exit__(self, exc_type, exc, tb):
        import time
        print(time.time() - self.start)

This guarantees cleanup, even on exceptions.


9️⃣ Arithmetic & Comparison Protocols

Common ones:

  • __add__, __sub__, __mul__
  • __eq__, __lt__, __le__
  • __hash__ (immutability matters!)

Example:

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

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

    def __hash__(self):
        return hash((self.x, self.y))

Now Point can be a dict key.


🔟 Object Lifecycle Hooks

  • __new__ → object creation (Day 19)
  • __init__ → initialization
  • __del__ → finalizer (⚠️ unreliable; avoid)

Rule:

Use context managers instead of __del__.


11️⃣ Protocol Thinking (Big Mental Shift)

Python prefers protocols over inheritance.

If an object:

  • Has __len__ → it’s “sized”
  • Has __iter__ → it’s “iterable”
  • Has __enter__/__exit__ → it’s a context manager

No base class required.

🧠 Duck typing in action.


🔥 INTERVIEW TRAPS (DAY 21)

Q1

class A:
    def __len__(self):
        return 0

print(bool(A()))

False


Q2

Why __repr__ is preferred for debugging?

✔ Unambiguous
✔ Shows internal state
✔ Used by interactive shell


Q3

What happens if both __bool__ and __len__ exist?

__bool__ wins


🧠 DAY 21 MENTAL CHECKLIST

When designing a class:

  1. What protocols should it support?
  2. Do I need iteration?
  3. How should it print?
  4. Should it be hashable?
  5. Can a context manager simplify cleanup?

📝 DAY 21 ASSIGNMENT (MANDATORY)

1️⃣ Predict output (NO RUNNING):

class A:
    def __str__(self):
        return "A"
    def __repr__(self):
        return "REP"

a = A()
print(a)
print([a])

2️⃣ Explain clearly:

  • Difference between __str__ and __repr__
  • Why protocols beat inheritance in Python

3️⃣ Design question:

How would you design a resource-safe database connection object?

(Hint: context manager + composition)


🔜 DAY 22 PREVIEW

DAY 22 — Exceptions Internals (Hierarchy, Cost & Best Practices)

You’ll learn:

  • Exception hierarchy
  • Why exceptions are slow
  • How try/except really works
  • Writing clean, Pythonic error handling

When ready, say 👉 “START DAY 22”