What’s one thing you learned? What’s still confusing?
Advanced OOP, Part 2: Dataclasses, Enum & Protocols
@dataclass (frozen, order, slots, __post_init__), Enum / IntEnum / Flag, Protocol for structural subtyping, and __slots__ for memory optimization.
Python's Data Model: Dunder Methods & Operator Overloading
Implement __repr__, __eq__, __hash__, __len__, __getitem__, __iter__, __enter__/__exit__, __call__.
Decorators, Generators & Context Managers
Decorators for timing/logging/caching, generators with yield, and context managers.
Interactive Labs for This Track
Loop Visualizer
You're a factory robot repeating the same task on an assembly line — watch how loops automate repetitive work
List Slicing
You have a playlist of 50 songs — grab just tracks 10 through 20 with a single slice expression
Sorting Algorithms
You're organizing a library of 10,000 books — which sorting method is fastest?
Ask questions, share insights
nn.Module says "you must implement forward()", that's @abstractmethod doing the work. Part 1 nails the inheritance fundamentals; Part 2 covers @dataclass, Enum, Protocol, and __slots__.Inheritance lets child classes reuse and specialize parent behavior without copying code.
class Animal:
"""Base class representing any animal."""
def __init__(self, name: str, weight_kg: float) -> None:
self.name = name
self.weight_kg = weight_kg
self._energy = 100.0
def speak(self) -> str:
"""All animals make sounds. Subclasses should override this."""
return f"{self.name} makes a sound"
def eat(self, calories: float) -> None:
self._energy = min(100.0, self._energy + calories / 10)
print(f"{self.name} eats ({calories} cal). Energy: {self._energy:.0f}")
def __repr__(self) -> str:
return f"{type(self).__name__}(name={self.name!r}, weight={self.weight_kg}kg)"
class Dog(Animal):
"""A dog: inherits Animal, adds breed and tricks."""
def __init__(self, name: str, weight_kg: float, breed: str) -> None:
super().__init__(name, weight_kg) # call parent's __init__
self.breed = breed
self._tricks: list[str] = []
def speak(self) -> str:
return f"{self.name} barks: Woof!"
def learn_trick(self, trick: str) -> None:
self._tricks.append(trick)
print(f"{self.name} learned: {trick}")
def perform(self) -> str:
if not self._tricks:
return f"{self.name} knows no tricks yet"
return f"{self.name} performs: {', '.join(self._tricks)}"
class GoldenRetriever(Dog):
"""Three levels deep: GoldenRetriever is-a Dog is-an Animal."""
def __init__(self, name: str, weight_kg: float) -> None:
super().__init__(name, weight_kg, breed="Golden Retriever")
self.fetch_count = 0
def speak(self) -> str:
return f"{self.name} barks happily: WOOF WOOF!"
def fetch(self, item: str) -> str:
self.fetch_count += 1
return f"{self.name} fetches the {item}! (fetch #{self.fetch_count})"
buddy = GoldenRetriever("Buddy", 32.0)
# Methods inherited from all three levels
buddy.eat(200) # from Animal
buddy.learn_trick("sit") # from Dog
print(buddy.fetch("ball")) # from GoldenRetriever
print(buddy.speak()) # overridden at each level
# isinstance checks the full inheritance chain
print(isinstance(buddy, GoldenRetriever)) # True
print(isinstance(buddy, Dog)) # True
print(isinstance(buddy, Animal)) # True
print(issubclass(GoldenRetriever, Dog)) # True
print(issubclass(GoldenRetriever, Animal)) # Trueclass Flyable:
"""Mixin: adds flying capability."""
def move(self) -> str:
return "flying through the air"
def land(self) -> str:
return "landing gracefully"
class Swimmable:
"""Mixin: adds swimming capability."""
def move(self) -> str:
return "swimming through the water"
def dive(self) -> str:
return "diving deep"
class Duck(Animal, Flyable, Swimmable):
"""A duck: is an animal that can both fly and swim."""
def __init__(self, name: str) -> None:
super().__init__(name, weight_kg=1.5)
def speak(self) -> str:
return f"{self.name} quacks: Quack!"
donald = Duck("Donald")
# Which move() wins? The MRO determines this.
# Duck and Animal don't define move(), so the search continues into the mixins.
# Flyable comes BEFORE Swimmable in `class Duck(Animal, Flyable, Swimmable)`,
# so Flyable.move wins.
print(donald.move()) # "flying through the air"
print(Duck.__mro__)
# (<class 'Duck'>, <class 'Animal'>, <class 'Flyable'>, <class 'Swimmable'>, <class 'object'>)
# All inherited methods are available
print(donald.speak())
print(donald.land()) # from Flyable
print(donald.dive()) # from Swimmableclass X(A, B): declaration is preserved. You can always inspect it:print([cls.__name__ for cls in Duck.__mro__])
# ['Duck', 'Animal', 'Flyable', 'Swimmable', 'object']Click "Resolve Method" below to watch Python walk the MRO and find which class provides each method:
D → B → C → A → object. Watch:greet is overridden in B, so Python stops at B (skipping A entirely).describe lives only on C — Python walks past B to find it.ping is unique to A — Python walks all the way up the chain.This is why diamond hierarchies don't cause "which parent wins?" ambiguity in Python: the MRO is a single, totally-ordered list, and every lookup walks it the same way.
super() chain — to see exactly when each class gets visited.super() walk the MROMixins are small, focused classes that add a specific capability without being standalone parent classes. They are the recommended way to use multiple inheritance in Python:
class JSONMixin:
"""Adds JSON serialization to any class with a __dict__."""
def to_json(self) -> str:
import json
# Filter out private attributes
public = {k: v for k, v in self.__dict__.items() if not k.startswith("_")}
return json.dumps(public, indent=2)
class LogMixin:
"""Adds structured logging to any class."""
def log(self, message: str) -> None:
print(f"[{type(self).__name__}] {message}")
class MLModel(LogMixin, JSONMixin):
"""An ML model with logging and JSON serialization from mixins."""
def __init__(self, name: str, n_params: int) -> None:
self.name = name
self.n_params = n_params
self.accuracy = 0.0
def train_step(self) -> None:
self.accuracy += 0.01
self.log(f"Step complete. Accuracy: {self.accuracy:.2%}")
model = MLModel("BERT-tiny", n_params=4_400_000)
model.train_step()
model.train_step()
print(model.to_json())
# {
# "name": "BERT-tiny",
# "n_params": 4400000,
# "accuracy": 0.02
# }TypeError at instantiation time, not at the point of first use.from abc import ABC, abstractmethod
import math
class Shape(ABC):
"""Abstract base class for all geometric shapes."""
def __init__(self, color: str = "black") -> None:
self.color = color
@abstractmethod
def area(self) -> float:
"""Every shape must implement area()."""
...
@abstractmethod
def perimeter(self) -> float:
"""Every shape must implement perimeter()."""
...
# Concrete method: uses abstract methods but is defined on the ABC
def describe(self) -> str:
return (f"{type(self).__name__} [{self.color}]: "
f"area={self.area():.2f}, perimeter={self.perimeter():.2f}")
def __repr__(self) -> str:
return f"{type(self).__name__}(color={self.color!r})"
class Circle(Shape):
def __init__(self, radius: float, color: str = "black") -> None:
super().__init__(color)
self.radius = radius
def area(self) -> float:
return math.pi * self.radius ** 2
def perimeter(self) -> float:
return 2 * math.pi * self.radius
class Rectangle(Shape):
def __init__(self, width: float, height: float, color: str = "black") -> None:
super().__init__(color)
self.width = width
self.height = height
def area(self) -> float:
return self.width * self.height
def perimeter(self) -> float:
return 2 * (self.width + self.height)
class Triangle(Shape):
def __init__(self, a: float, b: float, c: float) -> None:
super().__init__()
self.a, self.b, self.c = a, b, c
def area(self) -> float:
s = self.perimeter() / 2
return math.sqrt(s * (s-self.a) * (s-self.b) * (s-self.c))
def perimeter(self) -> float:
return self.a + self.b + self.c
# Cannot instantiate the ABC directly
try:
s = Shape()
except TypeError as e:
print(f"Cannot instantiate ABC: {e}")
# Cannot instantiate ABC: Can't instantiate abstract class Shape with abstract methods area, perimeter
# Concrete subclasses work fine
shapes: list[Shape] = [
Circle(5.0, "red"),
Rectangle(4, 6, "blue"),
Triangle(3, 4, 5),
]
for shape in shapes:
print(shape.describe())
# Circle [red]: area=78.54, perimeter=31.42
# Rectangle [blue]: area=24.00, perimeter=20.00
# Triangle [black]: area=6.00, perimeter=12.00
# Polymorphic -- total area works without knowing specific types
total = sum(s.area() for s in shapes)
print(f"Total area: {total:.2f}")HitTypeError: Can't instantiate abstract class ...orAttributeErroron a subclass? Forgetting to implement one of the@abstractmethodmethods blocks instantiation; a typo in the method name silently leaves the abstract one in place and triggersAttributeErrorlater. See the error decoder for both.
collections.abcPython's standard library provides ABCs for containers. If you implement the required abstract methods, you get extra methods for free:
from collections.abc import MutableSequence
class BoundedList(MutableSequence):
"""A list that enforces a maximum size limit.
Implement the 5 required abstract methods, get insert/append/pop/etc. free.
"""
def __init__(self, max_size: int) -> None:
self._max_size = max_size
self._data: list = []
def __getitem__(self, index):
return self._data[index]
def __setitem__(self, index, value):
self._data[index] = value
def __delitem__(self, index):
del self._data[index]
def __len__(self) -> int:
return len(self._data)
def insert(self, index: int, value) -> None:
if len(self._data) >= self._max_size:
raise OverflowError(f"BoundedList is full (max={self._max_size})")
self._data.insert(index, value)
bl = BoundedList(max_size=3)
bl.append(10) # free from MutableSequence
bl.append(20)
bl.append(30)
print(list(bl)) # [10, 20, 30]
print(30 in bl) # True -- __contains__ is free
bl.reverse() # free from MutableSequence
print(list(bl)) # [30, 20, 10]
try:
bl.append(40) # OverflowError: BoundedList is full (max=3)
except OverflowError as e:
print(f"Caught: {e}")A subclass of an ABC forgets to implement one of the @abstractmethod methods. WHEN does Python raise the error?
Explore how Python classes work as blueprints, how inheritance chains methods through the MRO, and how polymorphism lets one interface drive many implementations.
@property, @classmethod, @staticmethod, and methods themselves as if they were primitives of the language. They are not. They are all built from the same underlying mechanism: the descriptor protocol.__get__, __set__, or __delete__. When you access instance.attr, Python doesn't just dictionary-lookup "attr" on the instance — it runs a four-step algorithm that gives certain class-level objects a chance to intercept the access. That algorithm is why t.celsius can run validation code, why c.method returns a bound method instead of a function, and why ORMs like Django and SQLAlchemy can declare fields with name = CharField(max_length=80).@property with validation, a reusable validator class, method binding (the real mechanism behind self), and how @classmethod and @staticmethod differ. The amber pointer traces the four-step lookup algorithm; the call log shows every descriptor method invocation.# A reusable validator descriptor — the pattern behind Django Field and SQLAlchemy Column.
class Positive:
"""Stores its value on the instance under a name-mangled slot."""
def __set_name__(self, owner, name):
# Called once at class-creation time. Captures the attribute name.
self.private = "_" + name
def __get__(self, obj, objtype=None):
if obj is None:
return self # access through the class returns the descriptor itself
return getattr(obj, self.private)
def __set__(self, obj, value):
if value <= 0:
raise ValueError(f"{self.private[1:]} must be positive, got {value}")
setattr(obj, self.private, value)
class Account:
balance = Positive()
deposit_limit = Positive()
a = Account()
a.balance = 100 # → Positive.__set__(a, 100) → a.__dict__['_balance'] = 100
a.deposit_limit = 500 # → same descriptor class, different name (thanks to __set_name__)
print(a.balance) # → Positive.__get__(a, Account) → 100
try:
a.balance = -5 # → Positive.__set__(a, -5) → ValueError
except ValueError as e:
print(f"Rejected: {e}")A class defines `x = MyDescriptor()` (with both `__get__` and `__set__`). You then run `instance.__dict__['x'] = 'sneaky'`. What does `instance.x` return?
super() follows the MRO, not just the direct parent -- in multiple inheritance, super().__init__() walks the full method resolution order. Always use super() over ParentClass.__init__(self) to keep cooperative multiple inheritance working correctly__mro__ is the truth, and it always preserves both inheritance order and "child before parent"LoggerMixin, JSONMixin, focused single-purpose classes combined into rich behavior; this is the pattern that production codebases use, not deep N-level hierarchies@abstractmethod means subclasses that fail to implement required methods are caught immediately when you try to create an instance, not silently at runtime when the method is first calledcollections.abc mixins give methods for free -- implement five abstract methods on MutableSequence and you get , , , , and more without writing themIn class C(A, B):, both A and B define a method foo(). Which version does C.foo() use?
obj.xinsertappendpopreverse__contains__