What’s one thing you learned? What’s still confusing?
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.
Python's Data Model: Dunder Methods & Operator Overloading
Implement __repr__, __eq__, __hash__, __len__, __getitem__, __iter__, __enter__/__exit__, __call__.
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
__init__, instance methods, encapsulated state, and tracking a list of events on a single object.BankAccount class with deposit, withdraw, balance, and a full transaction history. Then a transfer method that moves money between two accounts atomically -- the first real "method that interacts with another object" you'll write.>>> alice = BankAccount("Alice", 100)
>>> alice.deposit(50)
>>> alice.withdraw(30)
>>> alice.balance
120
>>> alice.history
['opened with 100', 'deposit 50', 'withdraw 30']
>>> bob = BankAccount("Bob", 0)
>>> alice.transfer(bob, 70)
>>> alice.balance, bob.balance
(50, 70)
__init__ and instance attributes__init__ is the constructor -- it runs when you do BankAccount(...).class BankAccount:
def __init__(self, owner, balance=0):
self.owner = owner
self.balance = balance
alice = BankAccount("Alice", 100)
print(alice.owner) # Alice
print(alice.balance) # 100self is the instance being created. self.owner = owner stores the argument as an attribute on the instance. Every method you write on this class will receive self as its first argument.What does `BankAccount('Alice', 100)` create?
self as its first parameter -- that's how it knows WHICH account to operate on.class BankAccount:
def __init__(self, owner, balance=0):
self.owner = owner
self.balance = balance
def deposit(self, amount):
self.balance += amount
alice = BankAccount("Alice", 100)
alice.deposit(50)
print(alice.balance) # 150alice.deposit(50) is sugar for BankAccount.deposit(alice, 50). Python passes alice as self automatically.Don't let the balance go negative. Return True if the withdrawal succeeded, False if it failed.
class BankAccount:
def __init__(self, owner, balance=0):
self.owner = owner
self.balance = balance
def deposit(self, amount):
self.balance += amount
def withdraw(self, amount):
if amount > self.balance:
return False
self.balance -= amount
return True
alice = BankAccount("Alice", 100)
print(alice.withdraw(50)) # True
print(alice.withdraw(500)) # False (too big)
print(alice.balance) # 50This is a tiny but important design decision: methods can ENFORCE invariants. The class promises balance never goes negative -- callers can rely on that without checking themselves.
Why return True/False from withdraw instead of just printing 'Insufficient funds'?
self.history = [] in __init__. Append a description string on every operation.class BankAccount:
def __init__(self, owner, balance=0):
self.owner = owner
self.balance = balance
self.history = [f"opened with {balance}"]
def deposit(self, amount):
self.balance += amount
self.history.append(f"deposit {amount}")
def withdraw(self, amount):
if amount > self.balance:
self.history.append(f"FAILED withdraw {amount}")
return False
self.balance -= amount
self.history.append(f"withdraw {amount}")
return True
alice = BankAccount("Alice", 100)
alice.deposit(50)
alice.withdraw(30)
print(alice.history)
# ['opened with 100', 'deposit 50', 'withdraw 30']Even failed attempts are logged -- which is exactly what real banks do for audit purposes.
transfer is the first method that uses ANOTHER instance. It calls withdraw on self, and if that succeeds, deposit on the other account.class BankAccount:
# ... previous methods ...
def transfer(self, other, amount):
if self.withdraw(amount):
other.deposit(amount)
self.history.append(f"transferred {amount} to {other.owner}")
other.history.append(f"received {amount} from {self.owner}")
return True
return False
alice = BankAccount("Alice", 100)
bob = BankAccount("Bob", 0)
alice.transfer(bob, 70)
print(alice.balance, bob.balance) # 30 70
print(bob.history)
# ['opened with 0', 'deposit 70', 'received 70 from Alice']transfer reuses withdraw and deposit rather than directly touching balances. That's encapsulation in action -- the invariants stay safe and the logic stays in one place.Build a `BankAccount` class with: `__init__(owner, balance=0)`, `deposit(amount)`, `withdraw(amount) -> bool`, `transfer(other, amount) -> bool`, and a `history` list that logs every operation (including failed withdrawals). Withdraw must refuse if balance < amount. Transfer must be all-or-nothing: if the withdraw fails, no deposit happens.
Alice balance: 100
Bob balance: 0
Alice transfers 30 to Bob
Alice balance: 70
Bob balance: 30
Bob tries to withdraw 500 -- success? False
Bob history: ['opened with 0', 'received 30 from Alice', 'FAILED withdraw 500']class BankAccount:
def __init__(self, owner, balance=0):
# TODO: store owner, balance
# TODO: start history with 'opened with {balance}'
pass
def deposit(self, amount):
# TODO: add to balance, log to history
pass
def withdraw(self, amount):
# TODO: if amount > balance: log FAILED, return False
# TODO: else: subtract, log, return True
pass
def transfer(self, other, amount):
# TODO: if self.withdraw(amount) succeeds, other.deposit(amount)
# TODO: log the transfer on BOTH sides
pass
alice = BankAccount("Alice", 100)
bob = BankAccount("Bob", 0)
print(f"Alice balance: {alice.balance}")
print(f"Bob balance: {bob.balance}")
print("Alice transfers 30 to Bob")
alice.transfer(bob, 30)
print(f"Alice balance: {alice.balance}")
print(f"Bob balance: {bob.balance}")
success = bob.withdraw(500)
print(f"Bob tries to withdraw 500 -- success? {success}")
print(f"Bob history: {bob.history}")
Try one of these variations:
accrue_interest(rate) method that multiplies balance by (1 + rate). Log it in history. Apply it to a list of accounts in a single for loop.overdraft=0 parameter. Withdraw is allowed as long as the resulting balance is >= -overdraft. Log overdraft usage separately.SavingsAccount(BankAccount) that adds a monthly interest method, and CheckingAccount(BankAccount) that adds a transfer fee. Override or extend __init__ as needed.-> bool and amount: float to the methods makes the class self-documenting and lets editors catch mistakes before you run the code. Then pytest lets you lock in the "balance never goes negative" promise with automated tests.