100% Free Forever
AI-Powered Learning
Industry Expert Content
Certificates & Badges
Learn At Your Own Pace
Programming

Ranges in D

Learn how D's range interface unifies iteration over arrays, containers, and lazy sequences, and how to compose algorithms with std.range and std.algorithm.

Advanced DIntermediate9 min readJul 10, 2026
Analogies

What Is a Range?

In D, a range is not a specific type but a compile-time interface — any type that implements the right set of primitives is automatically a range, checked via templates like isInputRange!R. The simplest category, InputRange, requires three primitives: a bool empty property that reports whether iteration is finished, a front property that returns the current element without removing it, and a popFront() method that advances the range. Because this is structural typing (duck typing via templates), arrays, custom structs, and even lazy generators can all be passed to the same generic algorithms in std.algorithm without inheritance or explicit interface declarations.

🏏

Cricket analogy: A range is like a bowler's over card that only needs three facts — whether the over is complete (empty), which ball is coming up (front), and how to move to the next delivery (popFront) — regardless of whether it's Bumrah or a club bowler holding the ball.

Lazy, Composable Algorithms

Most of std.algorithm's building blocks — map, filter, take, until — do not eagerly allocate a new array; instead they return a lightweight struct that wraps the source range and computes each element only when front is evaluated. This means an expression like iota(1, 1_000_000).filter!(x => x % 7 == 0).map!(x => x * x).take(5) never touches most of the million numbers; only the first five results that satisfy the predicate are ever computed, because popFront on the outer range pulls values through the whole pipeline on demand. This laziness lets you build long algorithm pipelines cheaply and only pay for the work you actually consume, which is essential when working with infinite ranges like an unbounded Fibonacci generator.

🏏

Cricket analogy: It's like a highlights package generator that only renders a six or a wicket clip when the editor scrubs to that timestamp, rather than pre-rendering every ball of a Virat Kohli innings up front — you pay only for the highlights you actually watch.

d
import std.algorithm : map, filter;
import std.range : iota, take;
import std.stdio : writeln;

void main()
{
    auto pipeline = iota(1, 1_000_000)
        .filter!(x => x % 7 == 0)
        .map!(x => x * x)
        .take(5);

    // Nothing has been computed yet -- pipeline is a lazy range.
    foreach (value; pipeline)
        writeln(value); // 49, 196, 441, 784, 1225
}

std.range provides ready-made range adaptors — chain (concatenate ranges), zip (iterate several ranges in lockstep), retro (reverse a bidirectional range), and stride (skip every Nth element) — so you rarely need to hand-write a custom range for common patterns.

Custom Ranges and the Range Hierarchy

Writing your own range means implementing the primitives for the level of the hierarchy you need: InputRange (empty, front, popFront) is the baseline every foreach loop understands, ForwardRange additionally requires a save() method that returns an independent copy of the current iteration state so an algorithm can branch and retry without disturbing the original, and BidirectionalRange/RandomAccessRange add back()/popBack() and opIndex() respectively for reverse and indexed access. A crucial pitfall is that many custom ranges are reference types (classes or structs holding a pointer/slice), so passing one to a function or foreach that consumes it via popFront can silently exhaust the original range too — save() exists specifically so algorithms like std.algorithm.chunkBy or find can checkpoint progress safely.

🏏

Cricket analogy: It's like the difference between a bowler who can only bowl the next delivery (InputRange) versus one whose over can be "replayed" from a saved run-up via DRS review (save/ForwardRange) so the umpire can re-examine the same ball without losing track of the live over.

d
struct Countdown
{
    int current;

    bool empty() const => current < 0;
    int front() const => current;
    void popFront() => current--;
    Countdown save() const => this; // makes it a ForwardRange
}

void main()
{
    import std.stdio : writeln;
    import std.algorithm : each;

    auto c = Countdown(5);
    c.each!writeln; // 5, 4, 3, 2, 1, 0
}

InputRanges consumed by foreach or algorithms like find are mutated in place if they are reference types (classes, or structs wrapping a slice/pointer) — always call save() before branching, or you'll find the 'same' range has already been exhausted the second time you iterate it.

  • A range is a compile-time interface, not a base class — any type with empty/front/popFront satisfies InputRange.
  • The hierarchy (InputRange < ForwardRange < BidirectionalRange < RandomAccessRange) adds capabilities: save(), back()/popBack(), and opIndex().
  • std.algorithm functions like map, filter, and take are lazy — they compute elements only when pulled via popFront.
  • Laziness allows infinite or huge ranges (like iota over a billion values) to be processed cheaply.
  • save() exists to let algorithms checkpoint a ForwardRange without disturbing the original iteration state.
  • Reference-type ranges can be silently exhausted if consumed twice without calling save() first.
  • std.range offers chain, zip, retro, and stride as ready-made adaptors instead of hand-rolled ranges.

Practice what you learned

Was this page helpful?

Topics covered

#Programming#DProgrammingStudyNotes#RangesInD#Ranges#Range#Lazy#Composable#StudyNotes#SkillVeris#ExamPrep