🔄 Quick Recap (Day 22)

  • You created charts with Matplotlib (line, bar, scatter) and saved them for reports.

🎯 What You’ll Learn Today

  1. The iterator protocol: iterables vs. iterators, iter(), next(), and StopIteration.

  2. Custom iterators by implementing __iter__ and __next__.

  3. Generators: generator functions with yield, yield from, and generator expressions.

  4. Closures: functions that remember values, plus nonlocal for stateful inner functions.

📖 Iterables, Iterators, and the Protocol

  • An iterable is any object you can loop over (e.g., list, tuple, dict).

  • An iterator is an object with __iter__() (returns itself) and __next__() (returns the next value or raises StopIteration).

nums = [10, 20, 30]
itr = iter(nums)          # get an iterator
print(next(itr))          # 10
print(next(itr))          # 20
print(next(itr))          # 30
# next(itr) would now raise StopIteration

The for loop calls iter() and repeatedly calls next() under the hood.

📖 Building a Custom Iterator Class

Create your own iterator by defining __iter__ and __next__.

class Countdown:
    def __init__(self, n):
        self.n = n

    def __iter__(self):
        return self

    def __next__(self):
        if self.n <= 0:
            raise StopIteration
        current = self.n
        self.n -= 1
        return current

for x in Countdown(3):
    print(x)
# 3\n2\n1

Use cases: pagination over API pages, streaming sensor readings, or controlled iteration with custom stop conditions.

📖 Generators: Simpler, Lazier Iterators

A generator function uses yield to produce values one at a time, keeping its state between calls.

def evens(limit):
    n = 0
    while n <= limit:
        if n % 2 == 0:
            yield n
        n += 1

for e in evens(6):
    print(e)
# 0 2 4 6

Generator Expressions

Compact, lazy sequences using parentheses:

squares = (x * x for x in range(5))
print(next(squares))  # 0
print(next(squares))  # 1

Delegating with yield from

def chain(a, b):
    yield from a
    yield from b

print(list(chain([1, 2], (3, 4))))  # [1, 2, 3, 4]

Why generators? Memory efficiency (no big lists), composability (pipelines), and clarity for streaming or large data.

📖 Closures: Functions That Remember

A closure is an inner function that captures variables from an enclosing scope.

def make_multiplier(factor):
    def mul(x):
        return x * factor   # remembers factor
    return mul

times3 = make_multiplier(3)
print(times3(5))  # 15

Stateful Closures with nonlocal

def make_counter():
    count = 0
    def inc():
        nonlocal count
        count += 1
        return count
    return inc

c = make_counter()
print(c())  # 1
print(c())  # 2

Tip (late binding gotcha): In loops, lambda/inner functions capture variables by reference. To freeze a value, use a default arg: lambda x, i=i: (x + i).

🧙‍♂️ Take the Wand and Try Yourself

Create adv_funcs.py and complete the tasks below.

  1. Custom Iterator
    Implement Countdown(n) that iterates n, n-1, ..., 1.

class Countdown:
    def __init__(self, n):
        self.n = n
    def __iter__(self):
        return self
    def __next__(self):
        if self.n <= 0:
            raise StopIteration
        cur = self.n
        self.n -= 1
        return cur

for i in Countdown(3):
    print(i)

Expected output:

3
2
1
  1. Generators
    Write evens(limit) and a generator expression of their squares. Print the first three squares with next().

def evens(limit):
    n = 0
    while n <= limit:
        if n % 2 == 0:
            yield n
        n += 1

squares = (x*x for x in evens(10))
print(next(squares))  # 0
print(next(squares))  # 4
print(next(squares))  # 16

Expected output:

0
4
16
  1. Closures
    Create make_multiplier(factor) and make_counter() using nonlocal.

def make_multiplier(factor):
    def mul(x):
        return x * factor
    return mul

def make_counter():
    count = 0
    def inc():
        nonlocal count
        count += 1
        return count
    return inc

m = make_multiplier(3)
print(m(7))     # 21
c = make_counter()
print(c(), c()) # 1 2

Expected output:

21
1 2
  1. Bonus: Generator Pipeline
    Compose lazy stages to process numbers 1–20 → keep evens → square → print results.

def numbers():
    for i in range(1, 21):
        yield i

def only_even(seq):
    for n in seq:
        if n % 2 == 0:
            yield n

def square(seq):
    for n in seq:
        yield n*n

for val in square(only_even(numbers())):
    print(val)

Expected output (excerpt):

4
16
36
64
100
144
196
256
324
400

Run:

python adv_funcs.py

Once your outputs match, you’ve mastered iterators, generators, and closures—and you’re ready to build efficient, elegant pipelines.

Up next: Day 24: Testing & Debugging — learn unittest/pytest basics and practical debugging strategies.

Keep Reading