Articles
C++ Ranges Interview Questions: Lazy Views, Lifetimes, and Repeated Evaluation
Practice C++ ranges interview questions with lazy evaluation traces. Diagnose dangling views, repeated transforms, borrowed ranges, and materialization choices.
C++ ranges interview questions often start with an attractive pipeline and end with a lifetime or evaluation surprise. A view does not necessarily own its elements, does not generally save transformed results, and can perform work again when you dereference or traverse it again.
The practical answer is to separate ownership, traversal, and evaluation. Ask which object keeps the source alive, which objects the iterator depends on, and which operations invoke the callable. Then decide whether deferred computation is appropriate or whether the caller needs a stable, materialized result.
This article is for C++ candidates preparing code-review and debugging answers. The baseline is C++20 ranges; availability of later adaptors and defect-resolution updates must be checked against your compiler and standard library.
C++ ranges interview questions: what is a view?
A range exposes traversal through a beginning and an end. A view is a range with specified lightweight movement and construction properties suitable for composition. Some views borrow another object's elements; others retain owning state. “View” is not a synonym for “non-owning,” “snapshot,” or “no allocation anywhere.”
The source, view, iterator, sentinel, and captured callable state may have different lifetimes. Drawing those objects separately is more useful than memorizing that a pipeline is lazy. The working draft specifies the range and borrowed-range concepts separately from individual adaptor behavior. Range concepts
In an ordinary pipeline over an lvalue vector, the view generally refers to that vector rather than copying all its elements. Destroying or invalidating the underlying storage can invalidate later accesses. Copying the view does not necessarily copy the vector or create an independent lifetime.
An owning wrapper changes the source edge, but it does not own everything referenced by a lambda. A transform that captures a local lookup table by reference can still dangle even when the base container is safely owned. Audit the complete dependency graph, not just the first adaptor.
When does a lazy pipeline do work?
Use this original diagnostic fixture:
std::vector<int> values{1, 2, 3};
int calls = 0;
auto pipeline = values
| std::views::transform([&calls](int n) {
++calls; return n * 2;
})
| std::views::filter([](int n) {
return n % 4 == 0;
});
The transform returns the same doubled value for the same input; the counter is diagnostic observation, not part of the transformed result. Do not replace it with a callable whose output changes on each invocation and then assume the range's semantic requirements still hold.
Construction does not walk this vector. Finding the first accepted element does: the filter examines transformed values two and four, so the transform runs twice. Dereferencing the resulting transform iterator computes four again. The pipeline has found a position, not retained a saved transformed integer.
The transform iterator's dereference applies its stored callable to the underlying element. A filter over a forward range caches its first matching position for later begin() calls; that is not a cache of every transform result. Transform iterator, filter begin behavior
A repeated-evaluation trace you can defend
For the exact pipeline above, manually perform these operations on the same view. Keep the source unchanged and use a forward-range implementation satisfying the documented filter-begin caching behavior.
| Operation | Transform-call count and reason |
|---|---|
| Construct the pipeline | Zero: no traversal yet. |
| Obtain its first iterator | Two: scan inputs one and two to find transformed value four. |
| Dereference twice | Four: each dereference transforms input two again. |
| Increment to the end | Five: scan input three and reject transformed value six. |
| Begin again, dereference once, increment | Seven: cached first position avoids the initial rescan, but reading two and scanning three still perform work. |
The visible accepted sequence contains only four. That does not imply only one transform call, or one call per source element. The observation depends on this adaptor ordering and operation sequence; it is not a universal callback count for every range algorithm.
Now move the filter before the transform and adapt its predicate to the original values. The filter can identify accepted source elements before transformation, so rejected elements need not pay that transform cost. This is useful only when the selection condition can correctly be expressed on the original data.
Do not reorder adaptors merely because the new order is faster. If the predicate needs the transformed representation, filtering the input may change semantics. Start with an equivalence argument, then inspect work. A claimed optimization without that argument can quietly select a different result set.
Materialization creates a boundary, not a universal win
If transformed values are expensive, needed repeatedly, or must remain stable, explicitly compute and store them. In C++20, a straightforward loop into a vector makes the boundary visible without depending on a later ranges::to implementation.
std::vector<int> saved;
for (int n : pipeline) saved.push_back(n);
Later traversal of saved reads stored integers instead of invoking the pipeline's transform. The price is allocation, storage, and an eager pass. For a single short-circuited search over a huge source, deferred evaluation may avoid much more work than it repeats.
Choose the boundary around the caller's need. A report that requires consistent values across several passes may want a snapshot. A streaming consumer may require bounded memory and only one pass. A computation backed by remote access may need explicit caching or batching, not an assumption that a generic view memoizes requests.
Materialization is also not transactional by itself. If computation or allocation fails halfway through, the destination may contain a prefix. Specify whether partial output is acceptable, whether a temporary result should be committed only after success, and which failure state the caller receives.
Diagnose a returned dangling view
Consider a factory that creates a local vector and returns a filter view constructed from that lvalue vector. The returned object preserves a reference to the local vector. When the factory exits, the vector is destroyed. The later traversal is invalid even though returning the small view object itself succeeded.
This failure is different from returning an owning container with move elision. The view's reference relationship does not become ownership because the return statement looks efficient. Name the object destroyed at the factory boundary and the later operation that attempts to use it.
One repair is to return the materialized vector. Another is to give the view an owning base and return that composition, provided its callable does not borrow dead state. Current views::all rules can choose a reference wrapper or an owning wrapper according to the supplied range; older libraries and C++20-era behavior need version verification. All-view adaptor
Do not infer safety from a temporary alone. A temporary span can refer to long-lived storage; an owning view can refer through a lambda to short-lived storage. The type and the dependency graph decide which lifetime matters.
In an interview, replace “temporaries always dangle” with a concrete lifetime statement. That answer is both more accurate and more useful when the interviewer changes the input from a vector to a span or supplies an owning wrapper.
What does borrowed_range protect?
A borrowed range allows its iterators to remain valid with respect to destruction of the range object itself, subject to the underlying storage still being valid. It does not promise that an unrelated buffer lives forever or that container mutations cannot invalidate iterators.
A span is a useful example: destroying the span wrapper does not destroy the buffer it describes. But destroying the vector whose data supplied that buffer, or reallocating it, still invalidates accesses. The wrapper's lifetime and the storage lifetime are independent.
Ranges algorithms can return std::ranges::dangling instead of an iterator when an rvalue range does not meet the borrowed-range condition. This makes a particular unsafe iterator-result pattern visible in the type system. It is not a runtime lifetime detector for every pointer, capture, or view. Dangling iterator results
Also check whether an iterator depends on its parent view. An iterator into a transform or filter composition can need the view's callable or other state. Keeping only the source vector alive is not necessarily enough if the view providing that state has been destroyed.
Ask the interviewer which result is required: an iterator to original storage, a copied value, or an owning result. Often a copied value avoids exposing a dependency that the caller does not need to manage.
Mutation and single-pass sources change the answer
Views are generally not snapshots. If the source changes between traversals, later observations may change; if a mutation invalidates iterators or breaks the adaptor's assumptions, later traversal may be invalid rather than simply different.
Filter-begin caching introduces another reason not to assume a changed predicate or changed source causes a full fresh scan. Rebuild the view when you need a freshly selected sequence after changing the relevant data or predicate state. Better still, keep selection conditions stable while iterators are in use.
Do not test mutation by deliberately invalidating an iterator and asking whether it still prints a plausible value. Establish the container's invalidation rule first. Reallocation, erasure, insertion, and element replacement have different effects, and a pipeline does not erase those distinctions.
Input ranges add a traversal boundary. An input stream may support only one logical pass, so a diagnostic pass can consume data needed by the actual algorithm. Repeated begin() is not a general reset operation. Use a materialized buffer, a reproducible source, or a single-pass algorithm according to the contract.
The important follow-up is not “Are ranges fast?” It is “What work and lifetime guarantees does this particular composition provide under this source category and mutation policy?” That question leads to testable answers.
Practice related iterator and storage contracts
| Question | Ranges-specific follow-up |
|---|---|
| Implement Composable Range Iterators | Explain when each composed iterator advances and computes. |
| Implement a Simplified std::vector in C++: Growth, Memory, Exception Safety, and Type Traits | Identify the storage operation that invalidates a borrowed view. |
| Refactor a Correct but Quadratic C++ Routine Under a Microsecond Budget | Distinguish deferred work from eliminated repeated work. |
| Design a Lazy Union Iterator for Sorted Inputs | Maintain laziness without losing traversal invariants. |
| Implement iterator merging lists with filtering | Separate scanning rejected elements from reading accepted ones. |
Turn a pipeline into an explainable contract
For C++ ranges interview questions, draw the owners before tracing the callbacks. State which operations compute values, which positions are cached, and when the source or parent view may cease to be valid. Choose eager storage only when its snapshot or reuse benefits justify the cost.
Take the composable iterator exercise, add a transform followed by a filter, and predict the count before running it. Then return the composition from a factory and explain every object that must survive. That second step turns a working pipeline into a defensible design.
Sources and Further Reading
- C++ working draft: filter view and begin caching
- C++ working draft: transform iterator evaluation
- C++ working draft: views::all and owning/reference wrappers
- C++ working draft: range and borrowed-range concepts
- C++ working draft: dangling algorithm results
Research checked October 3, 2026. The callback trace is an original fixture with unchanged forward-range storage and a stable transform result. Current working-draft wording is not a blanket statement that every C++20 library supports every newer adaptor.