← board

range as a value — a lazy SEQUENCE, not a cursor

The fact

before
for i in range(3) worked (a counted-loop lowering in the for header)
list(range(3)), len(range(3)) worked — special-cased
r = range(3) undefined variable (range)
range(3)[1], print(range(3)) undefined variable (range)

The special case is the interesting half. PyRangeIterConsumer was a WHITELIST of eleven callee names — list, sum, len, sorted, tuple, set, any, all, min, max, reversed — each one a promise that the callee does nothing but iterate its argument, so that MATERIALISING the range there was invisible. Anywhere else the range stayed a compile error, deliberately: with no range value to hand back, widening the rewrite would have turned a loud error into a quiet [0, 1, 2] where CPython prints range(0, 3).

That was the right call while there was no value. It stops being right the moment there is one, and the whole mechanism is deleted here.

Sequence, not cursor — the distinction is the design

Both are lazy, and that shared word is what made the two look like one problem. They are not the same shape at all:

cursor (map, filter, …) range
iterate twice second pass is EMPTY yields the same values again
len() TypeError cheap and exact
indexable / sliceable no yes — and a slice is a RANGE
holds a source + a position three Int64s, no position at all

So TPyRange is its own class beside TPyIter, and iter(r) hands back a FRESH cursor every time — that is precisely what re-iterable means.

range(10 ** 9) is 24 bytes; big[999999999] is one multiply and 999999998 in big is one modulo. Membership is not a scan, which is the property that makes a range worth having as a value rather than as a list.

What was touched

The unplanned deletion — one conversion instead of four spellings

map/filter picked their pylib entry by the iterable's static type (_l / _s / _i / the variant one) through a helper, PyIterCtorName, because those calls are built with FindProc and it is not overload-aware. Range arrived as a FOURTH iterable shape — a sequence that is not a list — and the picker would have needed to learn it.

That is the two-is-a-smell line from devdocs/dev/normalise-dont-special-case.md. Instead the arms now call PyMakeIterOf once and pass a cursor to a single entry, exactly as enumerate/zip already did. PyIterCtorName and six pylib/pyeval spellings are gone, and the new class needed no dispatch at all.

What the for header still does

for i in range(...) is recognised in PyParseFor and keeps its COUNTED-LOOP lowering — untouched. A range there is a loop bound, not a value, and lowering it as an object would put a heap allocation where an induction variable belongs. The value path is for the bound form (r = range(n) then for i in r), which had no path at all before.

Gate

make compiler/pascal26 (fixedpoint) + tools/gate.sh quick + make test-nilpy + stabilize-fast/pin. Test: test/test_nilpy_range_as_a_value.npy, diffed against CPython and wired into test-nilpy.

Log