Loops & Iteration (for/while)
Master the hidden Iterator Protocol (__iter__,
__next__), Bytecode unrolling, and the controversial for...else
construct.
Loops allow a program to execute a block of code multiple times. Python provides two core
variations: the while loop (which repeats as long as
a condition remains mathematically Truthy), and the for loop
(which repeatedly extracts items from a data structure until it is empty).
However, unlike C++ or Java where a for loop simply increments a numeric counter
(for(int i=0; i<10; i++)), a Python for loop is radically
different. It does not count. It asks an object for an Iterator, and
constantly demands the "next" item from that iterator until the iterator throws a hidden
error.
Imagine a Pez candy dispenser.
In Python, a string or list is the physical dispenser (an Iterable). The
for loop acts as a mechanical thumb. Upon starting, the for loop
presses down on the dispenser's head (calling __iter__()). It then repeatedly
extracts the top candy (calling __next__()) and hands it to your indented code
block.
When the dispenser is empty, pulling the head back throws out a literal software exception
called StopIteration. The for loop silently catches this error,
stops the loop, and seamlessly moves to the next line of your script.
# Scenario 1: standard iteration
names = ["Alice", "Bob", "Charlie"]
for n in names:
print(n)
# Scenario 2: Controlling flow with break/continue
counter = 0
while True:
counter += 1
if counter % 2 == 0:
continue # Skips even numbers
if counter > 5:
break # Stops the infinite loop at 6
print(counter) # Prints 1, 3, 5
# Scenario 3: The hidden mechanics of the For Loop
nums = [1, 2]
iterator = iter(nums) # Extracts the iterator object
print(next(iterator)) # Prints 1
print(next(iterator)) # Prints 2
# print(next(iterator)) # Would normally crash with StopIteration
| Code Line | Explanation |
|---|---|
for n in names: |
Under the hood, Python executes iter_obj = names.__iter__() to create a
stateful tracking object. It then starts a massive hidden while True:
loop in C. |
while True: |
This executes an infinite loop because the boolean True is permanently
stored in CPython as literal integer `1` memory. It can never evaluate to Falsy.
|
continue |
The Python Virtual Machine reads the JUMP_ABSOLUTE bytecode and
instantly teleports the execution pointer back up to the while True:
evaluation line. Anything below continue is ignored for that specific
cycle. |
break |
The POP_BLOCK bytecode shatters the loop scaffolding entirely, forcing
the pointer to teleport to the first unindented line after the whole loop
structure. |
Input: for char in "AI": print(char)
Transformation: The string `"AI"` is passed into the loop. The string yields
an iterator. The loop calls __next__() twice. First, it extracts the `A`
pointer and assigns it to the namespace variable `char`. The block prints it. Second, it
extracts the `I` pointer. The third call causes a silent explosion
(StopIteration) that halts the loop.
Output State: Two strings (`"A"` and `"I"`) are printed sequentially to the standard output buffer.
Why are standard for loops inherently slow in Python compared to C?
In C, a loop just adds +1 to a register and moves the physical RAM reading pointer 4 bytes forward. In Python, every single tiny step of the loop must:
- Call the
__next__()function (Huge overhead). - Check the return type.
- Catch any potential Exceptions.
- Assign a brand new nametag to point to the extracted object.
- Decrement Reference Counts for the previous cycle's object.
This massive, complex scaffolding executed millions of times is why pure-Python loops are 100x slower than NumPy's vectorized C-loops.
When you loop over a Dictionary vs a List:
my_list = [10, 20]; for x in my_list: -> yields values (10, 20)
my_dict = {"a": 1, "b": 2}; for x in my_dict: -> yields KEYS ("a", "b")
Because the dictionary's __iter__() method is hardcoded to only dispense keys,
you must explicitly use my_dict.items() if you want the loop to pack and
dispense a Tuple of (key, value).
A standard for loop is 1-Dimensional—it extracts items from the outermost
wrapper of a Data Structure.
If you have a 3D Tensor [[[1, 2], [3, 4]]], writing
for item in tensor: will NOT give you the number `1`. It will give you the
massive 2D matrix [[1, 2], [3, 4]]. To drill down to scalars, you need 3 nested
for loops. (Or, in Data Science, use tensor.flatten()).
Loop structures themselves return nothing (they evaluate to `None`). They simply mutate the state of your application.
Note on range(): When you write
for i in range(1000000):, Python does NOT generate a list of a million numbers
in memory. range() returns a microscopic Generator Object that mathematically
computes the next number on-the-fly when requested by the loop. This uses exactly O(1)
memory, preventing RAM overflow.
The Dangerous Loop Modification:
my_list = [1, 2, 3, 4, 5]
for item in my_list:
if item == 3:
my_list.remove(item)
What Happens: This fails catastrophically. The list's physical memory shifts leftward when `3` is deleted, but the Iterator's internal Index Pointer blindly moves forward. It skips entirely over the number `4`, ignoring it! Never mutate the size of a list while you are actively looping over it.
The for ... else: construct:
A bizarre feature of Python. You can attach an else: block to the very bottom of
a loop.
for x in [1, 2, 3]:
if x == 5:
print("Found 5!")
break
else:
print("5 was never found.")
The else block executes ONLY IF the loop died a natural death
(ran out of items). If the loop was violently murdered artificially by a break
statement, the else block is skipped. This acts as a brilliant "search failure"
mechanism.
Mistake: Rebuilding lists inside loops.
new_string = ""for char in ["A", "B", "C"]: new_string += char
Why is this bad?: Strings in Python are immutable. Every single iteration of
this loop forces Python to ask the OS for a new block of RAM, copy the old string into it,
attach the new letter, and delete the old string. This is `O(N^2)` memory thrashing.
Fix: Store characters in a list and execute "".join(list)
precisely once at the very end.
Local vs Global Lookups:
If you execute a 1-million iteration loop at the global module level, it runs incredibly slow
because Python must use heavily encrypted Dictionary-lookups for every variable assignment.
If you wrap the exact same loop inside a def main(): function, it executes 30%
faster! Why? Inside functions, Python stores variables in a hyper-optimized bare-metal
C-array (accessed via LOAD_FAST bytecodes) instead of a dictionary.
Challenge: Write a loop to extract items sequentially from a list
[10, 20, 30] without using the words for or in.
Expected Answer: You must manually build the state-machine just like Python's internal C-engine does:
iterator = iter([10, 20, 30])
while True:
try:
item = next(iterator)
print(item)
except StopIteration:
break
Bytecode Unrolling optimization:
If you have a massive matrix loop, Data Scientists use specific libraries (like Numba or Jax) to execute Loop Unrolling. Instead of having the CPU re-evaluate a condition 1,000 times, the JIT (Just-In-Time) compiler literally writes exactly 1,000 sequential instructions into memory and obliterates the loop completely. This destroys Branch Prediction overhead, funneling data directly into the Vector Processing Units of the CPU untouched by Python's garbage collector. This makes python run at C++ speeds.