Representative interview topic

C++23 Interview: How Does std::generator Implement a Lazy Range?

CodingHard
Offer.cc Editorial TeamPublished Updated

Question

Use std::generator to produce a lazy sequence. When is it better than returning a vector, a callback, or views, and how do you avoid dangling references and leaks?

Prompt and Scope

You must traverse a potentially huge tree or file stream and produce a value only when the consumer asks for the next one. You cannot materialize all results in memory. Use C++23 std::generator as a lazy range, explain co_yield, exceptions, and lifetime, and compare alternatives.

This coding question focuses on coroutine handles, input-range semantics, and resource boundaries. Assume one thread, one forward traversal, and that referenced external objects outlive the traversal.

What the Interviewer Evaluates

  • Whether you distinguish lazy generation, materialized containers, and ordinary views.
  • Whether you understand what happens at first iteration, each ++, completion, and destruction.
  • Whether you catch lifetime risks for locals, temporaries, references, and asynchronous resources.
  • Whether you explain exception propagation, early stop, and recursive elements_of costs.
  • Whether data size, first-item latency, peak memory, and reuse needs drive the choice.

Clarifying Questions Before Answering

  1. Is the consumer single-pass or does it need reuse? Single-pass favors a generator; reuse may favor a container.
  2. Are elements values, references, or views? References avoid copies but extend lifetime requirements.
  3. Does generation block on I/O, wait for events, or cross threads? std::generator is synchronous and does not schedule async work.
  4. Are random access, size, or parallel algorithms required? An input range usually does not provide them.
  5. What happens when traversal stops early? File handles, locks, and buffers need an explicit owner and cleanup path.

30-Second Answer Framework

I model the generator as a synchronous, forward input range. co_yield suspends at each element; incrementing the consumer resumes the coroutine until the next yield, return, or exception. It fits large results that are consumed once and should produce the first item quickly. For random access, repeated traversal, or cross-thread async I/O, I choose a vector, a view pipeline, or an async stream, after checking source and resource lifetimes.

Step-by-Step Deep Dive

1. Establish the range and ownership

std::generator<T> is a C++23 synchronous coroutine range. Calling the generator function generally creates coroutine state; execution starts during iteration. The generator owns its frame, which is released when iteration ends or the generator is destroyed. Never return a reference to a local container; the caller or an outer owner must keep a referenced object alive.

2. Keep a constant working set with co_yield

cpp
#include <generator>

std::generator<int> range(int first, int last) {
  for (int value = first; value < last; ++value) {
    co_yield value;
  }
}

void consume() {
  for (int value : range(0, 1'000'000)) {
    if (value == 10) break;
  }
}

The code does not build one million elements first; each resume advances to the next co_yield. break destroys the iterator and generator, so the coroutine frame cannot be reused. Validate C++23 support against the selected standard-library and compiler version.

3. Compare four implementations

Returning a vector is simplest and supports size, random access, and reuse, but materializes everything. A callback gives control to the producer but composes poorly with range adaptors. A hand-written input iterator works before C++23 but must maintain state, end, and exception rules. std::views suits stateless transformations over an existing range; a generator suits state machines, recursive traversal, or logic that should advance only when pulled.

4. Handle recursion and references

A tree walk can compose child generators with elements_of instead of nested loops, but measure depth, coroutine-frame count, and exception paths. If yielding std::string_view or node references, source strings and nodes must remain valid for the whole traversal. Never yield a view into a temporary string or store the generator beyond the source owner’s lifetime.

5. Handle exceptions, early stop, and resources

An exception in the generator reaches the consumer when the iterator resumes; the consumer decides whether to log, retry, or stop. break is not a business-level commit. File handles, locks, and temporary buffers should be RAII objects in the generator frame and released on destruction. This is synchronous; co_yield does not wait for a network or turn blocking I/O into async work.

6. Close with boundary benchmarks

Test empty and one-element ranges, huge ranges, recursion depth, exception aborts, and invalidated references. Compare vector, generator, and view pipelines on first-item latency, full runtime, peak RSS, allocations, repeatability, and cleanup after cancellation. Introduce coroutine complexity only when one-pass consumption and memory constraints make the lazy benefit material.

High-Quality Sample Answer

I choose std::generator when the result is large, consumed once in order, and production can pause after each item. Calling the generator creates coroutine state; the iterator resumes it until the next co_yield, so the consumer receives the first value early without materializing the entire result.

I make ownership explicit: the source tree, file, and strings outlive the generator, while coroutine handles and temporary resources use RAII. I return a vector for random access, size, or repeated traversal; use views for pure transformations over existing ranges; and use an async-stream abstraction for network waits or cross-thread work. I benchmark empty input, early break, exceptions, deep recursion, and huge input, comparing first-item latency, peak memory, throughput, and cleanup before accepting the semantic cost.

Common Mistakes

Treating a generator as an async stream

It is synchronous and cannot await a network. Use an async runtime and explicit async-stream interface instead.

Returning references or views to locals

The local can die while the coroutine is suspended. Let an owner cover the entire traversal or yield values.

Assuming break completes business cleanup

Early stop ends iteration, not an external transaction. Use RAII cleanup and explicit cancellation semantics.

Measuring only full runtime

Lazy ranges may win on first-item latency and peak memory. Measure first item, RSS, allocations, and repeatability too.

Follow-Ups and Responses

Follow-up 1: Can a generator be consumed in parallel?

One input generator is normally a single-direction state machine and should not be incremented by multiple threads. Partition the input or create independent generators and define merge order.

Follow-up 2: How do you traverse a very deep tree?

Compose child generators with elements_of but measure frames and depth. For unbounded depth, an explicit stack makes memory limits and cancellation more visible.

Follow-up 3: What if a consumer stores an element reference?

Document validity through the next increment or generator destruction unless an external owner keeps the source alive. Copy the value or transfer ownership for long-term storage.

Follow-up 4: What if a C++20 project lacks std::generator?

Use a project generator or input-iterator wrapper, or return a view, but specify ownership, end, and exception contracts. Renaming C++23 syntax does not recreate its semantics.

Public sources

Related questions

Related interview tool

Use Screenshot for a coding prompt

Capture the problem, then work through the constraints, solution, code, edge cases, and complexity in order.

View the tool