01. Python Refresher — Classes, Exceptions, Timing, Recursion

2413 words
12 min read
Python Week 1: the first filter for runtime behavior
Visual companion
Python
Type and operator map

Python Week 1: the first filter for runtime behavior

View
Revision summary

What this note is really saying

Short form

# 01. Python Refresher — Classes, Exceptions, Timing, Recursion > **What problem does this solve?** This course assumes you can write Python.

01. Python Refresher — Classes, Exceptions, Timing, Recursion

What problem does this solve? This course assumes you can write Python. This module bridges the gap between basic Python and the DSA-focused Python used throughout the rest of the course: classes for implementing data structures, exception handling for robust code, timing for performance measurement, and recursion as the foundation of divide-and-conquer algorithms.

1. Classes & Objects — The Building Blocks of Data Structures

Mental Model

A class is a blueprint. An object is an instance of that blueprint — it lives in memory with its own copy of the class's data. Think of a class as a cookie cutter and objects as the cookies.

Syntax Reference

python
# runnable
class Node:
    """A node in a linked list."""
    def __init__(self, data):
        self.data = data      # instance variable
        self.next = None      # default value
    def __repr__(self):
        return f"Node({self.data})"
# Create objects
n1 = Node(10)
n2 = Node(20)
n1.next = n2
print(n1)       # Node(10)
print(n1.next)  # Node(20)

5 Progressively Complex Examples

Example 1: Simple Counter Class
python
# runnable
class Counter:
    def __init__(self):
        self.count = 0
    def increment(self):
        self.count += 1
    def reset(self):
        self.count = 0
    def value(self):
        return self.count
c = Counter()
c.increment()
c.increment()
print(c.value())  # 2
c.reset()
print(c.value())  # 0
Example 2: Stack Class (LIFO)
python
# runnable
class Stack:
    def __init__(self):
        self._items = []  # underscore = "private by convention"
    def push(self, item):
        self._items.append(item)
    def pop(self):
        if self.is_empty():
            raise IndexError("pop from empty stack")
        return self._items.pop()
    def peek(self):
        if self.is_empty():
            raise IndexError("peek from empty stack")
        return self._items[-1]
    def is_empty(self):
        return len(self._items) == 0
    def __len__(self):
        return len(self._items)
s = Stack()
s.push(1)
s.push(2)
s.push(3)
print(s.pop())      # 3
print(len(s))       # 2
print(s.peek())     # 2
Example 3: Class with __str__ and __repr__
python
# runnable
class Point:
    def __init__(self, x, y):
        self.x = x
        self.y = y
    def __repr__(self):
        return f"Point({self.x}, {self.y})"
    def __str__(self):
        return f"({self.x}, {self.y})"
    def distance_from_origin(self):
        return (self.x**2 + self.y**2) ** 0.5
p = Point(3, 4)
print(repr(p))   # Point(3, 4)
print(str(p))    # (3, 4)
print(p)         # (3, 4) — calls __str__
print(p.distance_from_origin())  # 5.0
Example 4: Inheritance for Specialized Nodes
python
# runnable
class TreeNode:
    def __init__(self, value):
        self.value = value
        self.left = None
        self.right = None
class BSTNode(TreeNode):
    """TreeNode with BST property."""
    def insert(self, value):
        if value < self.value:
            if self.left is None:
                self.left = BSTNode(value)
            else:
                self.left.insert(value)
        elif value > self.value:
            if self.right is None:
                self.right = BSTNode(value)
            else:
                self.right.insert(value)
        # Equal → don't insert (no duplicates)
root = BSTNode(10)
root.insert(5)
root.insert(15)
root.insert(3)
print(root.left.value)    # 5
print(root.left.left.value)  # 3
Example 5: Dunder Methods for a Custom List-like Class
python
# runnable
class ArrayList:
    def __init__(self):
        self._data = []
    def __getitem__(self, index):
        return self._data[index]
    def __setitem__(self, index, value):
        self._data[index] = value
    def __len__(self):
        return len(self._data)
    def append(self, value):
        self._data.append(value)
    def __repr__(self):
        return f"ArrayList({self._data})"
arr = ArrayList()
arr.append(10)
arr.append(20)
arr.append(30)
print(arr[1])      # 20
arr[1] = 99
print(arr)         # ArrayList([10, 99, 30])
print(len(arr))    # 3

Comparison: Class vs dict vs namedtuple

FeatureClassdictnamedtuple
Attribute accessobj.fieldd["key"]obj.field
Method definitionsYesNoNo
MutableYesYesNo (tuple-like)
Memory overheadModerateLowLow
SerializationManualBuilt-in JSONManual
Best forDS with behaviorSimple key-valueLightweight records

Common Bugs

python
# BUG 1: Forgetting self
class Bad:
    def __init__(self, x):
        self.x = x
    def double():  # Missing self!
        return self.x * 2
# TypeError: double() takes 0 positional arguments but 1 was given
# FIX:
class Good:
    def __init__(self, x):
        self.x = x
    def double(self):
        return self.x * 2
# BUG 2: Mutable default arguments
class BuggyStack:
    def __init__(self, items=[]):  # Same list shared by all instances!
        self.items = items
# >>> a = BuggyStack(); b = BuggyStack()
# >>> a.items.append(1); print(b.items)  # [1] — WRONG!
# FIX:
class CorrectStack:
    def __init__(self, items=None):
        self.items = items if items is not None else []
# BUG 3: Modifying class variable through instance creates instance variable
class Node:
    count = 0  # class variable
    def __init__(self):
        Node.count += 1
n1 = Node()
n2 = Node()
n1.count = 99  # Creates INSTANCE variable — doesn't change class var
print(Node.count)  # 2 (correct count)
print(n1.count)    # 99 (shadowed)

2. Exception Handling

Mental Model

When an error occurs, Python raises an exception. If you don't catch it, the program crashes. You catch exceptions with try/except blocks, similar to how you'd catch a ball thrown at you.

Syntax Reference

python
try:
    risky_operation()
except SomeError as e:
    # Handle the error
    print(f"Error: {e}")
finally:
    # Always runs (cleanup)
    cleanup_code()

5 Examples

Example 1: Basic try/except
python
# runnable
def divide(a, b):
    try:
        result = a / b
        return result
    except ZeroDivisionError:
        return "Cannot divide by zero!"
print(divide(10, 2))   # 5.0
print(divide(10, 0))   # Cannot divide by zero!
Example 2: Multiple Exception Types
python
# runnable
def safe_int_conversion(value):
    try:
        return int(value)
    except ValueError:
        return f"'{value}' is not a valid integer"
    except TypeError:
        return f"'{value}' has wrong type ({type(value).__name__})"
print(safe_int_conversion("42"))    # 42
print(safe_int_conversion("abc"))   # 'abc' is not a valid integer
print(safe_int_conversion([1,2]))   # '[1, 2]' has wrong type (list)
Example 3: try/except/else/finally
python
# runnable
def read_config(filename):
    try:
        f = open(filename, 'r')
    except FileNotFoundError:
        return None
    else:
        # Only runs if no exception occurred
        content = f.read()
        return content
    finally:
        # Always runs
        if 'f' in locals() and not f.closed:
            f.close()
print(read_config("nonexistent.txt"))  # None
Example 4: Raising Custom Exceptions
python
# runnable
class EmptyStackError(Exception):
    """Raised when trying to pop from an empty stack."""
    pass
class Stack:
    def __init__(self):
        self._data = []
    def pop(self):
        if not self._data:
            raise EmptyStackError("Cannot pop from empty stack")
        return self._data.pop()
s = Stack()
try:
    s.pop()
except EmptyStackError as e:
    print(f"Error: {e}")  # Error: Cannot pop from empty stack
Example 5: Context Manager (with statement)
python
# runnable
# The 'with' statement automatically handles cleanup
with open("demo.txt", "w") as f:
    f.write("Hello, exceptions!")
# File is automatically closed even if an error occurs
with open("demo.txt", "r") as f:
    print(f.read())  # Hello, exceptions!

Common Bugs

python
# BUG 1: Catching too broadly
try:
    user_input = input("Enter a number: ")
    result = 10 / int(user_input)
except Exception:  # Catches EVERYTHING — hides bugs
    pass
# Never know what went wrong!
# FIX: Be specific
try:
    result = 10 / int(user_input)
except ValueError:
    print("Not a number!")
except ZeroDivisionError:
    print("Can't divide by zero!")
# BUG 2: Forgetting that except order matters
try:
    risky_code()
except Exception:
    print("Caught")
except ValueError:  # This is unreachable!
    print("ValueError")
# ValueError is a subclass of Exception, caught by first block
# BUG 3: Not finally for resource cleanup
f = open("file.txt")
try:
    f.read()
except:
    return  # Returns without closing f!
finally:
    f.close()  # This always runs

3. Timing Code Execution

Mental Model

To measure an algorithm's performance, we don't use wall-clock time (too variable). Instead, we count basic operations or use Python's timeit module which runs code multiple times and gives statistical results.

Syntax Reference

python
import time
start = time.time()
# code to time
end = time.time()
elapsed = end - start
# OR using timeit (more accurate):
import timeit
timeit.timeit("code_string", number=1000)

3 Examples

Example 1: Manual Timing
python
# runnable
import time
def sum_upto_n(n):
    total = 0
    for i in range(n + 1):
        total += i
    return total
start = time.time()
result = sum_upto_n(1000000)
end = time.time()
print(f"Result: {result}, Time: {end - start:.4f} seconds")
Example 2: Using timeit
python
# runnable
import timeit
# Time list creation
setup = "n = 1000"
stmt1 = "[i**2 for i in range(n)]"
stmt2 = "list(map(lambda i: i**2, range(n)))"
t1 = timeit.timeit(stmt1, setup, number=10000)
t2 = timeit.timeit(stmt2, setup, number=10000)
print(f"List comprehension: {t1:.3f}s")
print(f"map + lambda: {t2:.3f}s")
Example 3: Timing Sorting Algorithms (Preview)
python
# runnable
import time, random
def timer(func, arr):
    start = time.time()
    func(arr)
    return time.time() - start
data = [random.randint(0, 1000) for _ in range(1000)]
# We'll see these sorting algorithms in detail later

4. Recursion — The Foundation of DSA

Mental Model

Recursion is when a function calls itself to solve a smaller instance of the same problem. Like Russian nesting dolls: each doll contains a smaller version of itself. Every recursive function needs:
  1. Base case — when to stop
  2. Recursive case — the function calls itself on a smaller input

Syntax Reference

python
def recursive_function(n):
    # Base case
    if n <= 1:
        return 1
    # Recursive case
    return n * recursive_function(n - 1)

5 Progressively Complex Examples

Example 1: Factorial
python
# runnable
def factorial(n):
    """Return n! = n * (n-1) * ... * 1"""
    if n <= 1:          # Base case
        return 1
    return n * factorial(n - 1)  # Recursive case
print(factorial(5))  # 120
# Trace:
# factorial(5) = 5 * factorial(4)
#             = 5 * 4 * factorial(3)
#             = 5 * 4 * 3 * factorial(2)
#             = 5 * 4 * 3 * 2 * factorial(1)
#             = 5 * 4 * 3 * 2 * 1 = 120
Example 2: Fibonacci (Inefficient)
python
# runnable
def fib(n):
    """Return the nth Fibonacci number (0-indexed)."""
    if n <= 1:
        return n
    return fib(n - 1) + fib(n - 2)
print(fib(10))  # 55
# Problem: fib(5) calls fib(4) and fib(3), which overlap massively!
# Complexity: O(2^n) — exponential! We'll fix this with DP later.
Example 3: Binary Search (Recursive)
python
# runnable
def binary_search(arr, target, left=0, right=None):
    """Return index of target in sorted arr, or -1 if not found."""
    if right is None:
        right = len(arr) - 1
    if left > right:          # Base case: empty range
        return -1
    mid = (left + right) // 2
    if arr[mid] == target:    # Found
        return mid
    elif arr[mid] > target:   # Search left half
        return binary_search(arr, target, left, mid - 1)
    else:                      # Search right half
        return binary_search(arr, target, mid + 1, right)
print(binary_search([1, 3, 5, 7, 9, 11, 13], 7))   # 3
print(binary_search([1, 3, 5, 7, 9, 11, 13], 4))   # -1
Example 4: Towers of Hanoi
python
# runnable
def hanoi(n, source, target, auxiliary):
    """Move n disks from source to target using auxiliary."""
    if n == 1:
        print(f"Move disk 1 from {source} to {target}")
        return
    hanoi(n - 1, source, auxiliary, target)
    print(f"Move disk {n} from {source} to {target}")
    hanoi(n - 1, auxiliary, target, source)
print("Solution for 3 disks:")
hanoi(3, 'A', 'C', 'B')
# Output shows all 7 moves for 3 disks
Example 5: Recursive vs Iterative — Call Stack Visualization
python
# runnable
import sys
def recursive_sum(n):
    """Recursive sum of 0..n."""
    if n == 0:
        return 0
    return n + recursive_sum(n - 1)
def iterative_sum(n):
    """Iterative sum of 0..n."""
    total = 0
    for i in range(n + 1):
        total += i
    return total
print(f"Recursive: {recursive_sum(100)}")  # 5050
print(f"Iterative: {iterative_sum(100)}")  # 5050
# Note: recursive_sum(1000) might hit RecursionError due to stack depth!

Comparison: Recursion vs Iteration

AspectRecursionIteration
Code clarityElegant for self-similar problemsStraightforward
MemoryUses call stack (O(n) memory)Usually O(1) memory
PerformanceFunction call overheadFaster (no overhead)
Infinite loopsStack overflow (crash)Infinite loop (hang)
Base caseNeeded to terminateLoop condition
Best forTrees, graphs, divide & conquerLinear operations

Common Bugs

python
# BUG 1: Missing base case — infinite recursion
def bad_factorial(n):
    return n * bad_factorial(n - 1)  # No base case!
# >>> bad_factorial(5)
# RecursionError: maximum recursion depth exceeded
# BUG 2: Base case never reached
def bad_power(x, n):
    if n == 1:
        return x
    return x * bad_power(x, n)  # n never decreases!
# BUG 3: Not returning the recursive result
def bad_sum(n):
    if n <= 1:
        return n
    bad_sum(n - 1) + n  # Missing return!
    # Returns None for n > 1

Practice Questions

Q1. Write a class BankAccount with deposit(), withdraw(), and get_balance() methods. Include proper exception handling for insufficient funds. Q2. What is the output?
python
def mystery(n):
    if n <= 0:
        return 0
    return n + mystery(n - 2)
print(mystery(6))
Q3. Fix the bug:
python
class Team:
    members = []  # What's wrong?
    def __init__(self, name):
        self.name = name
    def add_member(self, person):
        self.members.append(person)
Q4. Write a recursive function is_palindrome(s) that checks if a string reads the same forward and backward. Q5. Time the following two functions using timeit:
python
def method1(n):
    return sum(range(n))
def method2(n):
    return n*(n-1)//2
Which is faster and why? Q6. What does this exception handler print?
python
try:
    print(1/0)
except ZeroDivisionError:
    print("A")
except ArithmeticError:
    print("B")
except:
    print("C")
Q7. Write a class Queue using a list with enqueue(item) and dequeue() methods. Raise IndexError on empty dequeue. Q8. Trace the recursive calls for hanoi(2, 'A', 'C', 'B'). Q9. Convert this recursive function to iterative:
python
def countdown(n):
    if n <= 0:
        return
    print(n)
    countdown(n - 1)
Q10. Explain why recursive_sum(2000) might fail while iterative_sum(2000) works fine.
Answers
A1.
python
class BankAccount:
    def __init__(self):
        self._balance = 0

    def deposit(self, amount):
        if amount <= 0:
            raise ValueError("Deposit must be positive")
        self._balance += amount

    def withdraw(self, amount):
        if amount <= 0:
            raise ValueError("Withdrawal must be positive")
        if amount > self._balance:
            raise ValueError("Insufficient funds")
        self._balance -= amount

    def get_balance(self):
        return self._balance
A2. 12 (6 + 4 + 2 + 0 = 12)
A3. members is a class variable shared by all instances. Move it into __init__ as self.members = [].
A4.
python
def is_palindrome(s):
    if len(s) <= 1:
        return True
    if s[0] != s[-1]:
        return False
    return is_palindrome(s[1:-1])
A5. method2 is O(1), method1 is O(n). For large n, method2 is dramatically faster.
A6. A — ZeroDivisionError is caught first. ArithmeticError is its parent class but the child exception handler matched first.
A7.
python
class Queue:
    def __init__(self):
        self._data = []
    def enqueue(self, item):
        self._data.append(item)
    def dequeue(self):
        if not self._data:
            raise IndexError("dequeue from empty queue")
        return self._data.pop(0)
A8.
sql
Move disk 1 from A to B
Move disk 2 from A to C
Move disk 1 from B to C
A9.
python
def countdown_iter(n):
    while n > 0:
        print(n)
        n -= 1
A10. Python has a recursion limit (default ~1000). Each recursive call consumes stack frame memory. recursive_sum(2000) exceeds this limit. The iterative version uses a single while loop with no stack growth. Join Discord Next02. Algorithm Analysis & Big-O Notation
Document outline

Keep your place and jump directly to a heading.

Table of contents

No note selected

Open a chapter to see its outline, backlinks, and sources here.