What’s one thing you learned? What’s still confusing?
Mini-Project: Bank Account Class
Build a BankAccount class with deposit, withdraw, history, and transfers.
Advanced OOP, Part 1: Inheritance, MRO & ABCs
Multiple inheritance, MRO with C3 linearization, cooperative super(), the mixin pattern, and Abstract Base Classes.
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.
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, every Django ORM entity — they're all classes. The Anthropic SDK's Anthropic() client is a class. Classes group related state and behavior into one cohesive unit, and they're the foundation of every framework you'll ever build on."hello".upper(), the string "hello" is an object of the class str, and .upper() is a method defined in that class.class Dog:
"""A simple Dog class."""
def __init__(self, name, breed, age):
"""Initialize a new Dog instance."""
self.name = name # attribute
self.breed = breed # attribute
self.age = age # attribute
def bark(self):
"""Make the dog bark."""
return f"{self.name} says: Woof!"
def describe(self):
"""Return a description of the dog."""
return f"{self.name} is a {self.age}-year-old {self.breed}"
# Create instances (objects)
dog1 = Dog("Buddy", "Golden Retriever", 3)
dog2 = Dog("Luna", "Husky", 5)
print(dog1.bark()) # Buddy says: Woof!
print(dog2.describe()) # Luna is a 5-year-old Husky
print(dog1.name) # Buddy -- accessing an attribute directlyrex.name and rex.bark are still attribute lookups: Python checks the instance first, then walks up to the class. That's why methods defined on Dog are callable on every Dog instance — they live on the class, not the instance.Let us break down the syntax:
class Dog: -- defines a new class (use PascalCase for class names)__init__ -- the constructor method, called automatically when you create a new objectself -- a reference to the current object being created or usedself.name = name -- stores the name argument as an attribute on the objectdog1 = Dog("Buddy", ...) -- creates a new Dog instance and calls __init__What does self refer to inside a method?
dog1.bark(), Python automatically passes dog1 as the self parameter. So inside bark(), self.name is dog1.name, which is "Buddy". When you call dog2.bark(), self becomes dog2, and self.name is "Luna".dog1.bark() is secretly Dog.bark(dog1) behind the scenes.HitAttributeError: '...' object has no attribute '...'? This means you typedobj.foobut the class never assignedself.foo— usually a typo or you forgot to set it in__init__. See the error decoder for plain-English fixes.
class Student:
"""A student in a course."""
# Class attribute -- shared by ALL instances
school = "RugvAI Academy"
def __init__(self, name, grade):
# Instance attributes -- unique to EACH instance
self.name = name
self.grade = grade
self.courses = [] # mutable default -- safe here because it is in __init__
def enroll(self, course):
"""Add a course to this student's schedule."""
self.courses.append(course)
return f"{self.name} enrolled in {course}"
def gpa_status(self):
"""Return academic standing based on grade."""
if self.grade >= 90:
return "Honor Roll"
elif self.grade >= 70:
return "Good Standing"
else:
return "Needs Improvement"
alice = Student("Alice", 95)
bob = Student("Bob", 72)
print(alice.school) # RugvAI Academy -- class attribute
print(bob.school) # RugvAI Academy -- same for all
print(alice.enroll("ML 101")) # Alice enrolled in ML 101
print(alice.enroll("Python")) # Alice enrolled in Python
print(alice.courses) # ['ML 101', 'Python']
print(bob.courses) # [] -- Bob has his own list
print(alice.gpa_status()) # Honor Roll
print(bob.gpa_status()) # Good Standing__init__ and shared by all instances. Instance attributes are defined inside __init__ with self. and are unique per object.__str__ Methodprint() an object, Python calls its __str__ method. You can customize it:class Vector:
"""A 2D vector."""
def __init__(self, x, y):
self.x = x
self.y = y
def __str__(self):
return f"Vector({self.x}, {self.y})"
def magnitude(self):
return (self.x ** 2 + self.y ** 2) ** 0.5
v = Vector(3, 4)
print(v) # Vector(3, 4) -- thanks to __str__
print(v.magnitude()) # 5.0class Animal:
"""Base class for all animals."""
def __init__(self, name, species):
self.name = name
self.species = species
def speak(self):
"""Default speak -- subclasses will override this."""
return f"{self.name} makes a sound"
def describe(self):
return f"{self.name} is a {self.species}"
class Dog(Animal):
"""A dog -- inherits from Animal."""
def __init__(self, name, breed):
super().__init__(name, species="Dog") # call parent __init__
self.breed = breed
def speak(self):
"""Override the parent's speak method."""
return f"{self.name} says: Woof! Woof!"
def fetch(self, item):
"""Dogs can fetch -- a method unique to Dog."""
return f"{self.name} fetches the {item}"
class Cat(Animal):
"""A cat -- inherits from Animal."""
def __init__(self, name, indoor=True):
super().__init__(name, species="Cat")
self.indoor = indoor
def speak(self):
return f"{self.name} says: Meow!"
def purr(self):
return f"{self.name} purrs contentedly..."
# Create instances
buddy = Dog("Buddy", "Golden Retriever")
whiskers = Cat("Whiskers", indoor=True)
# Inherited method from Animal
print(buddy.describe()) # Buddy is a Dog
print(whiskers.describe()) # Whiskers is a Cat
# Overridden method
print(buddy.speak()) # Buddy says: Woof! Woof!
print(whiskers.speak()) # Whiskers says: Meow!
# Subclass-specific methods
print(buddy.fetch("ball")) # Buddy fetches the ball
print(whiskers.purr()) # Whiskers purrs contentedly...class Dog(Animal): -- Dog inherits from Animalsuper().__init__(...) -- calls the parent class's __init__ to set up inherited attributesspeak(), replacing the parent versionfetch(), Cat has purr(). Each subclass can add new behaviorspeak on Dog (not Animal) — the subclass wins because it appears earlier in the chain. describe falls all the way back to Animal. fly isn't found anywhere, so Python raises AttributeError. This is the heart of polymorphism: the MRO chain decides which version of a method runs.If Dog inherits from Animal and BOTH define a method called `speak()`, which one runs when you call `Dog('Rex').speak()`?
# All these animals have a speak() method, but each behaves differently
pets = [
Dog("Buddy", "Retriever"),
Cat("Whiskers"),
Dog("Rex", "Shepherd"),
Cat("Mittens", indoor=False),
]
# Polymorphism in action -- same method call, different behavior
for pet in pets:
print(pet.speak())
# Buddy says: Woof! Woof!
# Whiskers says: Meow!
# Rex says: Woof! Woof!
# Mittens says: Meow!pet.speak() does not need to know whether pet is a Dog or a Cat. It just calls speak() and the right version runs. This is exactly how AI frameworks work -- model.forward(x) runs different code depending on whether model is a CNN, an RNN, or a Transformer.class Model:
"""Base class for ML models."""
def __init__(self, name):
self.name = name
self.trained = False
def train(self, data):
"""Train the model -- subclasses implement specifics."""
raise NotImplementedError("Subclasses must implement train()")
def predict(self, x):
"""Make a prediction -- subclasses implement specifics."""
raise NotImplementedError("Subclasses must implement predict()")
class LinearRegression(Model):
def __init__(self):
super().__init__("Linear Regression")
self.slope = 0
self.intercept = 0
def train(self, data):
# Simplified training: average slope from data points
xs = [p[0] for p in data]
ys = [p[1] for p in data]
n = len(data)
self.slope = (n * sum(x * y for x, y in data) - sum(xs) * sum(ys)) / \
(n * sum(x ** 2 for x in xs) - sum(xs) ** 2)
self.intercept = (sum(ys) - self.slope * sum(xs)) / n
self.trained = True
print(f"{self.name} trained: y = {self.slope:.2f}x + {self.intercept:.2f}")
def predict(self, x):
return self.slope * x + self.intercept
class KNearestNeighbors(Model):
def __init__(self, k=3):
super().__init__(f"{k}-Nearest Neighbors")
self.k = k
self.data = []
def train(self, data):
self.data = data # KNN just stores the data
self.trained = True
print(f"{self.name} trained on {len(data)} points")
def predict(self, x):
# Find k nearest points, return average y
distances = [(abs(x - px), py) for px, py in self.data]
distances.sort()
nearest = distances[: self.k]
return sum(y for _, y in nearest) / self.k
# Polymorphism: same interface, different algorithms
training_data = [(1, 2), (2, 4), (3, 5), (4, 8), (5, 10)]
models = [LinearRegression(), KNearestNeighbors(k=2)]
for model in models:
model.train(training_data)
prediction = model.predict(6)
print(f" {model.name} predicts f(6) = {prediction:.2f}\n")class BankAccount:
"""A bank account with controlled access to the balance."""
def __init__(self, owner, initial_balance=0):
self.owner = owner
self._balance = initial_balance # underscore = "private by convention"
self._transactions = []
def deposit(self, amount):
"""Add money to the account."""
if amount <= 0:
raise ValueError("Deposit amount must be positive")
self._balance += amount
self._transactions.append(f"+{amount}")
return self._balance
def withdraw(self, amount):
"""Remove money from the account."""
if amount <= 0:
raise ValueError("Withdrawal amount must be positive")
if amount > self._balance:
raise ValueError(f"Insufficient funds (balance: {self._balance})")
self._balance -= amount
self._transactions.append(f"-{amount}")
return self._balance
def get_balance(self):
"""Read the balance safely."""
return self._balance
def get_statement(self):
"""Return transaction history."""
return f"Account: {self.owner} | Balance: {self._balance} | Transactions: {self._transactions}"
account = BankAccount("Alice", 1000)
account.deposit(500)
account.withdraw(200)
print(account.get_statement())
# Account: Alice | Balance: 1300 | Transactions: ['+500', '-200']_balance is a Python convention meaning "this is internal -- do not access it directly from outside." Unlike Java or C++, Python does not enforce private access. It trusts developers to respect the convention.| Use a class when... | Use a function when... |
|---|---|
| You have data + behavior together | You have a single, stateless operation |
| Multiple instances with different state | No need to track state between calls |
| You need inheritance or polymorphism | The logic is straightforward and standalone |
| Building a library or framework component | Writing a utility or transformation |
Tests · Create subclasses, test inheritance, and verify polymorphism works!
@property decorator lets you define methods that behave like attributes — called when you access obj.attribute without parentheses:class Circle:
def __init__(self, radius):
self._radius = radius # private by convention (single underscore)
@property
def radius(self):
"""Getter — called when you access circle.radius"""
return self._radius
@radius.setter
def radius(self, value):
"""Setter — called when you write circle.radius = 5"""
if value < 0:
raise ValueError("Radius cannot be negative")
self._radius = value
@radius.deleter
def radius(self):
"""Deleter — called when you write del circle.radius"""
del self._radius
@property
def area(self):
"""Computed property — no setter, read-only"""
import math
return math.pi * self._radius ** 2
@property
def diameter(self):
return self._radius * 2
c = Circle(5)
print(c.radius) # 5 — calls the getter
print(c.area) # 78.53... — computed on access, no ()
print(c.diameter) # 10
c.radius = 10 # calls the setter (with validation!)
# c.radius = -1 # ValueError: Radius cannot be negative
# c.area = 100 # AttributeError: can't set attribute (no setter)radius, you add a @radius.setter — callers do not need to change their code.class Temperature:
def __init__(self, celsius):
self.celsius = celsius
# Instance method — has access to self (the instance)
def to_fahrenheit(self):
return self.celsius * 9/5 + 32
# Class method — has access to cls (the class itself), not an instance
# Used for alternative constructors
@classmethod
def from_fahrenheit(cls, fahrenheit):
celsius = (fahrenheit - 32) * 5/9
return cls(celsius) # creates a new instance using the class
@classmethod
def from_kelvin(cls, kelvin):
return cls(kelvin - 273.15)
# Static method — no access to self or cls
# Just a regular function that lives in the class namespace
@staticmethod
def is_valid_celsius(value):
return value >= -273.15 # absolute zero
def __repr__(self):
return f"Temperature({self.celsius:.2f}°C)"
# Instance method
t = Temperature(100)
print(t.to_fahrenheit()) # 212.0
# Class method — alternative constructor (no instance needed!)
t2 = Temperature.from_fahrenheit(32)
print(t2) # Temperature(0.00°C)
t3 = Temperature.from_kelvin(373.15)
print(t3) # Temperature(100.00°C)
# Static method
print(Temperature.is_valid_celsius(-300)) # False
print(Temperature.is_valid_celsius(0)) # Trueself): needs to read or modify instance state — the most common typecls): alternative constructors, factory methods, or operations on the class itself__dict__ (a dictionary) per object. For a class with millions of instances, this overhead adds up:class PointNormal:
def __init__(self, x, y):
self.x = x
self.y = y
class PointSlots:
__slots__ = ['x', 'y'] # pre-declare allowed attributes
def __init__(self, x, y):
self.x = x
self.y = y
import sys
p1 = PointNormal(1, 2)
p2 = PointSlots(1, 2)
print(sys.getsizeof(p1)) # ~152 bytes (includes __dict__)
print(sys.getsizeof(p2)) # ~56 bytes (no __dict__, fixed array)
# Can't add new attributes with __slots__:
p1.z = 3 # OK for normal class
# p2.z = 3 # AttributeError: 'PointSlots' object has no attribute 'z'__slots__ when you will create millions of instances (e.g., machine learning training samples, game entities, graph nodes). The trade-off: no dynamic attribute addition.__init__, methods, and inheritance — here's what you'll actually write:# sklearn transformers use fit() and transform() — your class can too
from sklearn.base import BaseEstimator, TransformerMixin
class TextLengthFeature(BaseEstimator, TransformerMixin):
"""Extract character count as a numeric feature."""
def fit(self, X, y=None):
return self # Nothing to learn from data
def transform(self, X):
return [[len(text)] for text in X]
extractor = TextLengthFeature()
print(extractor.transform(["hello world", "hi", "how are you?"]))
# [[11], [2], [12]]# PyTorch models inherit from nn.Module — same pattern you just learned
import torch.nn as nn
class SimpleClassifier(nn.Module):
def __init__(self, input_size: int, num_classes: int):
super().__init__() # ← always call parent __init__
self.layer1 = nn.Linear(input_size, 64)
self.layer2 = nn.Linear(64, num_classes)
self.relu = nn.ReLU()
def forward(self, x): # ← called when you do model(x)
x = self.relu(self.layer1(x))
return self.layer2(x)
model = SimpleClassifier(input_size=784, num_classes=10)__init__, self, method definitions, inheritance. Every ML model you'll ever write is a class.class Dog: defines the blueprint, buddy = Dog("Buddy") creates an actual dog object with specific values__init__ is the constructor -- it runs automatically when you create a new object. Use self.attribute = value to store data on the instanceself refers to the current object -- it is how methods access the object's own attributes and other methods. Every instance method must have self as its first parameterclass Dog(Animal): means Dog inherits everything from Animal and can add or override behavior. Use super() to call the parent's methodspet.speak() runs different code depending on whether pet is a Dog, Cat, or Bird. This is the foundation of how AI frameworks let you swap models seamlesslyWhat is the purpose of __init__ in a Python class?