4.3 for / while — Advanced Loops
Loops are a fundamental tool in programming. Python's for and while are closely tied to the iterator protocol, going far beyond simple repetition. Used correctly, they make code clearer and more performant.
for + range(): Complete Guide
# range(stop): 0 to stop-1
for i in range(5):
print(i, end=" ") # 0 1 2 3 4
print()
# range(start, stop): start to stop-1
for i in range(2, 8):
print(i, end=" ") # 2 3 4 5 6 7
print()
# range(start, stop, step): with step
for i in range(0, 20, 3):
print(i, end=" ") # 0 3 6 9 12 15 18
print()
# Reverse: negative step
for i in range(10, 0, -1):
print(i, end=" ") # 10 9 8 7 6 5 4 3 2 1
print()
# range is memory-efficient — it does not build an actual list
big_range = range(0, 10_000_000)
print(type(big_range)) # <class 'range'>
print(len(big_range)) # 10000000 — O(1) operation
# range object features
r = range(1, 11)
print(5 in r) # True — O(1) membership test
print(r[3]) # 4 — index access
print(list(r[:3])) # [1, 2, 3] — slicing
enumerate(): Index and Value Together
fruits = ["apple", "banana", "cherry", "date"]
# Bad: manual index
for i in range(len(fruits)):
print(f"{i}: {fruits[i]}")
# Good: use enumerate
for i, fruit in enumerate(fruits):
print(f"{i}: {fruit}")
# Output:
# 0: apple
# 1: banana
# 2: cherry
# 3: date
# Specify a start index
for i, fruit in enumerate(fruits, start=1):
print(f"{i}. {fruit}")
# Real-world: modifying elements in place
numbers = [3, 1, 4, 1, 5, 9, 2, 6]
for i, num in enumerate(numbers):
if num < 3:
numbers[i] = 0
print(numbers) # [3, 0, 4, 0, 5, 9, 0, 6]
zip(): Parallel Iteration over Multiple Iterables
names = ["Alice", "Bob", "Charlie"]
scores = [95, 87, 92]
grades = ["A", "B", "A"]
# Basic zip: stops at the shortest iterable
for name, score, grade in zip(names, scores, grades):
print(f"{name}: {score} ({grade})")
# zip_longest: fills shorter ones with fillvalue
from itertools import zip_longest
extra_names = ["Dave", "Eve"]
extra_scores = [78]
for name, score in zip_longest(extra_names, extra_scores, fillvalue=0):
print(f"{name}: {score}")
# Dave: 78
# Eve: 0
# zip(strict=True) — Python 3.10+: raises ValueError on length mismatch
try:
for a, b in zip([1, 2, 3], [4, 5], strict=True):
print(a, b)
except ValueError as e:
print(f"Error: {e}")
# Build a dictionary from two lists
keys = ["name", "age", "city"]
values = ["Alice", 30, "Seoul"]
person = dict(zip(keys, values))
print(person) # {'name': 'Alice', 'age': 30, 'city': 'Seoul'}
# Matrix transpose
matrix = [[1, 2, 3], [4, 5, 6], [7, 8, 9]]
transposed = list(zip(*matrix))
print(transposed) # [(1, 4, 7), (2, 5, 8), (3, 6, 9)]
for-else / while-else: Detecting Normal Loop Completion
A unique Python feature: the else block runs when a for/while loop finishes without hitting a break.
# for-else: else runs only if no break occurred
def find_prime_factor(n: int) -> int | None:
"""Returns the first prime factor of n, or None if n is prime."""
for i in range(2, int(n**0.5) + 1):
if n % i == 0:
return i # Factor found → else not executed
else:
return None # Loop completed without break → n is prime
for n in [12, 17, 25, 29]:
factor = find_prime_factor(n)
if factor:
print(f"{n} = {factor} × {n // factor}")
else:
print(f"{n} is prime")
# while-else
def binary_search(arr: list, target: int) -> int:
"""Binary search. Returns index if found, -1 otherwise."""
left, right = 0, len(arr) - 1
while left <= right:
mid = (left + right) // 2
if arr[mid] == target:
break
elif arr[mid] < target:
left = mid + 1
else:
right = mid - 1
else:
return -1 # Loop ended without break = not found
return mid
sorted_arr = [1, 3, 5, 7, 9, 11, 13]
print(binary_search(sorted_arr, 7)) # 3 (index)
print(binary_search(sorted_arr, 6)) # -1 (not found)
while + break: Infinite Loop Escape Patterns
import random
# Basic infinite loop pattern
attempt = 0
while True:
attempt += 1
value = random.randint(1, 10)
print(f"Attempt {attempt}: {value}")
if value == 7:
print("Found 7!")
break
# Retry pattern with max retries
MAX_RETRIES = 3
def fetch_data(url: str) -> dict | None:
import random
return {"data": "success"} if random.random() > 0.5 else None
url = "https://api.example.com/data"
result = None
retries = 0
while retries < MAX_RETRIES:
result = fetch_data(url)
if result is not None:
print(f"Success (attempt {retries + 1})")
break
retries += 1
print(f"Failed, retrying... ({retries}/{MAX_RETRIES})")
else:
print("Max retries exceeded")
continue and break Patterns
# continue: skip current iteration
data = [1, None, 3, None, 5, None, 7]
total = 0
for item in data:
if item is None:
continue # Skip None
total += item
print(f"Sum: {total}") # 16
# break: immediately exit the loop
def first_negative(numbers: list[int]) -> int | None:
for n in numbers:
if n < 0:
return n # Use return instead of break inside a function
return None
# Nested loop escape — break exits only one level
def find_in_matrix(matrix: list[list[int]], target: int) -> tuple[int, int] | None:
for i, row in enumerate(matrix):
for j, val in enumerate(row):
if val == target:
return (i, j) # Function returns escape all loops at once
return None
matrix = [[1, 2, 3], [4, 5, 6], [7, 8, 9]]
print(find_in_matrix(matrix, 5)) # (1, 1)
print(find_in_matrix(matrix, 10)) # None
A Taste of itertools
import itertools
# chain(): combine multiple iterables into one
from itertools import chain
list1 = [1, 2, 3]
list2 = [4, 5, 6]
list3 = [7, 8, 9]
for item in chain(list1, list2, list3):
print(item, end=" ") # 1 2 3 4 5 6 7 8 9
print()
# chain.from_iterable(): flatten a nested iterable
nested = [[1, 2], [3, 4], [5, 6]]
flat = list(chain.from_iterable(nested))
print(flat) # [1, 2, 3, 4, 5, 6]
# cycle(): repeat indefinitely
from itertools import cycle, islice
colors = cycle(["red", "green", "blue"])
for color in islice(colors, 7):
print(color, end=" ") # red green blue red green blue red
print()
# repeat(): repeat a value n times
from itertools import repeat
for val in repeat("hello", 3):
print(val, end=" ") # hello hello hello
print()
pairs = list(zip(range(5), repeat(0)))
print(pairs) # [(0, 0), (1, 0), (2, 0), (3, 0), (4, 0)]
Real-world Example: Matrix Multiplication with Nested Loops
def matrix_multiply(a: list[list[float]], b: list[list[float]]) -> list[list[float]]:
"""Matrix multiplication (m×n) × (n×p) = (m×p)"""
m, n = len(a), len(a[0])
n2, p = len(b), len(b[0])
if n != n2:
raise ValueError(f"Matrix size mismatch: ({m}×{n}) × ({n2}×{p})")
result = [[0.0] * p for _ in range(m)]
for i in range(m):
for j in range(p):
for k in range(n):
result[i][j] += a[i][k] * b[k][j]
return result
A = [[1, 2], [3, 4]]
B = [[5, 6], [7, 8]]
C = matrix_multiply(A, B)
for row in C:
print(row)
# [19.0, 22.0]
# [43.0, 50.0]
Pro Tips
1. Watch Out for Loop Variable Reuse
# The loop variable persists after the loop ends
for i in range(5):
pass
print(i) # 4 — last value remains
# Unintended closure capture
result = [lambda: i for i in range(5)]
print([f() for f in result]) # [4, 4, 4, 4, 4] — all reference the last i=4
# Fix: capture by default argument
result = [lambda i=i: i for i in range(5)]
print([f() for f in result]) # [0, 1, 2, 3, 4]
2. Cache Attribute Access in Loops
import math
# Bad: attribute lookup every iteration
result = []
for i in range(10000):
result.append(math.sqrt(i))
# Good: cache the attribute in a local variable
sqrt = math.sqrt
result = []
for i in range(10000):
result.append(sqrt(i)) # Local lookup is faster
3. Safe Loops over Empty Iterables
data = []
# for is automatically safe on empty iterables
for item in data:
print(item) # Nothing printed — no error
# Getting the first element safely
first = next(iter(data), None)
print(first) # None