1.4.3 · D2Python & Scientific Computing

Visual walkthrough — List - dict comprehensions and generators

2,245 words10 min readBack to topic

Prerequisites we lean on (build here if rusty): Python lists & loops, and the idea of stepping through items one-by-one from the iterator protocol. This page is a child of the parent topic on comprehensions & generators.


Step 1 — What "building a collection" even means

WHAT. Before any brackets, let us agree on the raw material. A collection is a pile of values sitting somewhere in the computer's memory (RAM). Think of RAM as a long shelf with numbered slots. To "have a list of 5 squares" means: 5 slots on the shelf hold references (little pointers) to the numbers , and Python holds a label pointing at where that pile starts.

WHY start here. Every difference later is about how many shelf-slots are occupied at once. If you cannot picture the shelf, the memory story is invisible.

PICTURE. The shelf below has 5 filled slots (the list) and one small label arrow pointing at it.

Figure — List - dict comprehensions and generators

Step 2 — The eager way: a list computes everything now

WHAT. A list comprehension [x**2 for x in range(5)] runs the loop to completion immediately and puts all results on the shelf before handing you the label.

  • — the square brackets are the order "make the shelf now".
  • — the recipe applied to each source value.
  • ranges over — five source values, so five results.

WHY. The bracket is greedy: the instant this line runs, Python walks the whole range, computes every square, and stores each one. Five slots fill up. The benefit — you can now revisit any of them (data[3]) as often as you like.

PICTURE. Watch all five slots light up in one flash, and the memory bar shoot to full.

Figure — List - dict comprehensions and generators

Step 3 — The lazy way: a generator computes on demand

WHAT. Swap [ for ( and you get a generator expression (x**2 for x in range(5)). Running this line computes nothing yet. Python only makes a tiny machine that remembers how to make the next square when asked.

Same middle, different bracket — and the meaning flips from "do it all" to "do it on request".

WHY. A generator answers a different question. The list answered "what are all the squares?". The generator answers "what is the next square, only when I ask?". That deferral is the whole trick: nothing is stored ahead of time.

PICTURE. Contrast the full shelf (list) with a single "next-value" hatch and a nearly-empty memory bar (generator).

Figure — List - dict comprehensions and generators

Step 4 — Following one value through the generator machine

WHAT. When something asks the generator for its next value (Python calls this next()), the machine wakes up, runs the recipe for exactly one source value, hands that value back, and freezes again — remembering only which source value comes next.

WHY. This is why the memory stays flat: at any instant, only one computed value exists, plus a tiny note saying "I was at , next is ." No shelf of past results is kept.

PICTURE. Follow the red arrow: request → compute → yield → freeze at "next: ".

Figure — List - dict comprehensions and generators

Step 5 — The catch: a generator is consumed once

WHAT. Because the generator never stored past values, once it has yielded them all, they are gone. Ask it to iterate again and it produces nothing.

gen = (x**2 for x in range(5))
list(gen)   # [0, 1, 4, 9, 16]  ← first pass drains it
list(gen)   # []                 ← empty, already exhausted

WHY. The machine's frozen "next" marker has reached the end. There is no shelf to re-read, so there is nothing to hand out. A list has the opposite trade: it kept everything, so you may loop over it as many times ( passes) as you like.

PICTURE. The generator's marker walks off the right edge; a second request finds an empty box.

Figure — List - dict comprehensions and generators

Step 6 — Why the pipeline saves both memory and work

WHAT. Chain lazy stages — square, then keep evens, then take the first 3 — and values flow through one at a time. No stage builds a full intermediate list.

from itertools import islice
 
squares = (x**2 for x in range(10))       # lazy stage 1
evens   = (s for s in squares if s % 2 == 0)  # lazy stage 2
first3  = list(islice(evens, 3))           # [0, 4, 16]
  • Stage 1 offers one square at a time.
  • Stage 2 pulls one from stage 1, passes it only if even.
  • islice(evens, 3) pulls exactly 3 evens and then stops asking — so later squares are never computed.

WHY. Each stage is a machine that pulls from the machine before it. Because pulling is on demand, asking for only 3 results (via islice) means the earliest stages run only far enough to supply them — lazy evaluation prevents wasted computation, not just wasted memory.

PICTURE. One marble rolls stage → stage → out; the memory bar stays a thin sliver the whole way.

Figure — List - dict comprehensions and generators
Recall

Why does islice(evens, 3) stop early while list(evens)[:3] does not? ::: islice pulls only 3 values then stops asking, so upstream stages never run further; list(evens) first exhausts the whole generator into memory, then slices — computing everything anyway.


Step 7 — The degenerate cases (never leave a scenario unshown)

WHAT. Corner inputs behave predictably once you hold the "on demand" picture:

Case List [...] Generator (...)
Empty source range(0) [] — zero slots yields immediately-empty; list()[]
Infinite source runs out of memory: keeps allocating until the OS kills the process fine — you just stop pulling
, single item 1 slot 1 yield, then exhausted
Ask twice works every time second pass empty (Step 5)

WHY. The infinite-source row is the punchline of the whole page: an eager list must finish the loop, so an endless source makes it request memory forever — the interpreter does not "crash" instantly; it hangs allocating more and more RAM until the operating system runs out and kills the process (an out-of-memory kill). A lazy generator only ever computes what you pull, so endless sources are ordinary.

PICTURE. Left: list trying to fill an endless shelf (red overflow). Right: generator calmly emitting from an endless stream, memory flat.

Figure — List - dict comprehensions and generators

The one-picture summary

Everything above compressed: the eager list fills the whole shelf now (heavy, re-readable), the lazy generator hands one value at a time (light, single-use), and a chain of generators streams a single value end-to-end.

Figure — List - dict comprehensions and generators
Recall Feynman retelling — say it back in plain words

Picture a bakery. A list comprehension bakes every loaf the moment you order, stacks them all on a big counter, and gives you the counter. You can grab any loaf, any time, again and again — but the counter must be big enough for all the loaves at once. A generator hands you an empty shelf and a baker who bakes one loaf only when you ask. Ask, get a loaf; ask again, get the next; the shelf never holds more than one loaf, so it stays tiny — even if you plan to order a billion. The price: once the baker runs out of dough (the source is used up), the shop is closed for that order — you cannot re-walk the loaves you already took. Chain several bakers and a single loaf travels bakery-to-bakery, so you only ever bake exactly as many as you actually take — but only if you ask for just those few (islice), not if you demand the whole batch first (list(...)). Choose the big counter when the batch is small and you'll revisit it; choose the one-loaf baker when the batch is huge and you'll pass through once — exactly the streaming setup you meet in mini-batch training and streaming files from disk.

Related build-outs: contrast this with the all-at-once math of NumPy vectorization (eager by design, for speed) and the machinery underneath every generator in the iterator protocol.