Why is `iota(0) | common` not a `random_access_range`?

Summary

A std::ranges::iota_view, which is a RandomAccessRange, loses its random access capability when piped through std::views::common. This prevents the usage of the subscript operator ([]) on the resulting view, even though the goal was simply to enable std::ranges::distance calculations (which require a common_range) on a taken or unbounded sequence. The view becomes a common_range but downgrades to a BidirectionalRange (or worse), leading to a frustrating choice between distance support and index performance.

Root Cause

The root cause lies in the C++20 standard requirements for common_range and the implementation constraints of common_view.

  1. common_view Implementation: std::views::common wraps a view V into a common_view<V>. The iterator of common_view must hold both the iterator and the sentinel of the underlying view V (or handle the case where they are different types).
  2. Sentinel Storage: To provide the end() iterator for a common_range, the common_view::iterator must store a sentinel value.
  3. Iterator/Sentinel Mismatch: For a random_access_range like iota_view, the iterator and sentinel types are usually distinct (or at least, the sentinel lacks the arithmetic operations). To make them the same type (the definition of common_range), common_view creates a unified wrapper iterator.
  4. Missing random_access_iterator Tag: Because the common_view::iterator is essentially a “type-erased” or “adapted” iterator that stores extra state (the sentinel), it cannot satisfy the strict requirements of a random_access_iterator unless the underlying iterator and sentinel are exactly the same type and trivial.
  5. The iota Case: iota_view‘s iterator and sentinel are distinct types. Therefore, common_view<iota_view>::iterator cannot provide the O(1) random access operations (like +, -, []) required by the random_access_iterator concept.

Why This Happens in Real Systems

This behavior stems from the tension between type erasure and performance guarantees.

  • Uniformity vs. Capability: To turn a view V into a common_range, we force the begin() and end() to return the same type. This requires wrapping the original iterator and sentinel into a common object.
  • Abstraction Penalty: The standard library refuses to lie about capabilities. If the resulting iterator wrapper cannot implement operator- (calculating distance between iterator/sentinel) or operator[] efficiently without relying on the underlying types being identical, it simply does not model random_access_iterator.
  • Design Philosophy: C++ ranges are designed to be zero-overhead abstractions. Returning a random_access_iterator tag when the operations aren’t actually random access (or require branching to handle the sentinel state) would violate this principle.

Real-World Impact

  • Algorithm Limitations: Algorithms that require RandomAccessIterator (like std::sort or specific std::ranges variants) will fail to compile on iota | common.
  • Ergonomics Trade-off: Developers must choose between:
    • iota(0) | take(5) -> Random Access, but std::distance fails (non-common).
    • iota(0) | take(5) | common -> std::distance works, but random access ([n]) fails.
  • API Friction: It forces the developer to know the internal category of the view after adaptation, breaking the abstraction where “a range is a range.”
  • Workaround Bloat: Users must often write manual loops or convert to a std::vector just to bridge the gap between “distanceable” and “randomly accessible.”

Example or Code

#include 
#include 
#include 

int main() {
    // 1. iota is Random Access
    auto v1 = std::views::iota(0);
    static_assert(std::ranges::random_access_range); // Pass
    std::cout << v1[1] << "\n"; // OK: prints 1

    // 2. iota | common loses Random Access
    auto v2 = std::views::iota(0) | std::views::common;
    static_assert(std::ranges::common_range); // Pass
    static_assert(std::ranges::random_access_range); // FAIL

    // 3. The specific use case: take + common
    auto v3 = std::views::iota(0) | std::views::take(2) | std::views::common;

    // We want distance (requires common_range)
    auto dist = std::ranges::distance(v3.begin(), v3.end()); // OK

    // We want index access (requires random_access_range)
    // std::cout << v3[1]; // Compile Error: no operator[]
}

How Senior Engineers Fix It

Senior engineers approach this by analyzing the specific requirements of the downstream algorithm.

  1. Check the Constraint: If the algorithm needs std::distance but not random access, common is fine.
  2. Invert the Pipeline: If you need random access and common behavior (like with iota), check if the underlying view supports both. std::views::iota(0, 10) is both common and random_access. Use the bounded version whenever possible.
  3. Custom Sentinel/Destruction: If using an unbounded view (like iota(0)), realize that common is fundamentally the wrong tool if you also need indexing.
  4. Strategy Pattern: Instead of adapting the view, adapt the usage:
    • If you need to pass the view to a function that calculates distance, wrap that function call to use std::ranges::distance (which handles non-common ranges for sized ranges, or requires sized_range).
    • Note: std::ranges::distance works on iota(0) | take(2) if the view is sized_range.
    • Correction: iota(0) | take(2) is NOT a sized_range (size is unknown at compile time, and querying size consumes the view). So the user must use common to get distance if they can’t pass the view by value to distance.
  5. Manual Range Construction: Construct a std::subrange explicitly if you have the iterator and sentinel and want to force it to be random_access (though this usually requires the iterator/sentinel to support subtraction).

Why Juniors Miss It

  • Mental Model of “Common”: Juniors often think common just “fixes” range issues without downsides. They view it as a magic “make it work” button, missing that it incurs a type erasure penalty.
  • Category Confusion: They don’t realize that common_view is a distinct type with its own iterator capabilities, decoupled from the wrapped view’s capabilities.
  • Over-reliance on common: They learn that iota(0) | take(2) fails distance (because of the sentinel mismatch) and immediately slap | common on it, assuming it maintains all previous capabilities.
  • Hidden Requirements: They might not know that operator[] requires random_access_iterator, and that common_view‘s iterator is usually just a BidirectionalIterator because it has to handle the unified iterator/sentinel state management.