1.1.1 Iterators
A for loop in Python does not magically know your list. It asks an iterator for the next item until the iterator raises StopIteration.
That protocol is why the same loop works for lists, files, ranges, and your own types:
for line in open("notes.txt"):
print(line, end="")
The iterator protocol
An iterable implements __iter__() and returns an iterator.
An iterator implements __next__() (and usually __iter__ returning self).
class Countdown:
def __init__(self, start):
self.current = start
def __iter__(self):
return self
def __next__(self):
if self.current <= 0:
raise StopIteration
self.current -= 1
return self.current + 1
print(list(Countdown(3))) # [3, 2, 1]
iter(obj) calls __iter__. next(it) calls __next__.
Generators
A generator function uses yield instead of building a class. Each yield pauses the function and hands a value to the caller. The next next() resumes just after that yield.
def countdown(start):
current = start
while current > 0:
yield current
current -= 1
for n in countdown(3):
print(n)
Generators are lazy: they compute one value at a time. That matters for large files and infinite streams.
def read_numbers(path):
with open(path) as f:
for line in f:
line = line.strip()
if line:
yield int(line)
A generator expression is the compact form:
squares = (n * n for n in range(10) if n % 2 == 0)
Parentheses make a generator; square brackets make a list. Use a generator when you will consume the values once and do not need random access.
Why this belongs in Advanced
Once you can yield values, you can write pipelines: one generator reads, the next filters, the next transforms. Memory stays flat even when the source is huge.