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

views::unique

Abstract

This paper proposes a range adaptor views::unique that filters out consecutive equivalent elements from a range, leaving only one element from each group of consecutive equivalent elements. This adaptor complements the existing std::unique algorithm by providing a composable, lazy, and allocation-free view for a common filtering operation.

The adaptor fills a notable gap in the Ranges library by enabling functional composition of uniqueness filtering with other range operations, consistent with the design philosophy of range adaptors.

Revision history

R0

Initial revision.

Motivation

The standard library provides std::unique algorithm for removing consecutive equivalent elements. However, like most standard algorithms, it requires in-place modification and returns an iterator to the new end of the range. This approach modifies the underlying range, which may not be desired, and requires eager evaluation that forces all elements to be processed. Furthermore, it cannot be easily composed with other range operations, and the returned range must be manually resized or sliced before use.

A range adaptor approach provides a fundamentally different experience, enabling lazy evaluation where elements are processed on-demand, non-destructive filtering that does not modify the underlying range, and seamless composition with other adaptors through the pipe operator. Consider the contrast:

  /* algorithm approach */
  std::vector<int> v = {1, 1, 2, 2, 2, 3, 1, 1};
  auto last = std::unique(v.begin(), v.end());
  v.erase(last, v.end());  // Modifies original range

  /* range adaptor approach */
  auto v = std::vector{1, 1, 2, 2, 2, 3, 1, 1};
  auto uniq = v | views::unique;  // Lazy, non-modifying, composable

Design

Forward range requirement

The unique_view requires its underlying range to be a forward_range. This requirement exists because comparing consecutive elements for equivalence requires maintaining a reference to the current element while advancing. An input range cannot support this since once we advance, we lose access to the previous element. Forward iterators also provide the multi-pass guarantee, allowing multiple iterations over the view.

Custom equivalence relation

The predicate must satisfy indirect_equivalence_relation<iterator_t<V>>, defaulting to ranges::equal_to. Callables like function pointers, lambda expressions, and member function pointers (via std::invoke) are supported, enabling filtering based on custom logic:

  auto v = std::vector{1, 1, 2, 2, 3, 3};
  auto uniq = v | views::unique;
  // uniq yields: 1, 2, 3

  auto words = std::vector{std::string{"cat"}, std::string{"CAT"}, std::string{"dog"}};
  auto case_insensitive = words | views::unique([](std::string_view a, std::string_view b) {
    return std::ranges::equal(a, b, [](char x, char y) {
      return std::tolower(static_cast<unsigned char>(x)) ==
             std::tolower(static_cast<unsigned char>(y));
    });
  });
  // case_insensitive yields: "cat", "dog"

  struct Record { int key; std::string name; };
  auto records = std::vector{Record{1, "a"}, Record{1, "b"}, Record{2, "c"}};
  auto by_key = records | views::unique([](auto&& a, auto&& b) {
    return a.key == b.key;
  });
  // by_key yields: {1, "a"}, {2, "c"}

Bidirectional support

Supporting operator-- is quite doable. The idea is simple: when we move backward, we do not just step one position and stop. We need to find the previous visible element in the view.

Noted that when we move backward, we need to land on the first element of that run, not on one of the skipped duplicates. That is the element that should be exposed as the previous visible value.

It should be noted that if views::unique supports operator--, combining it with an rvalue pipeline and backward traversal inevitably exposes the exact same semantic breakdown discussed in Filter View Extensions for Safer Use, as in the following example:

vector<string> coll{"hello", "hello"};
auto result = coll | views::unique
                   | views::reverse
                   | views::as_rvalue
                   | ranges::to<std::vector>();
println("{}", result); // Expected: ["hello"], Actual: ["hello", ""]

This inherent tension between a 'destructive move' and 'bidirectional traversal requiring repeated look-backs at element values' highlights a known design challenge in the Ranges.

Given that supporting operator-- still holds some practical value and intuitive utility in conventional lvalue scenarios (e.g., purely traversing std::vector<int> | unique | reverse), the author has chosen to retain support for bidirectional_range for the time being.

Nonetheless, whether this design should remain as-is or follow the direction of certain filtering views by strictly limiting views::unique to a forward_range demands explicit consideration and direction from LEWG.

Common range behavior

When the underlying range is a common_range, the end() function returns an iterator. Otherwise, it returns a sentinel that only stores the underlying sentinel:

  if constexpr (common_range<V>)
    return iterator<Const>(this, ranges::end(base_));
  else
    return sentinel<Const>(ranges::end(base_));

Const propagation

The adaptor provides separate const and non-const overloads of begin(), allowing users to obtain const or non-const iterators as appropriate.

Borrowed range support

In principle, unique_view could be borrowed_range if the underlying view V is a borrowed_range and the equivalence relation Pred is a stateless, "tiny" object (such as ranges::equal_to) that can be trivially reconstructed without increasing footprint.

However, the iterator of unique_view still needs to know where the boundary of the underlying sequence is to safely skip consecutive duplicates during increment operations. This implies that the iterators must store the end sentinel of the underlying range to have access to it.

At present, the author has no immediate intention to pursue borrowed_range specialization for unique_view. Nevertheless, the author remain neutral on this matter and welcome further feedback from the committee if such support is deemed desirable.

Implementation experience

The implementation of views::unique has been tested and validated using the Compiler Explorer.

Wording

This wording is relative to N5046.

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

    #define __cpp_lib_ranges_unique 2026XXL // freestanding, also in <ranges>
  • Modify 25.2 [ranges.syn], Header <ranges> synopsis, as indicated:

    namespace std::ranges {
      // [range.unique], unique view
      template<forward_range V,
               indirect_equivalence_relation<iterator_t<V>> Pred = ranges::equal_to>
        requires view<V> && is_object_v<Pred>
      class unique_view;
    
      namespace views { inline constexpr unspecified unique = unspecified; }
    }
  • Add 26.7.? Unique view [range.unique] after 25.7.35 [range.as.input] as indicated:

    [26.7.?.1] Overview [range.unique.overview]

    -1- unique_view presents a view of an underlying sequence with consecutive equivalent elements removed, where equivalence is determined by a predicate.

    -2- The name views::unique denotes a range adaptor object (25.7.2 [range.adaptor.object]). Given subexpressions E and F, the expression views::unique(E) and views::unique(E, F) is expression-equivalent to unique_view(E) and unique_view(E, F), respectively.

    -3- [Example 1:

    vector v = {1, 1, 2, 2, 2, 3, 1, 1};
    auto uniq = v | views::unique;
    for (int i : uniq)
      cout << i <<; ' ';   // prints 1 2 3 1
    end example]

    [26.7.?.2] Class template unique_view [range.unique.view]

    namespace std::ranges {
      template<forward_range V,
               indirect_equivalence_relation<iterator_t<V>> Pred = ranges::equal_to>
        requires view<V> && is_object_v<Pred>
      class unique_view : public view_interface<unique_view<V, Pred>> {
        V base_ = V();                      // exposition only
        movable-box<Pred> pred_;            // exposition only
    
        // [range.unique.iterator], class template unique_view::iterator
        template<bool Const>
        class iterator;                     // exposition only
    
        // [range.unique.sentinel], class template unique_view::sentinel
        template<bool Const>
        class sentinel;                     // exposition only
    
      public:
        unique_view() requires default_initializable<V> && default_initializable<Pred> = default;
        constexpr explicit unique_view(V base, Pred pred = {});
    
        constexpr V base() const& requires copy_constructible<V> { return base_; }
        constexpr V base() && { return std::move(base_); }
    
        constexpr const Pred& pred() const;
    
        constexpr iterator<false> begin() { return iterator<false>(this, ranges::begin(base_)); }
        constexpr iterator<true> begin() const
          requires forward_range<const V> &&
                   indirect_equivalence_relation<const Pred, iterator_t<const V>> {
          return iterator<true>(this, ranges::begin(base_));
        }
    
        constexpr auto end() { 
          if constexpr (common_range<V>)
            return iterator<false>(this, ranges::end(base_));
          else
            return sentinel<false>(ranges::end(base_)); 
        }
    
        constexpr auto end() const
          requires forward_range<const V> &&
                   indirect_equivalence_relation<const Pred, iterator_t<const V>> {
          if constexpr (common_range<const V>)
            return iterator<true>(this, ranges::end(base_));
          else
            return sentinel<true>(ranges::end(base_));
        }
      };
    
      template<class R, class Pred>
      unique_view(R&&, Pred) -> unique_view<views::all_t<R>, Pred>;
    }
    constexpr explicit unique_view(V base, Pred pred = {});
    -1- Effects: Initializes base_ with std::move(base) and pred_ with std::move(pred).
    constexpr const Pred& pred() const;

    -2- Preconditions: pred_.has_value() is true.

    -3- Effects: Equivalent to: return *pred_;

    [26.7.?.3] Class template unique_view::iterator [range.unique.iterator]

    namespace std::ranges {
      template<forward_range V,
               indirect_equivalence_relation<iterator_t<V>> Pred = ranges::equal_to>
        requires view<V> && is_object_v<Pred>
      template<bool Const>
      class unique_view<V, Pred>::iterator {
        using Parent = maybe-const<Const, unique_view>;                  // exposition only
        using Base = maybe-const<Const, V>;                              // exposition only
        Parent* parent_ = nullptr;                                       // exposition only
        iterator_t<Base> current_ = iterator_t<Base>();                  // exposition only
        constexpr iterator(Parent* parent, iterator_t<Base> current);    // exposition only
    
      public:
        using iterator_concept = 
          conditional_t<bidirectional_range<Base>, bidirectional_iterator_tag, forward_iterator_tag>;
        using iterator_category = see below;
        using value_type = range_value_t<Base>;
        using difference_type = range_difference_t<Base>;
    
        iterator() = default;
        constexpr iterator(iterator<!Const> i)
          requires Const && convertible_to<iterator_t<V>, iterator_t<Base>>;
    
        constexpr iterator_t<Base> base() const;
    
        constexpr range_reference_t<Base> operator*() const;
        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_range<Base>;
        constexpr iterator operator--(int) requires bidirectional_range<Base> = default;
    
        friend constexpr bool operator==(const iterator& x, const iterator& y);
    
        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- The member typedef-name iterator::iterator_category is defined as follows:
    1. (1.1) — Let C denote the type iterator_traits<iterator_t<Base>>::iterator_category.

    2. (1.2) — If C models derived_from<bidirectional_iterator_tag>, then iterator_category denotes bidirectional_iterator_tag.

    3. (1.3) — Otherwise, if C models derived_from<forward_iterator_tag>, then iterator_category denotes forward_iterator_tag.

    4. (1.4) — Otherwise, iterator_category denotes input_iterator_tag.

    constexpr iterator(Parent* parent, iterator_t<Base> current);
    -2- Effects: Initializes parent_ with parent and current_ with current.
    constexpr iterator(iterator<!Const> i)
          requires Const && convertible_to<iterator_t<V>, iterator_t<Base>>;
    -3- Effects: Initializes parent_ with i.parent_ and current_ with std::move(i.current_).
    constexpr iterator_t<Base> base() const;
    -4- Effects: Equivalent to: return current_;
    constexpr range_reference_t<Base> operator*() const;
    -5- Effects: Equivalent to: return *current_;
    constexpr iterator_t<Base> operator->() const requires has-arrow<iterator_t<Base>>;
    -6- Effects: Equivalent to: return current_;
    constexpr iterator& operator++();
    -7- Effects: Equivalent to:
    auto&& deref = *current_;
    while (++current_ != ranges::end(parent_->base_))
      if (!std::invoke(*parent_->pred_, std::forward<decltype(deref)>(deref), *current_))
        break;
    return *this;
    constexpr iterator& operator--() requires bidirectional_range<Base>;
    -8- Effects: Equivalent to:
    auto&& deref = *--current_;
    while (current_ != std::ranges::begin(parent_->base_)) {
      auto prev = ranges::prev(current_);
      if (!std::invoke(*parent_->pred_, *prev, std::forward<decltype(deref)>(deref)))
        break;
      current_ = prev;
    }
    return *this;
    friend constexpr bool operator==(const iterator& x, const iterator& y);
    -9- Effects: Equivalent to: return x.current_ == y.current_;
    friend constexpr range_rvalue_reference_t<Base> iter_move(const iterator& i)
      noexcept(noexcept(ranges::iter_move(i.current_)));
    -10- 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>>;
    -11- Effects: Equivalent to ranges::iter_swap(x.current_, y.current_);

    [26.7.?.3] Class template unique_view::sentinel [range.unique.sentinel]

    namespace std::ranges {
      template<forward_range V,
               indirect_equivalence_relation<iterator_t<V>> Pred = ranges::equal_to>
        requires view<V> && is_object_v<Pred>
      template<bool Const>
      class unique_view<V, Pred>::sentinel {
        using Base = maybe-const<Const, V>;                   // exposition only
        sentinel_t<Base> end_ = sentinel_t<Base>();           // exposition only
        constexpr explicit sentinel(sentinel_t<Base> end);    // exposition only
    
      public:
        sentinel() = default;
        constexpr sentinel(sentinel<!Const> other)
          requires Const && convertible_to<sentinel_t<V>, sentinel_t<Base>>;
    
        constexpr sentinel_t<Base> base() const;
    
        template<bool OtherConst>
          requires sentinel_for<sentinel_t<Base>, iterator_t<maybe-const<OtherConst, V>>>
          friend constexpr bool operator==(const iterator<OtherConst>& x, const sentinel& y);
      };
    }
    constexpr explicit sentinel(sentinel_t<Base> end);
    -1- Effects: Initializes end_ with end.
    constexpr sentinel(sentinel<!Const> other)
    -2- Effects: Initializes end_ with std::move(other.end_).
    constexpr sentinel_t<Base> base() const;
    -3- Effects: Equivalent to: return end_;
    template<bool OtherConst>
      requires sentinel_for<sentinel_t<Base>, iterator_t<maybe-const<OtherConst, V>>>
      friend constexpr bool operator==(const iterator<OtherConst>& x, const sentinel& y);
    -4- Effects: Equivalent to: return x.current_ == y.end_;

    References