| Document number | P4291R0 |
| Date | 2026-07-01 |
| Audience | LEWG, SG9 (Ranges) |
| Reply-to | Hewill Kang <hewillk@gmail.com> |
views::unique
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.
Initial revision.
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
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.
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"}
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.
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_));
The adaptor provides separate const and non-const overloads of begin(),
allowing users to obtain const or non-const iterators as appropriate.
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.
The implementation of views::unique has been tested and validated using the
Compiler Explorer.
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:
— end example]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
[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: Initializesbase_withstd::move(base)andpred_withstd::move(pred).
constexpr const Pred& pred() const;
-2- Preconditions:
pred_.has_value()istrue.-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_categoryis defined as follows:(1.1) — Let
Cdenote the typeiterator_traits<iterator_t<Base>>::iterator_category.(1.2) — If
Cmodelsderived_from<bidirectional_iterator_tag>, theniterator_categorydenotesbidirectional_iterator_tag.(1.3) — Otherwise, if
Cmodelsderived_from<forward_iterator_tag>, theniterator_categorydenotesforward_iterator_tag.(1.4) — Otherwise,
iterator_categorydenotesinput_iterator_tag.constexpr iterator(Parent* parent, iterator_t<Base> current);-2- Effects: Initializesparent_withparentandcurrent_withcurrent.constexpr iterator(iterator<!Const> i) requires Const && convertible_to<iterator_t<V>, iterator_t<Base>>;-3- Effects: Initializesparent_withi.parent_andcurrent_withstd::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 toranges::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: Initializesend_withend.constexpr sentinel(sentinel<!Const> other)-2- Effects: Initializesend_withstd::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
- [P2760R1] Barry Revzin. A Plan for C++26 Ranges. URL: https://www.open-std.org/jtc1/sc22/wg21/docs/papers/2023/p2760r1.html
- [range/v3] Eric Niebler. Range library v3. URL: https://github.com/ericniebler/range-v3