Skip to main content

Python for Loops Internals Practice Problems & Exercises

Practice: for Loops Internals

12 problems4 Easy5 Medium3 Hard45–60 min
← Back to lesson

Easy

#1Enumerate with Custom StartEasy
enumerateindexingfor-loop

Use enumerate() with a custom start index to print each fruit with a number starting at 3.

Python
fruits = ["cherry", "date", "elderberry", "fig"]

for i, fruit in enumerate(fruits, start=3):
    print(f"{i}: {fruit}")
Solution
fruits = ["cherry", "date", "elderberry", "fig"]

for i, fruit in enumerate(fruits, start=3):
print(f"{i}: {fruit}")

Output:

3: cherry
4: date
5: elderberry
6: fig

How it works: enumerate(iterable, start=0) yields (index, element) tuples. The start parameter shifts the counter but does not skip elements — it simply changes what number the counter begins at. This is useful for 1-based indexing, page numbering, or continuing a count from a previous batch.

Key insight: enumerate does not slice or skip. start=3 means the first element gets index 3, the second gets 4, and so on. Every element in the iterable is still visited.

Expected Output
3: cherry\n4: date\n5: elderberry\n6: fig
Hints

Hint 1: enumerate() accepts a second argument `start` that sets the initial counter value.

Hint 2: The default start is 0, but you can pass any integer.

#2Zip Two Lists TogetherEasy
zipparallel-iterationtuples

Use zip() to pair each student with their score and print the result.

Python
students = ["Alice", "Bob", "Carla", "Dan"]
scores = [88, 92, 75, 81]

for student, score in zip(students, scores):
    print(f"{student} scored {score}")
Solution
students = ["Alice", "Bob", "Carla", "Dan"]
scores = [88, 92, 75, 81]

for student, score in zip(students, scores):
print(f"{student} scored {score}")

Output:

Alice scored 88
Bob scored 92
Carla scored 75
Dan scored 81

How it works: zip(a, b) creates a lazy iterator that yields tuples (a[0], b[0]), (a[1], b[1]), etc. Under the hood, it calls next() on each iterable in lockstep and stops as soon as any iterable is exhausted.

Key insight: zip is lazy — it does not build a list of tuples in memory. It yields one tuple at a time, making it memory-efficient even for very large iterables. If lengths differ, zip silently truncates to the shortest. Use itertools.zip_longest when you need to handle unequal lengths.

Expected Output
Alice scored 88\nBob scored 92\nCarla scored 75\nDan scored 81
Hints

Hint 1: zip() takes two or more iterables and yields tuples of corresponding elements.

Hint 2: zip() stops at the shortest iterable — no IndexError if lengths differ.

#3Iterate Over Dict Keys, Values, and ItemsEasy
dictiterationkeysvaluesitems

Iterate over a dictionary three different ways: by keys, by values, and by items.

Python
person = {"name": "Alice", "age": 30, "city": "Portland"}

# Iterate over keys
print("Keys:", " ".join(str(k) for k in person.keys()))

# Iterate over values
print("Values:", " ".join(str(v) for v in person.values()))

# Iterate over items (key-value pairs)
items_str = " ".join(f"{k}={v}" for k, v in person.items())
print(f"Items: {items_str}")
Solution
person = {"name": "Alice", "age": 30, "city": "Portland"}

print("Keys:", " ".join(str(k) for k in person.keys()))
print("Values:", " ".join(str(v) for v in person.values()))
items_str = " ".join(f"{k}={v}" for k, v in person.items())
print(f"Items: {items_str}")

Output:

Keys: name age city
Values: Alice 30 Portland
Items: name=Alice age=30 city=Portland

How it works: Python dictionaries provide three view objects:

  • dict.keys() — a dynamic view of the dictionary's keys. Iterating over the dict directly (for k in person:) does the same thing.
  • dict.values() — a dynamic view of values. No way to get the corresponding key from this view alone.
  • dict.items() — a dynamic view of (key, value) tuples, which is the most useful for most tasks.

These are views, not copies. They reflect changes to the dictionary in real time. Since Python 3.7, dictionaries maintain insertion order, so iteration order is guaranteed to match insertion order.

Key insight: Use .items() when you need both key and value. Use .keys() or direct iteration when you only need keys. Avoid calling .keys() explicitly when iterating — for k in person: is idiomatic.

Expected Output
Keys: name age city\nValues: Alice 30 Portland\nItems: name=Alice age=30 city=Portland
Hints

Hint 1: dict.keys() returns a view of keys, dict.values() returns values, dict.items() returns (key, value) tuples.

Hint 2: Iterating over a dict directly is equivalent to iterating over its keys.

#4Range with StepEasy
rangestepfor-loop

Use range() with a negative step to count down from 10 to 1, stepping by -3.

Python
for n in range(10, 0, -3):
    print(n)
Solution
for n in range(10, 0, -3):
print(n)

Output:

10
7
4
1

How it works: range(10, 0, -3) starts at 10 and subtracts 3 each step. The sequence is 10, 7, 4, 1. The next value would be -2, which is less than the stop value 0 (when stepping down, range stops when the value goes below stop), so iteration ends.

The math behind range length: For range(start, stop, step), the number of elements is max(0, ceil((stop - start) / step)). Here: ceil((0 - 10) / -3) = ceil(3.33) = 4. So we get exactly 4 values.

Key insight: range is lazy — it does not store all values in memory. It computes each value on demand using the formula start + step * i. This makes range(0, 10**12) use the same memory as range(0, 10). It also supports in membership testing in O(1) time, not O(n).

Expected Output
10\n7\n4\n1
Hints

Hint 1: range(start, stop, step) generates numbers from start up to (but not including) stop, incrementing by step.

Hint 2: A negative step counts downward. The stop value is still exclusive.


Medium

#5Implement enumerate from ScratchMedium
iterator-protocolenumerateiternext

Reimplement enumerate() using only iter(), next(), and a counter. Do not use the built-in enumerate.

Python
def my_enumerate(iterable, start=0):
    """Reimplement enumerate using iter() and next()."""
    iterator = iter(iterable)
    index = start
    while True:
        try:
            value = next(iterator)
        except StopIteration:
            return
        yield (index, value)
        index += 1

# Test it
colors = ["red", "green", "blue"]
for i, color in my_enumerate(colors, start=1):
    print(f"{i}: {color}")
Solution
def my_enumerate(iterable, start=0):
"""Reimplement enumerate using iter() and next()."""
iterator = iter(iterable)
index = start
while True:
try:
value = next(iterator)
except StopIteration:
return
yield (index, value)
index += 1

colors = ["red", "green", "blue"]
for i, color in my_enumerate(colors, start=1):
print(f"{i}: {color}")

Output:

1: red
2: green
3: blue

How it works step by step:

  1. iter(iterable) converts the input into an iterator. If it is already an iterator, it returns it unchanged.
  2. We maintain a counter index starting at start.
  3. Each iteration calls next(iterator) to fetch the next value. If the iterator is exhausted, StopIteration is raised and we return from the generator (which also signals StopIteration to the caller).
  4. We yield a tuple (index, value) — this makes my_enumerate a generator function, which is itself an iterator.

Why return not break: Inside a generator, return raises StopIteration, which is exactly what the for loop expects to end iteration. Using break would also work here since the while True would end, but return is more explicit about the intent.

Key insight: This is the core of the iterator protocol — iter() to get an iterator, next() to advance it, StopIteration to signal completion. Every for loop in Python follows this exact protocol under the hood.

def my_enumerate(iterable, start=0):
    """Reimplement enumerate using iter() and next()."""
    # Your code here
    pass

# Test it
colors = ["red", "green", "blue"]
for i, color in my_enumerate(colors, start=1):
    print(f"{i}: {color}")
Expected Output
1: red\n2: green\n3: blue
Hints

Hint 1: Use iter() to get an iterator from the iterable, then call next() in a loop.

Hint 2: You can use a while loop with a try/except StopIteration, or yield from a generator.

#6Build a Custom Iterator ClassMedium
iterator-protocol__iter____next__class

Build a Countdown iterator class that counts down from n to 1. Implement __iter__ and __next__ to make it work with a for loop.

Python
class Countdown:
    """Iterator that counts down from n to 1."""

    def __init__(self, n):
        self.current = n

    def __iter__(self):
        return self

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

# Test it
for num in Countdown(5):
    print(num)
Solution
class Countdown:
"""Iterator that counts down from n to 1."""

def __init__(self, n):
self.current = n

def __iter__(self):
return self

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

for num in Countdown(5):
print(num)

Output:

5
4
3
2
1

The iterator protocol requires two methods:

  1. __iter__(self) — must return an iterator object. When the object is its own iterator (as here), it returns self. This is what iter() calls.
  2. __next__(self) — must return the next value or raise StopIteration when exhausted. This is what next() calls.

What for does under the hood:

_iter = iter(Countdown(5)) # calls __iter__(), gets the object back
while True:
try:
num = next(_iter) # calls __next__()
except StopIteration:
break
print(num) # loop body

Important caveat: This iterator is single-use. Once exhausted, calling next() again will keep raising StopIteration. If you need to iterate multiple times, separate the iterable (which has __iter__) from the iterator (which has __next__). The iterable's __iter__ should return a new iterator each time.

class Countdown:
    """Iterator that counts down from n to 1."""

    def __init__(self, n):
        # Your code here
        pass

    def __iter__(self):
        # Your code here
        pass

    def __next__(self):
        # Your code here
        pass

# Test it
for num in Countdown(5):
    print(num)
Expected Output
5\n4\n3\n2\n1
Hints

Hint 1: __iter__ should return self — the object is its own iterator.

Hint 2: __next__ should return the current value and decrement, raising StopIteration when done.

#7zip_longest BehaviorMedium
itertoolszip_longestiteration

Use itertools.zip_longest to merge two lists of different lengths into a dictionary, filling missing values with a default.

Python
from itertools import zip_longest

def merge_with_defaults(keys, values, default="N/A"):
    """Merge keys and values into a dict, using default for missing values."""
    return dict(zip_longest(keys, values, fillvalue=default))

# Test with unequal lengths
keys = ["name", "age", "city", "country"]
values = ["Alice", 30]
result = merge_with_defaults(keys, values)
for k, v in sorted(result.items()):
    print(f"{k}: {v}")
Solution
from itertools import zip_longest

def merge_with_defaults(keys, values, default="N/A"):
"""Merge keys and values into a dict, using default for missing values."""
return dict(zip_longest(keys, values, fillvalue=default))

keys = ["name", "age", "city", "country"]
values = ["Alice", 30]
result = merge_with_defaults(keys, values)
for k, v in sorted(result.items()):
print(f"{k}: {v}")

Output:

age: 30
city: N/A
country: N/A
name: Alice

How zip_longest differs from zip:

Behaviorzipzip_longest
Unequal lengthsTruncates to shortestPads to longest
Missing valuesSilently droppedReplaced with fillvalue
Default fillN/A (no padding)None (customizable)

The pairing produced: zip_longest(keys, values, fillvalue="N/A") yields:

  • ("name", "Alice") — both present
  • ("age", 30) — both present
  • ("city", "N/A") — values exhausted, filled with default
  • ("country", "N/A") — values exhausted, filled with default

Key insight: When keys outnumber values, zip would silently lose data. zip_longest makes the mismatch explicit. In Python 3.10+, you can also use zip(strict=True) to raise a ValueError on length mismatch instead of silently truncating.

from itertools import zip_longest

def merge_with_defaults(keys, values, default="N/A"):
    """Merge keys and values into a dict, using default for missing values."""
    # Your code here
    pass

# Test with unequal lengths
keys = ["name", "age", "city", "country"]
values = ["Alice", 30]
result = merge_with_defaults(keys, values)
for k, v in sorted(result.items()):
    print(f"{k}: {v}")
Expected Output
age: 30\ncity: N/A\ncountry: N/A\nname: Alice
Hints

Hint 1: zip_longest(*iterables, fillvalue=None) pads shorter iterables with fillvalue.

Hint 2: You can pass a custom fillvalue to replace None.

#8Iterate and Modify a List SafelyMedium
list-mutationcopyiteration-safety

Remove all even numbers from a list while iterating. Demonstrate the safe approach using a copy.

The naive approach (for x in lst: if even: lst.remove(x)) is a classic Python bug — it skips elements because the iterator's internal index gets out of sync with the shrinking list.

Python
numbers = [1, 2, 3, 4, 5, 6, 7]

# Safe approach: iterate over a copy
for n in numbers[:]:  # numbers[:] creates a shallow copy
    if n % 2 == 0:
        print(f"Removing: {n}")
        numbers.remove(n)

print(f"Result: {numbers}")
Solution
numbers = [1, 2, 3, 4, 5, 6, 7]

for n in numbers[:]:
if n % 2 == 0:
print(f"Removing: {n}")
numbers.remove(n)

print(f"Result: {numbers}")

Output:

Removing: 2
Removing: 4
Removing: 6
Result: [1, 3, 5, 7]

Why the naive approach fails:

# BUG: modifying while iterating
nums = [1, 2, 3, 4, 5, 6]
for n in nums:
if n % 2 == 0:
nums.remove(n)
# Result: [1, 3, 5] — WRONG! 4 was skipped

When you remove element at index 1 (value 2), all subsequent elements shift left. The iterator's internal index advances from 1 to 2, but position 2 now holds what was at position 3 (value 4 is now at index 2, but the iterator jumps to index 2 which is now 4... wait, 3 is at index 1 now). The iterator skips 3 and sees 4. Then removing 4 causes 6 to be skipped.

Three safe alternatives:

  1. Copy iteration: for n in numbers[:] — iterate the copy, mutate the original.
  2. List comprehension: numbers = [n for n in numbers if n % 2 != 0] — build a new list (preferred for simple filters).
  3. Reverse iteration: for i in range(len(numbers) - 1, -1, -1) — removing from the end does not affect earlier indices.

Key insight: List comprehensions are almost always the cleanest solution for filtering. Reserve in-place mutation for cases where other references to the same list object must see the change.

Expected Output
Removing: 2\nRemoving: 4\nRemoving: 6\nResult: [1, 3, 5, 7]
Hints

Hint 1: Never modify a list while iterating over it directly — it skips elements.

Hint 2: Iterate over a copy (list[:]) or build a new list instead.

#9Sliding Window Using IterationMedium
sliding-windowiterationzipalgorithm

Implement a sliding window function that yields tuples of size consecutive elements from an iterable.

Python
def sliding_window(iterable, size):
    """Yield sliding windows of the given size over the iterable."""
    lst = list(iterable)
    # Create 'size' slices, each offset by 1
    slices = [lst[i:] for i in range(size)]
    return zip(*slices)

# Test
data = [10, 20, 30, 40, 50, 60]
for window in sliding_window(data, 3):
    print(window)
Solution
def sliding_window(iterable, size):
"""Yield sliding windows of the given size over the iterable."""
lst = list(iterable)
slices = [lst[i:] for i in range(size)]
return zip(*slices)

data = [10, 20, 30, 40, 50, 60]
for window in sliding_window(data, 3):
print(window)

Output:

(10, 20, 30)
(20, 30, 40)
(30, 40, 50)
(40, 50, 60)

How it works: For size=3, we create three slices:

  • lst[0:] = [10, 20, 30, 40, 50, 60]
  • lst[1:] = [20, 30, 40, 50, 60]
  • lst[2:] = [30, 40, 50, 60]

Then zip(*slices) pairs corresponding elements across all three slices:

  • (10, 20, 30), (20, 30, 40), (30, 40, 50), (40, 50, 60)

Since zip stops at the shortest iterable, the last slice (lst[2:]) determines the number of windows: len(lst) - size + 1.

Alternative using collections.deque (more memory-efficient for large iterables):

from collections import deque

def sliding_window_deque(iterable, size):
it = iter(iterable)
window = deque(maxlen=size)
for _ in range(size):
window.append(next(it))
yield tuple(window)
for item in it:
window.append(item) # oldest element auto-drops
yield tuple(window)

Key insight: The zip-of-slices approach is elegant and Pythonic but materializes the entire input. For streaming data or very large sequences, the deque approach processes elements one at a time. In Python 3.12+, itertools.batched exists but does not overlap — sliding windows still need custom code (or more-itertools.windowed).

def sliding_window(iterable, size):
    """Yield sliding windows of the given size over the iterable."""
    # Your code here
    pass

# Test
data = [10, 20, 30, 40, 50, 60]
for window in sliding_window(data, 3):
    print(window)
Expected Output
(10, 20, 30)\n(20, 30, 40)\n(30, 40, 50)\n(40, 50, 60)
Hints

Hint 1: One approach: zip multiple slices of the iterable offset by 1.

Hint 2: zip(data[0:], data[1:], data[2:]) gives you a window of size 3.


Hard

#10Custom Float Range ClassHard
iterator-protocolclassfloat-range__contains__

Build a FloatRange class that works like range() but supports floating-point start, stop, and step. It must support iteration, len(), and in membership testing.

Python
import math

class FloatRange:
    """A range-like class that supports float start, stop, and step."""

    def __init__(self, start, stop=None, step=1.0):
        if stop is None:
            self.start = 0.0
            self.stop = float(start)
        else:
            self.start = float(start)
            self.stop = float(stop)
        self.step = float(step)
        if self.step == 0:
            raise ValueError("FloatRange step must not be zero")

    def __iter__(self):
        # Use a generator so each call to __iter__ starts fresh
        count = len(self)
        for i in range(count):
            yield self.start + i * self.step

    def __len__(self):
        if self.step > 0 and self.start >= self.stop:
            return 0
        if self.step < 0 and self.start <= self.stop:
            return 0
        return max(0, math.ceil((self.stop - self.start) / self.step))

    def __contains__(self, value):
        if self.step > 0:
            if value < self.start or value >= self.stop:
                return False
        else:
            if value > self.start or value <= self.stop:
                return False
        # Check if value falls on a step boundary
        steps = (value - self.start) / self.step
        return abs(steps - round(steps)) < 1e-9

    def __repr__(self):
        return f"FloatRange({self.start}, {self.stop}, {self.step})"

# Test
print("Forward:")
for x in FloatRange(0.0, 1.0, 0.3):
    print(f"  {x:.1f}")

print(f"Length: {len(FloatRange(0.0, 1.0, 0.3))}")
print(f"0.3 in FloatRange(0.0, 1.0, 0.3): {0.3 in FloatRange(0.0, 1.0, 0.3)}")

print("Backward:")
for x in FloatRange(1.0, 0.0, -0.4):
    print(f"  {x:.1f}")
Solution
import math

class FloatRange:
def __init__(self, start, stop=None, step=1.0):
if stop is None:
self.start = 0.0
self.stop = float(start)
else:
self.start = float(start)
self.stop = float(stop)
self.step = float(step)
if self.step == 0:
raise ValueError("FloatRange step must not be zero")

def __iter__(self):
count = len(self)
for i in range(count):
yield self.start + i * self.step

def __len__(self):
if self.step > 0 and self.start >= self.stop:
return 0
if self.step < 0 and self.start <= self.stop:
return 0
return max(0, math.ceil((self.stop - self.start) / self.step))

def __contains__(self, value):
if self.step > 0:
if value < self.start or value >= self.stop:
return False
else:
if value > self.start or value <= self.stop:
return False
steps = (value - self.start) / self.step
return abs(steps - round(steps)) < 1e-9

def __repr__(self):
return f"FloatRange({self.start}, {self.stop}, {self.step})"

Output:

Forward:
0.0
0.3
0.6
0.9
Length: 4
0.3 in FloatRange(0.0, 1.0, 0.3): True
Backward:
1.0
0.6
0.2

Key design decisions:

  1. __iter__ uses start + i * step, not cumulative addition. Floating-point errors accumulate when you repeatedly add step. Using start + i * step keeps the error bounded to a single multiplication.

    Cumulative: 0.0 → 0.3 → 0.6000000000000001 → 0.9000000000000001 (drift)
    Index-based: 0.0 + 0*0.3 → 0.0 + 1*0.3 → 0.0 + 2*0.3 → 0.0 + 3*0.3 (stable)
  2. __iter__ is a generator, so each for loop call gets a fresh iterator. This is crucial — if __iter__ returned self with mutable state, the object could only be iterated once.

  3. __contains__ uses epsilon comparison (1e-9) because floating-point values are almost never exactly equal. We compute how many steps from start to value and check if that is close to an integer.

  4. __len__ uses math.ceil to match range semantics — the stop value is exclusive. ceil((1.0 - 0.0) / 0.3) = ceil(3.33) = 4, giving us values at indices 0, 1, 2, 3.

Why Python's range does not support floats: Floating-point arithmetic is inherently imprecise. The built-in range guarantees exact values and O(1) membership testing, which is only possible with integers. A float range must make tradeoffs around precision, as this implementation shows with the epsilon comparison.

class FloatRange:
    """A range-like class that supports float start, stop, and step."""

    def __init__(self, start, stop=None, step=1.0):
        # Handle single-argument case: FloatRange(5.0) means FloatRange(0, 5.0)
        # Your code here
        pass

    def __iter__(self):
        # Your code here
        pass

    def __len__(self):
        # Your code here
        pass

    def __contains__(self, value):
        # Your code here
        pass

    def __repr__(self):
        return f"FloatRange({self.start}, {self.stop}, {self.step})"

# Test
print("Forward:")
for x in FloatRange(0.0, 1.0, 0.3):
    print(f"  {x:.1f}")

print(f"Length: {len(FloatRange(0.0, 1.0, 0.3))}")
print(f"0.3 in FloatRange(0.0, 1.0, 0.3): {0.3 in FloatRange(0.0, 1.0, 0.3)}")

print("Backward:")
for x in FloatRange(1.0, 0.0, -0.4):
    print(f"  {x:.1f}")
Expected Output
Forward:\n  0.0\n  0.3\n  0.6\n  0.9\nLength: 4\n0.3 in FloatRange(0.0, 1.0, 0.3): True\nBackward:\n  1.0\n  0.6\n  0.2
Hints

Hint 1: Use math.ceil((stop - start) / step) for __len__, clamped to 0 for empty ranges.

Hint 2: For __contains__, check if (value - start) / step is close to an integer and within bounds.

Hint 3: Use a generator in __iter__ so the object can be iterated multiple times.

#11Lazy File Line Iterator with Context ManagerHard
iteratorcontext-manager__enter____exit__lazy

Build a LineReader class that lazily reads lines from a file one at a time. It must implement both the iterator protocol (__iter__/__next__) and the context manager protocol (__enter__/__exit__).

Since we cannot create actual files in this sandbox, we simulate using io.StringIO.

Python
import io

class LineReader:
    """Lazy line iterator that works as a context manager."""

    def __init__(self, file_obj):
        self._file_obj = file_obj
        self._file = None
        self.lines_read = 0

    def __enter__(self):
        self._file = self._file_obj
        return self

    def __exit__(self, exc_type, exc_val, exc_tb):
        self._file.close()
        return False  # Do not suppress exceptions

    def __iter__(self):
        return self

    def __next__(self):
        line = self._file.readline()
        if not line:
            raise StopIteration
        self.lines_read += 1
        return line.rstrip('\n')

# Simulate a file with io.StringIO
fake_file = io.StringIO("Hello World\nPython Iterators\nAre Powerful\n")

with LineReader(fake_file) as reader:
    for line_num, line in enumerate(reader, 1):
        print(f"[{line_num}] {line}")
    print(f"Lines read: {reader.lines_read}")

print(f"File closed: {fake_file.closed}")
Solution
import io

class LineReader:
"""Lazy line iterator that works as a context manager."""

def __init__(self, file_obj):
self._file_obj = file_obj
self._file = None
self.lines_read = 0

def __enter__(self):
self._file = self._file_obj
return self

def __exit__(self, exc_type, exc_val, exc_tb):
self._file.close()
return False

def __iter__(self):
return self

def __next__(self):
line = self._file.readline()
if not line:
raise StopIteration
self.lines_read += 1
return line.rstrip('\n')

fake_file = io.StringIO("Hello World\nPython Iterators\nAre Powerful\n")

with LineReader(fake_file) as reader:
for line_num, line in enumerate(reader, 1):
print(f"[{line_num}] {line}")
print(f"Lines read: {reader.lines_read}")

print(f"File closed: {fake_file.closed}")

Output:

[1] Hello World
[2] Python Iterators
[3] Are Powerful
Lines read: 3
File closed: True

Two protocols in one class:

Iterator protocol:

  • __iter__(self) returns self — the object is its own iterator.
  • __next__(self) reads one line using readline(), which reads only until the next newline. This is lazy — it never loads the entire file into memory. When readline() returns an empty string, the file is exhausted and we raise StopIteration.

Context manager protocol:

  • __enter__(self) sets up the resource (opens/assigns the file) and returns the object to bind to the as variable.
  • __exit__(self, exc_type, exc_val, exc_tb) is called when leaving the with block, whether normally or due to an exception. We close the file here. Returning False means exceptions propagate normally.

Why this matters for real files:

# With a real file, __init__ would store the path,
# and __enter__ would open it:
class LineReader:
def __init__(self, filename):
self._filename = filename

def __enter__(self):
self._file = open(self._filename, 'r')
return self

def __exit__(self, *args):
self._file.close()
return False

Key insight: Combining iterator + context manager lets you write with LineReader('huge.csv') as reader: for line in reader: ... — the file is guaranteed to close even if an exception occurs mid-iteration, and memory usage stays constant regardless of file size because we read one line at a time.

class LineReader:
    """Lazy line iterator that works as a context manager.
    
    Usage:
        with LineReader('file.txt') as reader:
            for line in reader:
                print(line)
    """

    def __init__(self, filename):
        # Your code here
        pass

    def __enter__(self):
        # Your code here
        pass

    def __exit__(self, exc_type, exc_val, exc_tb):
        # Your code here
        pass

    def __iter__(self):
        # Your code here
        pass

    def __next__(self):
        # Your code here
        pass
Expected Output
[1] Hello World\n[2] Python Iterators\n[3] Are Powerful\nLines read: 3\nFile closed: True
Hints

Hint 1: __enter__ should open the file and return self.

Hint 2: __exit__ should close the file and return False (do not suppress exceptions).

Hint 3: __next__ should read one line at a time, strip the newline, and raise StopIteration at EOF.

#12Parallel Round-Robin IteratorHard
iteratorround-robinmultiple-iterablesadvanced

Build a round_robin generator that takes multiple iterables and yields their elements in interleaved order. When one iterable is exhausted, continue with the remaining ones.

This pattern is useful for fair scheduling, load balancing, and merging data streams.

Python
def round_robin(*iterables):
    """Yield items from multiple iterables in round-robin order."""
    # Convert all to iterators
    iterators = [iter(it) for it in iterables]

    while iterators:
        # Track which iterators are still active this round
        still_active = []
        for it in iterators:
            try:
                yield next(it)
                still_active.append(it)
            except StopIteration:
                pass  # This iterator is exhausted — drop it
        iterators = still_active

# Test
print(list(round_robin([1, 2, 3], ['a', 'b'], [True])))
print(list(round_robin("AB", "CDEF", "GH")))
print(list(round_robin(range(3), range(5))))
Solution
def round_robin(*iterables):
"""Yield items from multiple iterables in round-robin order."""
iterators = [iter(it) for it in iterables]

while iterators:
still_active = []
for it in iterators:
try:
yield next(it)
still_active.append(it)
except StopIteration:
pass
iterators = still_active

print(list(round_robin([1, 2, 3], ['a', 'b'], [True])))
print(list(round_robin("AB", "CDEF", "GH")))
print(list(round_robin(range(3), range(5))))

Output:

[1, 'a', True, 2, 'b', 3]
['A', 'C', 'G', 'B', 'D', 'H', 'E', 'F']
[0, 0, 1, 1, 2, 2, 3, 4]

Trace through the first test caseround_robin([1,2,3], ['a','b'], [True]):

Round 1: iterators = [it0, it1, it2]
next(it0) -> 1 (active)
next(it1) -> 'a' (active)
next(it2) -> True (active)
still_active = [it0, it1, it2]

Round 2: iterators = [it0, it1, it2]
next(it0) -> 2 (active)
next(it1) -> 'b' (active)
next(it2) -> StopIteration (dropped)
still_active = [it0, it1]

Round 3: iterators = [it0, it1]
next(it0) -> 3 (active)
next(it1) -> StopIteration (dropped)
still_active = [it0]

Round 4: iterators = [it0]
next(it0) -> StopIteration (dropped)
still_active = []

while iterators is now empty -> done

Why we build still_active instead of removing in-place: Modifying a list while iterating over it causes skipped elements (the same bug from Problem 8). Building a fresh list each round is safe and clear.

Alternative using itertools:

from itertools import cycle, islice

def round_robin_itertools(*iterables):
"""Round-robin using itertools (from the official Python docs)."""
num_active = len(iterables)
nexts = cycle(iter(it).__next__ for it in iterables)
while num_active:
try:
for next_func in nexts:
yield next_func()
except StopIteration:
num_active -= 1
nexts = cycle(islice(nexts, num_active))

This version from the itertools documentation is more concise but harder to understand. The manual approach above is clearer and works just as well for most use cases.

Key insight: Round-robin iteration is a fundamental scheduling algorithm. It guarantees fairness — no single iterable monopolizes the output. This same pattern appears in CPU scheduling, network packet scheduling, and distributed task queues. The core technique — maintaining a list of active iterators and pruning exhausted ones — is widely applicable beyond just round-robin.

def round_robin(*iterables):
    """Yield items from multiple iterables in round-robin order.
    
    round_robin([1,2,3], ['a','b'], [True]) 
    -> 1, 'a', True, 2, 'b', 3
    
    When an iterable is exhausted, skip it and continue
    with the remaining iterables.
    """
    # Your code here
    pass

# Test
print(list(round_robin([1, 2, 3], ['a', 'b'], [True])))
print(list(round_robin("AB", "CDEF", "GH")))
print(list(round_robin(range(3), range(5))))
Expected Output
[1, 'a', True, 2, 'b', 3]\n['A', 'C', 'G', 'B', 'D', 'H', 'E', 'F']\n[0, 0, 1, 1, 2, 2, 3, 4]
Hints

Hint 1: Convert all iterables to iterators first using iter().

Hint 2: Maintain a list of active iterators. Each round, try next() on each. Remove exhausted ones.

Hint 3: Be careful not to modify a list while iterating over it — collect survivors into a new list.

© 2026 EngineersOfAI. All rights reserved.