Document number P3806R1
Date 2026-07-11
Audience LEWG, SG9 (Ranges)
Reply-to Hewill Kang <hewillk@gmail.com>

views::cycle

Abstract

This paper proposes adding views::cycle, a Tier 1 range adaptor as described in P2760, to enhance the C++29 Ranges library by enabling infinite repetition of a range's elements.

Revision history

R0

Initial revision.

R1

  1. Provide a bound version.

  2. Fix the calculation of cycle count according to user reports.

  3. Provide iter_swap specialization.

Discussion

There is currently no standard range adaptor in C++ that allows repeating a range endlessly. Although similar behavior can be approximated via views::repeat(r) | views::join or a custom generator, such constructs are limited to forward ranges and often introduce additional complexity and boilerplate. Additionally, they lack the semantic clarity and composability offered by a dedicated adaptor.

The ability to cycle through elements infinitely is a common requirement in many domains: circular buffers, animations, event loops, and more. This functionality exists natively in other modern languages (e.g., Python's itertools.cycle, Rust's .cycle()), highlighting a gap in C++'s otherwise powerful Ranges library.

We propose introducing views::cycle as a simple, intuitive, and efficient adaptor for producing infinite, multi-pass ranges that cycle through the original range. This fills a notable gap in the existing standard and improves parity with other languages while supporting both eager and lazy range pipelines:

  for (auto&& song : playlist | views::cycle | views::take(100)) {
    play(song);
  }

Design

Class signature

Since views::cycle repeats the original range, the minimum requirement is forward_range to ensure multi-pass. The proposed signature of cycle_view is:

  template<view V>
    requires forward_range<V>
  class cycle_view : public view_interface<cycle_view<V>> {
    // ...
  };
  

Handling of Empty Ranges

range/v3's cycle view (which is named cycled_view) requires the original range to be non-empty:

  explicit cycled_view(Rng rng)
    : rng_(std::move(rng))
  {
      RANGES_EXPECT(!ranges::empty(rng_));
  }

It always expects a non-empty range and produces an infinite range. This requires the user to ensure the input range is non-empty, or else the behavior is undefined:

  auto e = ranges::views::empty<int>;
  auto c1 = e | ranges::views::cycle;   // UB

  vector<int> v;
  auto c2 = v | ranges::views::cycle;   // also UB

Unlike range-v3's views::cycle, the proposed views::cycle supports empty ranges. This is consistent with other standard range adaptors that naturally handle empty input by producing empty views. Supporting empty ranges avoids undefined behavior in common scenarios, such as default-initializing a cycle_view:

  auto c = vector{42} | views::cycle;
  decltype(c) c2{};     // Default construction is now well-formed

This improves composability by aligning with the standard library's treatment of empty ranges, yielding intuitive and consistent behavior. Consequently, since a cycle_view can now be empty, its end() cannot be unreachable_sentinel as in range-v3. Instead, we use default_sentinel, with the following semantics:

This approach preserves correctness for both empty and infinite cases without introducing special-case logic.

Supporting empty ranges means we must handle operator+= more carefully. Since the arithmetic relies on the underlying range's size to calculate offsets and cycle counts, a division by zero will occur if the range is empty. To prevent this, we include an early return in operator+= when the distance is zero. This ensures that expressions like views::cycle(views::empty<int>).begin() += 0 remain safe and well-defined, effectively treating any advancement on an empty cycle as a no-op.

End Iterator Is Not Cached

For bidirectional operations such as operator--(), when we find that the current iterator has reached the beginning of the original range, we need to move the current iterator back to the last element of the original range:

  if (current_ == ranges::begin(base_)) {
    current_ = end-iterator-of-base_; // Repositioning
    // ...
  }
  --current_;

However, getting the end iterator of the original range may not be constant time unless the original range is common_range or models both sized_range and random_access_range; in such case, we can extract them as we do in cartesian-common-arg-end:

  template<cartesian-product-common-arg R>
  constexpr auto cartesian-common-arg-end(R& r) {       // exposition only
    if constexpr (common_range<R>) {
      return ranges::end(r);
    } else {
      return ranges::begin(r) + ranges::distance(r);
    }
  }

However, it is worth noting that range/v3 takes a more aggressive approach, providing bidirectional operations as long as the underlying range models bidirectional_range, and providing random-access operations as long as the underlying range models random_access_range.

For unsized ranges, random-access operation requires knowing the actual size to locate the correct position. In this case, range/v3's cycled_view will eagerly calculate the end iterator of the original range and cache it internally, so that it can be used to compute the size next time:

  const char* s = /* */;
  ranges::subrange rng(s,null_sentinel{});
  static_assert(!ranges::sized_range<decltype(rng)>);

  auto cycled = rng | ranges::views::cycle;
  auto it = cycled.begin() + 42;   // O(n) time to get the rng's end iterator for computing size

Similarly, for bidirectional operations, range-v3 will also eagerly cache the end iterator if the underlying range is not common_range.

Unlike range/v3, we deliberately avoid this optimization to prevent hidden costs and semantic surprises. Instead, we propose to restrict such operations only when the underlying range natively supports them, that is, when it is a random_access_range and sized_range for random-access, or a bidirectional_range and common_range for bidirectional support.

This avoids hidden O(n) complexity, preserves lazy evaluation, reduces internal state, and eliminates the need for caching, consistent with the design philosophy of other standard views.

difference_type Considerations

To support operations such as iterator difference or positioning, cycle_view::iterator needs to track how many full passes have been made through the base range. This is typically implemented by maintaining a counter n, which is incremented each time the iterator loops back to the beginning of the base range.

Although n can be represented using the difference_type of the underlying range, since the number of cycles itself only needs to count a finite number of iterations, for difference_type of cycle_view::iterator, the original difference_type may not be a good choice.

The reason is that the logical distance between two cycle_view::iterators may become much larger than any value that can be stored in the original difference_type; in such cases, subtracting them will result in overflow which leads to UB.

Noted that range/v3 chooses std::intmax_t, and we propose using an integral-class type to represent the difference_type of cycle_view::iterator, that is, an implementation-defined integer type that is sufficiently wide to accommodate the expected range of iteration.

Support for Bounded Cycle

Although range-v3 only provides an infinite cycle, we propose adding an optional count parameter: views::cycle(R, N).

Bounded cycles are a common requirement. While Ruby provides native cycle(n), other ecosystems typically rely on composing infinite cycles with truncation. Native support in C++ aligns with the design of views::repeat(x, N) and is strictly superior to the views::repeat(R, N) | views::join workaround: it exposes a sized_range interface, reduces view pipeline complexity, and improves compilation performance.

Not borrow range

cycle_view cannot be a borrowed_range, because its iterators internally store a pointer to the cycle_view in order to access the stored underlying range.

Specifically, the iterator relies on calling ranges::end/ranges::begin on underlying range to determine whether it has reached the end/begin of a cycle. As such, cycle_view cannot be a borrowed_range, because the validity of its iterators depends on the lifetime of the view object.

Common range

A bounded cycle_view satisfies the common_range by defining its terminal state using an iterator rather than a sentinel. For a range cycled N times, the end of the range is defined as the moment the iteration completes exactly N cycles, which corresponds to a state of n_ = N and an internal iterator pointing to ranges::begin(base_).

This allows every bounded cycle_view to trivially model common_range. This architecture eliminates the need for a dedicated sentinel type and provides seamless interoperability with standard algorithms and legacy APIs that require [begin, end) iterator pairs.

Implementation experience

The author implemented views::cycle based on libstdc++, see here.

Proposed change

This wording is relative to latest working draft.

    1. Add a new feature-test macro to 17.3.2 [version.syn]:

      #define __cpp_lib_ranges_cycle 2025XXL // freestanding, also in <ranges>
    2. Modify 25.2 [ranges.syn], Header <ranges> synopsis, as indicated:

      #include <compare>              // see [compare.syn]
      #include <initializer_list>     // see [initializer.list.syn]
      #include <iterator>             // see [iterator.synopsis]
      
      namespace std::ranges {
        […]
        namespace views { inline constexpr unspecified enumerate = unspecified; }
      
      // [range.cycle], cycle view
        enum class cycle_view_kind : bool { unbounded, bounded };
      
        template<view V, cycle_view_kind K = cycle_view_kind::unbounded>
          requires forward_range<V>
        class cycle_view;
      
        namespace views { inline constexpr unspecified cycle = unspecified; }
        […]
      }
              
    3. Add 25.7.? Cycle view [range.cycle] after 25.7.24 [range.enumerate] as indicated:

      -1- A cycle view presents a view that repeats the source range, either indefinitely or for a specified number of times.

      -2- The name views::cycle denotes a range adaptor object ([range.adaptor.object]). Given a subexpression E and F, the expression views::cycle(E) and views::cycle(E, F) is expression-equivalent to cycle_view<views::all_t<decltype((E))>>(E) and cycle_view(E, F), respectively.

      [Drafting note: This prevents cycle_view{E} from returning the same cycle_view for E that is already a cycle_view specialization.]

    -3- [Example 1:

      auto cycle = views::iota(0, 3) | views::cycle;
      println("{} ", cycle | views::take(10)); // prints [0, 1, 2, 0, 1, 2, 0, 1, 2, 0]
    end example]

    [25.7.?.2] Class template cycle_view [range.cycle.view]

    namespace std::ranges {
      template<view V, cycle_view_kind K = cycle_view_kind::unbounded>
        requires forward_range<V>
      class cycle_view : public view_interface<cycle_view<V, K>> {
      private:
        V base_ = V();                                      // exposition only
        see below n_ = 0;                                   // exposition only, present only 
                                                            // if K == cycle_view_kind::bounded is true
    
        // [range.cycle.iterator], class template cycle_view::iterator
        template<bool> class iterator;                      // exposition only
      
      public:
        cycle_view() requires default_initializable<V> = default;
        constexpr explicit cycle_view(V base) requires (K == cycle_view_kind::unbounded);
        constexpr explicit cycle_view(V base, decltype(n_) n)
          requires (K == cycle_view_kind::bounded);
    
        constexpr V base() const & requires copy_constructible<V> { return base_; }
        constexpr V base() && { return std::move(base_); }
    
        constexpr iterator<false> begin() requires (!simple-view<V>)
        { return iterator<false>(*this, ranges::begin(base_)); }
    
        constexpr iterator<true> begin() const requires range<const V> 
        { return iterator<true>(*this, ranges::begin(base_)); }
    
        constexpr iterator<false> end() requires (K == cycle_view_kind::bounded) && range<V>
        { return iterator<false>(*this, ranges::begin(base_), ranges::empty(base_) ? 0 : n_); }
        
        constexpr iterator<true> end() const requires (K == cycle_view_kind::bounded) && range<const V>
        { return iterator<true>(*this, ranges::begin(base_), ranges::empty(base_) ? 0 : n_); }
        
        constexpr default_sentinel_t end() const noexcept requires (K == cycle_view_kind::unbounded)
        { return default_sentinel; }
    
        constexpr auto size() requires (K == cycle_view_kind::bounded) && sized_range<V>;
        constexpr auto size() const
          requires (K == cycle_view_kind::bounded) && sized_range<const V>;
    
        constexpr auto reserve_hint()
          requires (K == cycle_view_kind::bounded) && approximately_sized_range<V>;
        constexpr auto reserve_hint() const
          requires (K == cycle_view_kind::bounded) && approximately_sized_range<const V>;
      };
    
      template<class R>
        cycle_view(R&&) -> cycle_view<views::all_t<R>>;
    
      template<class R, class T>
        cycle_view(R&&, T) -> cycle_view<views::all_t<R>, cycle_view_kind::bounded>;
    }

    -1- The type of n_ is an implementation-defined signed-integer-like type.

    constexpr explicit cycle_view(V base) requires (K == cycle_view_kind::unbounded);

    -2- Effects: Initializes base_ with std::move(base).

    constexpr explicit cycle_view(V base, decltype(n_) n)
      requires (K == cycle_view_kind::bounded);

    -3- Preconditions: n >= 0 is true.

    -4- Effects: Initializes base_ with std::move(base) and n_ with n.

    constexpr auto size() requires (K == cycle_view_kind::bounded) && sized_range<V>;
    constexpr auto size() const
      requires (K == cycle_view_kind::bounded) && sized_range<const V>;

    -5- Effects: Equivalent to:

      return to-unsigned-like(ranges::size(base_)) * to-unsigned-like(n_);
    constexpr auto reserve_hint()
      requires (K == cycle_view_kind::bounded) && approximately_sized_range<V>;
    constexpr auto reserve_hint() const
      requires (K == cycle_view_kind::bounded) && approximately_sized_range<const V>;

    -6- Effects: Equivalent to:

      return to-unsigned-like(ranges::reserve_hint(base_)) * to-unsigned-like(n_);

    [25.7.?.3] Class cycle_view::iterator [range.cycle.iterator]

    namespace std::ranges {
      template<view V, cycle_view_kind K>
      template<bool Const>
      class cycle_view<V, K>::iterator {
      public:
        using iterator_concept  = see below;
        using iterator_category = see below;
        using value_type        = range_value_t<Base>;
        using difference_type   = see below;
    
      private:
        using Parent = maybe-const<Const, cycle_view<V, K>>;   // exposition only
        using Base = maybe-const<Const, V>;                    // exposition only
        
        iterator_t<Base> current_ = iterator_t<Base>();        // exposition only
        Parent* parent_ = nullptr;                             // exposition only
        difference_type n_ = 0;                                // exposition only
    
        constexpr iterator(Parent& parent, iterator_t<Base> current, 
                           difference_type n = 0);             // exposition only
    
      public:
        iterator() requires default_initializable<iterator_t<Base>> = default;
    
        constexpr iterator(iterator<!Const> i)
          requires Const && convertible_to<iterator_t<V>, iterator_t<Base>>;
    
        constexpr iterator_t<Base> base() const;
    
        constexpr decltype(auto) operator*() const { return *current_; }
    
        constexpr iterator_t<Base> operator->() const 
          requires has-arrow<iterator_t<Base>>;
    
        constexpr iterator& operator++();
        constexpr iterator operator++(int) = default;
    
        constexpr iterator& operator--() 
          requires bidirectional-common<Base> || sized-random-access-range<Base>;
        constexpr iterator operator--(int)
          requires bidirectional-common<Base> || sized-random-access-range<Base> = default;
    
        constexpr iterator& operator+=(difference_type x)
          requires sized-random-access-range<Base>;
        constexpr iterator& operator-=(difference_type x)
          requires sized-random-access-range<Base>;
    
        constexpr decltype(auto) operator[](difference_type n) const
          requires sized-random-access-range<Base>
        { return *(*this + n); }
    
        friend constexpr bool operator==(const iterator& x, const iterator& y);
        friend constexpr bool operator==(const iterator& x, default_sentinel_t)
          requires (K == cycle_view_kind::unbounded);
    
        friend constexpr bool operator<(const iterator& x, const iterator& y)
          requires random_access_range<Base>;
        friend constexpr bool operator>(const iterator& x, const iterator& y)
          requires random_access_range<Base>;
        friend constexpr bool operator<=(const iterator& x, const iterator& y)
          requires random_access_range<Base>;
        friend constexpr bool operator>=(const iterator& x, const iterator& y)
          requires random_access_range<Base>;
        friend constexpr compare_three_way_result_t<iterator_t<Base>>
          operator<=>(const iterator& x, const iterator& y)
            requires random_access_range<Base> && three_way_comparable<iterator_t<Base>>;
    
        friend constexpr iterator operator+(const iterator& i, difference_type n)
          requires sized-random-access-range<Base>;
        friend constexpr iterator operator+(difference_type n, const iterator& i)
          requires sized-random-access-range<Base>;
        friend constexpr iterator operator-(const iterator& i, difference_type n)
          requires sized-random-access-range<Base>;
        friend constexpr difference_type operator-(const iterator& x, const iterator& y)
          requires sized_sentinel_for<iterator_t<Base>, iterator_t<Base>> && 
                   sized_range<Base>;
      
        friend constexpr range_rvalue_reference_t<Base> iter_move(const iterator& i)
          noexcept(noexcept(ranges::iter_move(i.current_)));
    
        friend constexpr void iter_swap(const iterator& x, const iterator& y)
          noexcept(noexcept(ranges::iter_swap(x.current_, y.current_)))
          requires indirectly_swappable<iterator_t<Base>>;
      };
    }

    -1- iterator::iterator_concept is defined as follows:

    1. (1.1) — If Base models sized-random-access-range, then iterator_concept denotes random_access_iterator_tag.

    2. (1.2) — Otherwise, if Base models bidirectional-common, then iterator_concept denotes bidirectional_iterator_tag.

    3. (1.3) — Otherwise, iterator_concept denotes forward_iterator_tag.

    -2- iterator::iterator_category is defined as follows:

    1. (2.1) — Let C denote iterator_traits<iterator_t<Base>>::iterator_category.

    2. (2.2) — If C models derived_from<random_access_iterator_tag> and Base models sized_range, iterator_category denotes random_access_iterator_tag.

    3. (2.3) — Otherwise, if C models derived_from<bidirectional_iterator_tag> and Base models common_range, iterator_category denotes bidirectional_iterator_tag.

    4. (2.4) — Otherwise, iterator_category denotes forward_iterator_tag.

    -3- iterator::difference_type is an implementation-defined signed-integer-like type.

    constexpr iterator(Parent& parent, iterator_t<Base> current, difference_type n = 0);

    -4- Effects: Initializes current_ with std::move(current), parent_ with addressof(parent), and n_ with n.

    constexpr iterator(iterator<!Const> i)
          requires Const && convertible_to<iterator_t<V>, iterator_t<Base>>;

    -5- Effects: Initializes current_ with std::move(i.current_), parent_ with i.parent_, and n_ with i.n_.

    constexpr iterator_t<Base> base() const;

    -6- Effects: Equivalent to: return current_;

    constexpr iterator_t<Base> operator->() const 
          requires has-arrow<iterator_t<Base>>;

    -7- Effects: Equivalent to: return current_;

    constexpr iterator& operator++();

    -8- Effects: Equivalent to:

      if (++current_ == ranges::end(parent_->base_)) {
        current_ = ranges::begin(parent_->base_);
        ++n_;
      }
      return *this;
    constexpr iterator& operator--()
      requires bidirectional-common<Base> || sized-random-access-range<Base>;

    -9- Effects: Equivalent to:

      if (current_ == ranges::begin(parent_->base_)) {
        if constexpr (common_range<Base>)
          current_ = ranges::end(parent_->base_);
        else
          current_ = ranges::begin(parent_->base_) + ranges::distance(parent_->base_);
        --n_;
      }
      --current_;
      return *this;
    constexpr iterator& operator+=(difference_type x)
      requires sized-random-access-range<Base>;

    -10- Effects: Equivalent to:

      const auto dist = ranges::distance(parent_->base_);
      if (dist == 0)
        return *this;
      const auto first = ranges::begin(parent_->base_);
      const auto new_x = x + (current_ - first);
      auto n_offset = new_x / dist;
      auto current_offset = new_x % dist;
      if (current_offset < 0) {
        n_offset -= 1;
        current_offset += dist;
      }
      n_ += n_offset;
      current_ = first + static_cast<range_difference_t<Base>>(current_offset);
      return *this;
    constexpr iterator& operator-=(difference_type x)
      requires sized-random-access-range<Base>;

    -11- Effects: Equivalent to: return *this += -x;

    friend constexpr bool operator==(const iterator& x, const iterator& y);

    -12- Returns: x.n_ == y.n_ && x.current_ == y.current_.

    friend constexpr bool operator==(const iterator& x, default_sentinel_t)
      requires (K == cycle_view_kind::unbounded);

    -13- Returns: ranges::empty(x.parent_->base_).

    friend constexpr bool operator<(const iterator& x, const iterator& y)
      requires random_access_range<Base>;
    friend constexpr bool operator>(const iterator& x, const iterator& y)
      requires random_access_range<Base>;
    friend constexpr bool operator<=(const iterator& x, const iterator& y)
      requires random_access_range<Base>;
    friend constexpr bool operator>=(const iterator& x, const iterator& y)
      requires random_access_range<Base>;
    friend constexpr compare_three_way_result_t<iterator_t<Base>>
      operator<=>(const iterator& x, const iterator& y)
        requires random_access_range<Base> && three_way_comparable<iterator_t<Base>>;

    -14- Let op be the operator.

    -15- Effects: Equivalent to:

      if (x.n_ != y.n_)
        return x.n_ op y.n_;
      return x.current_ op y.current_;
    
    friend constexpr iterator operator+(const iterator& i, difference_type n)
      requires sized-random-access-range<Base>;
    friend constexpr iterator operator+(difference_type n, const iterator& i)
      requires sized-random-access-range<Base>;

    -16- Effects: Equivalent to:

      auto r = i;
      r += n;
      return r;
    

    friend constexpr iterator operator-(const iterator& i, difference_type n)
      requires sized-random-access-range<Base>;

    -17- Effects: Equivalent to:

      auto r = i;
      r -= n;
      return r;
    

    friend constexpr difference_type operator-(const iterator& x, const iterator& y)
      requires sized_sentinel_for<iterator_t<Base>, iterator_t<Base>> && 
               sized_range<Base>;

    -18- Effects: Equivalent to:

      return (x.n_ - y.n_) * ranges::distance(x.parent_->base_) + (x.current_ - y.current_);

    friend constexpr range_rvalue_reference_t<Base> iter_move(const iterator& i)
      noexcept(noexcept(ranges::iter_move(i.current_)));

    -19- Effects: Equivalent to: return ranges::iter_move(i.current_);

    friend constexpr void iter_swap(const iterator& x, const iterator& y)
      noexcept(noexcept(ranges::iter_swap(x.current_, y.current_)))
      requires indirectly_swappable<iterator_t<Base>>;

    -20- Effects: Equivalent to: ranges::iter_swap(x.current_, y.current_);

References

[P2760R1]
Barry Revzin. A Plan for C++26 Ranges. URL: https://www.open-std.org/jtc1/sc22/wg21/docs/papers/2023/p2760r1.html