Skip to main content

Extending Ranges and Views to Non-Iterable Container Adaptors

· 14 min read
Austin Kim
Staff Software Engineer, System programming and media framework specialist

1. Motivation

std::stack, std::queue, and std::priority_queue are container adaptors: thin wrappers around an underlying sequence container (vector, deque, list) that expose a deliberately restricted interface — push, pop, top/front, empty, size. None of them expose begin()/end(). This is not an oversight; it is a design decision inherited from the original SGI STL, whose documentation states outright that priority_queue "does not allow iteration through its elements." The rationale is that the adaptor's whole contract is "you may only ever see the current top/front element," and exposing arbitrary iteration would leak the underlying container's storage order (vector's heap layout, in the case of priority_queue), which is not part of the abstraction.

The consequence is that these three types cannot participate in the modern iteration and composition idioms C++ has accumulated since C++11 — range-based for, and since C++20, the <ranges> view/pipe machinery — because both require some notion of begin()/end(). This document describes pop_range, a small header-only library that closes that gap by giving these adaptors a well-defined, honestly-named destructive view: iterating it doesn't just read the adaptor, it drains it, one pop() per increment.

2. A Short History of Iteration Idioms in C++

It's worth being precise about which feature arrived when, since the two headline features referenced in this document — range-based for and the pipe operator — are often conflated with the wrong standard versions.

2.1 C++11 — the range-based for loop

Range-based for (for (auto& x : container) ...) was introduced in C++11 (originally proposed as N2930). It desugars to roughly:

{
auto&& __range = range_expression;
for (auto __it = begin(__range), __end = end(__range); __it != __end; ++__it) {
auto&& x = *__it;
loop_statement
}
}

The requirement, as originally specified, was that begin(__range) and end(__range) produce values of the same type, because the loop compares them directly with operator!=. This is a strict requirement: the "end" has to be an actual iterator of the same type as "begin," not merely something comparable to it.

2.2 C++17 — generalizing range-based for (sentinels)

C++17 didn't introduce range-based for — it generalized it, via P0184R0, "Generalizing the Range-Based For Loop". The requirement that begin() and end() return the same type was relaxed to only require that they be comparable with !=. This is what allows an end() to be a lightweight sentinel — a distinct type carrying no state, or carrying just enough state to answer "are we done?" — instead of a full iterator.

This sounds like a small wording change, but it's the piece of infrastructure that later made the C++20 ranges model possible: a sentinel_for<S, I> no longer has to be an I. It's also directly used in this project's own iterator: pop_iterator's "end" is std::default_sentinel_t, a stateless tag type, compared against the iterator via a friend operator==. Without the C++17 relaxation, we'd have had to give the sentinel the same type as the iterator (e.g., by giving pop_iterator a null/"end" state and constructing two of them), which is exactly the kind of awkwardness sentinels exist to avoid.

2.3 C++20 — <ranges>, views, concepts, and the pipe operator

C++20 (via P0896R4, "The One Ranges Proposal") introduced the <ranges> library proper:

  • A hierarchy of range/iterator concepts (range, view, input_iterator, forward_range, sized_range, common_range, ...) that formally describe what guarantees a given range provides, rather than just "it has begin/end."
  • Views: lightweight, non-owning (or cheaply-movable), lazily-evaluated ranges, distinguished from containers by the view concept.
  • Range adaptors (views::filter, views::transform, views::take, ...) and the pipe operator (|) for composing them: data | views::filter(p) | views::transform(f).

The pipe operator is not magic — it's operator| overloaded for a range on the left and an adaptor closure object on the right, which simply calls the adaptor with the range as its argument. Built-in adaptors like views::filter have supported | since C++20. What C++20 did not provide was a public, standard base class that lets user-defined adaptors opt into | for free — that arrived a version later.

2.4 C++23 — range_adaptor_closure and friends

C++23 added std::ranges::range_adaptor_closure<D>, a CRTP base class that gives a user's own closure type the | operator automatically, plus ranges::to<Container>() for materializing a view into a concrete container, views::zip, and a few other conveniences. This is directly relevant here: our own views::pop needed operator| hand-written, because targeting plain C++20 meant range_adaptor_closure wasn't available (confirmed by compiling against libstdc++ 13: the type doesn't exist under -std=c++20, only under -std=c++23).

2.5 Summary table

YearFeatureWhat it enabled
C++11Range-based forIterate any type with matching begin()/end()
C++17Generalized range-based for (P0184)begin()/end() may differ in type → sentinels
C++20<ranges>, views, concepts, adaptors, |Lazy composition of standard containers/views
C++23range_adaptor_closure, ranges::to, zip| support for user-defined adaptors, for free

The common thread: every one of these features assumes the underlying type already exposes some notion of iteration. stack, queue, and priority_queue are conspicuous holdouts — by design, not oversight — which is the gap pop_range targets.

3. Design Goals

  1. Honesty about semantics. Iterating must visibly and unambiguously consume the adaptor. The name pop was chosen specifically so nobody mistakes this for a peek-only, repeatable view.
  2. Minimal surface, maximal reuse. One adaptor should work for stack, queue, priority_queue, and any user type with the same shape — via a concept, not three copy-pasted specializations.
  3. Correct concept membership. The result should satisfy exactly the concepts its behavior supports: input_range and view, and not forward_range, because a forward_range is required to let you traverse the same elements more than once, which is structurally impossible here.
  4. Interoperability. It must compose with existing standard range adaptors (views::take, views::filter, ranges-based algorithms, etc.) without special-casing.
  5. Two ownership modes. Adapting an existing, named container adaptor in place (pop(my_stack)), and adapting a temporary that the view itself should own (pop(make_stack())), so the pattern is convenient at a call site as well as reusable on an existing variable.
  6. Plain C++20 portability, with graceful use of C++23 facilities where available, rather than a hard C++23 requirement.

4. Implementation

4.1 The adaptor concept

template <typename A>
concept poppable_adaptor =
requires(A& a) {
typename A::value_type;
{ a.empty() } -> std::convertible_to<bool>;
a.pop();
} && (requires(A& a) { a.top(); } || requires(A& a) { a.front(); });

This is the entire "interface requirement." It's deliberately structural (duck-typed via requires), not a stack/queue/priority_queue allow-list, so any type shaped like a container adaptor — including user-defined ones — is automatically supported. A small helper dispatches to whichever of top()/front() the type actually has:

template <poppable_adaptor A>
decltype(auto) peek(A& a) {
if constexpr (requires { a.top(); })
return a.top();
else
return a.front();
}

4.2 The iterator: single-pass, destructive, forward-only-in-motion

pop_iterator<A> models std::input_iterator — the weakest of the standard iterator concepts, and the only honest choice here. Key points:

  • operator*() returns a copy of the element (value_type, not a reference). top()/front() return a reference into the adaptor's storage, which becomes dangling the instant pop() runs — so the iterator must copy out the value before it can be safely incremented.
  • operator++() calls A::pop() and then checks empty(); once empty, the iterator's internal pointer is set to nullptr, which is also the "end" state.
  • Comparison against the end is against std::default_sentinel_t — a stateless tag from <iterator> — rather than another pop_iterator. This is the sentinel model that C++17's relaxation of range-based for made legal, and which C++20 ranges formalize via the sentinel_for concept.
  • operator++(int) (postfix) returns void. This looks unusual coming from classic STL iterators, but it's conformant: std::weakly_incrementable (which every iterator concept builds on) only requires postfix ++ to be a valid expression, not that it return anything in particular — a carve-out that exists precisely to accommodate input iterators like this one, which have nothing meaningful to return a copy of after the value has been consumed.
template <poppable_adaptor A>
class pop_iterator {
public:
using value_type = typename A::value_type;
using difference_type = std::ptrdiff_t;
using iterator_concept = std::input_iterator_tag;

pop_iterator() = default;
explicit pop_iterator(A* adaptor) noexcept : adaptor_(adaptor) {
if (adaptor_ && adaptor_->empty()) adaptor_ = nullptr;
}

value_type operator*() const { return peek(*adaptor_); }

pop_iterator& operator++() {
adaptor_->pop();
if (adaptor_->empty()) adaptor_ = nullptr;
return *this;
}
void operator++(int) { ++(*this); }

friend bool operator==(const pop_iterator& it, std::default_sentinel_t) noexcept {
return it.adaptor_ == nullptr;
}

private:
A* adaptor_ = nullptr;
};

4.3 The view: reference or ownership, stable across moves

pop_view<A> derives from std::ranges::view_interface<pop_view<A>> (for the usual free functions like empty()/front() that view_interface derives from begin()/end()), and supports two constructors:

  • pop_view(A&) — adapt an existing, named adaptor. The caller keeps ownership and must keep it alive for as long as the view/iterators are used (an ordinary borrowing convention, same as std::span).
  • pop_view(A&&) — take ownership of a temporary. The adaptor is moved into a std::unique_ptr<A>, specifically so that its address stays stable even if the view itself is later moved — iterators hold a raw pointer to the adaptor, not to the view, so a unique_ptr indirection is what prevents those iterators from dangling across a view move.

The view is move-only. It deliberately has no copy constructor: copying it would either have to deep-copy the entire underlying container (surprising and expensive for something that looks like a lightweight view) or alias the same adaptor from two independent views that would then race to pop() it. Move-only is the only model that doesn't quietly misbehave.

template <poppable_adaptor A>
class pop_view : public std::ranges::view_interface<pop_view<A>> {
public:
pop_view() = default;
explicit pop_view(A& adaptor) noexcept : ptr_(&adaptor) {}
explicit pop_view(A&& adaptor)
: owned_(std::make_unique<A>(std::move(adaptor))), ptr_(owned_.get()) {}

pop_view(pop_view&&) = default;
pop_view& operator=(pop_view&&) = default;
pop_view(const pop_view&) = delete;
pop_view& operator=(const pop_view&) = delete;

pop_iterator<A> begin() const { return pop_iterator<A>(ptr_); }
std::default_sentinel_t end() const noexcept { return std::default_sentinel; }

[[nodiscard]] bool empty() const noexcept { return ptr_ == nullptr || ptr_->empty(); }

private:
std::unique_ptr<A> owned_;
A* ptr_ = nullptr;
};

pop_view is explicitly opted into enable_view:

template <typename A>
inline constexpr bool std::ranges::enable_view<pop_range::pop_view<A>> = true;

and is not a forward_range — this falls out naturally, since pop_iterator only satisfies input_iterator, and forward_range requires forward_iterator, which in turn requires that copies of the iterator be independently incrementable and still compare equal to each other where appropriate — a guarantee that's fundamentally incompatible with "advancing consumes the shared, singular underlying container."

4.4 views::pop: call syntax and pipe syntax, C++20-portable

struct pop_fn {
template <poppable_adaptor A>
[[nodiscard]] auto operator()(A& adaptor) const { return pop_view<A>(adaptor); }

template <poppable_adaptor A>
[[nodiscard]] auto operator()(A&& adaptor) const { return pop_view<A>(std::move(adaptor)); }
};
inline constexpr pop_fn pop{};

template <poppable_adaptor A>
[[nodiscard]] auto operator|(A& adaptor, pop_fn) { return pop_view<A>(adaptor); }

template <poppable_adaptor A>
[[nodiscard]] auto operator|(A&& adaptor, pop_fn) { return pop_view<A>(std::move(adaptor)); }

As noted in §2.4, std::ranges::range_adaptor_closure would give | "for free," but it's a C++23 addition; verified directly by compiling a minimal check against libstdc++ 13 under both standards:

$ g++ -std=c++20 -fsyntax-only check.cpp # error: 'range_adaptor_closure' does not name a type
$ g++ -std=c++23 -fsyntax-only check.cpp # compiles

Hand-writing operator| sidesteps that dependency entirely, so the library works unmodified under -std=c++20.

5. Semantics and Guarantees

PropertyValueWhy
Concept satisfiedinput_range, viewSingle-pass, destructive traversal
Concept explicitly not satisfiedforward_range, common_rangeRe-traversal is impossible; begin()/end() types differ
Dereferenceby value (value_type)top()/front() reference dangles after pop()
Side effect of ++itone A::pop()The defining behavior of the library
Copyable?View: no. Iterator: yes (cheap, just a raw pointer).Copying the view would duplicate or alias the container
Empty containerbegin() == end() immediatelyNo special-casing needed at call sites
ComposabilityWorks with any standard range adaptor (views::take, algorithms, etc.)Only requires input_range

A worked example of composability: pop(pq) | std::views::take(2) pops exactly two elements and stops — the other three elements are left completely untouched in the underlying priority_queue, confirmed by checking pq.size() afterward. This is only possible because views::take only asks its underlying range for input_range, which pop_view genuinely provides — nothing in the pipeline over-reads or over-pops beyond what was requested.

6. Testing

The header (pop_range.hpp) and a demo/test program (main.cpp) were compiled and run with g++ 13.3.0:

  • -std=c++20 and -std=c++23, both with -Wall -Wextra, zero warnings.
  • -fsanitize=address,undefined for the full test run — no ASan/UBSan reports.
  • Coverage: stack (LIFO order), queue (FIFO order), priority_queue (descending heap order), pipe syntax, composition with views::take (verifying partial-drain leaves the remainder untouched), ownership of an rvalue-returned adaptor, std::ranges::fold_left over the view, an empty container producing begin() == end(), and static_asserts pinning down the exact concept membership (input_iterator yes, input_range yes, view yes, forward_range no).
  • A second, header-only smoke test confirmed the library compiles and runs correctly under plain -std=c++20 with no -std=c++23 dependency.

All cases passed.

7. Prior Art

No published library implementing this exact pattern was found. The closest related material:

  • The C++ standard library's own documentation is explicit that priority_queue "does not allow iteration through its elements" — the restriction this project works around is by design, not a gap anyone considers a bug.
  • P1425, "Iterators pair constructors for stack and queue" addresses the opposite direction: constructing a stack/queue from a range, not extracting a range back out of one.
  • A similar design tension shows up outside C++: a dart-lang/collection GitHub issue debates adding non-destructive, linear-time iteration to a priority queue, with a maintainer explicitly weighing (and being wary of) a destructive-iteration API — the same tradeoff this project resolves by naming the operation pop rather than pretending it's a peek.
  • The general folk pattern in day-to-day C++ code is simply while (!pq.empty()) { use(pq.top()); pq.pop(); } written out by hand at each call site, rather than generalized into a reusable view — plausibly why nothing like pop_range shows up as an established library.

8. Limitations and Future Work

  • Not thread-safe. No synchronization is attempted; concurrent push/pop from other code while the view is being iterated is undefined behavior, same as it would be for the raw adaptor.
  • operator* copies. For expensive-to-copy value_types, this is an inherent cost of the design (the alternative — returning a reference — is unsound, as noted in §4.2). Move-extraction is possible if A exposes a method to move the top element out before popping, but std::stack, std::queue, and std::priority_queue don't offer one, and the exercise intentionally targeted the standard adaptors as-is.
  • No size()/sized_range. Deliberately omitted: since consuming elements changes size() while iterating, exposing it risks implying a stronger guarantee (like sized_range's expectation that size doesn't change under const, unmutated access) than is actually true.
  • Possible extension: an explicit non-owning "peek view" (read-only, repeatable, requires a container that actually supports iteration, e.g., by exposing the underlying c member for stack/queue via the protected-inheritance trick some codebases use) would be a legitimate, complementary addition — but it is a fundamentally different, non-destructive contract and was out of scope here.

Appendix A: Full Source

The complete, tested implementation is in pop_range.hpp, with a demonstration/test suite in main.cpp (both delivered alongside this document).