Extending Ranges and Views to Non-Iterable Container Adaptors
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
viewconcept. - 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
| Year | Feature | What it enabled |
|---|---|---|
| C++11 | Range-based for | Iterate any type with matching begin()/end() |
| C++17 | Generalized range-based for (P0184) | begin()/end() may differ in type → sentinels |
| C++20 | <ranges>, views, concepts, adaptors, | | Lazy composition of standard containers/views |
| C++23 | range_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
- Honesty about semantics. Iterating must visibly and unambiguously
consume the adaptor. The name
popwas chosen specifically so nobody mistakes this for a peek-only, repeatable view. - 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. - Correct concept membership. The result should satisfy exactly the
concepts its behavior supports:
input_rangeandview, and notforward_range, because aforward_rangeis required to let you traverse the same elements more than once, which is structurally impossible here. - Interoperability. It must compose with existing standard range
adaptors (
views::take,views::filter, ranges-based algorithms, etc.) without special-casing. - 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. - 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 instantpop()runs — so the iterator must copy out the value before it can be safely incremented.operator++()callsA::pop()and then checksempty(); once empty, the iterator's internal pointer is set tonullptr, which is also the "end" state.- Comparison against the end is against
std::default_sentinel_t— a stateless tag from<iterator>— rather than anotherpop_iterator. This is the sentinel model that C++17's relaxation of range-basedformade legal, and which C++20 ranges formalize via thesentinel_forconcept. operator++(int)(postfix) returnsvoid. 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 asstd::span).pop_view(A&&)— take ownership of a temporary. The adaptor is moved into astd::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 aunique_ptrindirection 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
| Property | Value | Why |
|---|---|---|
| Concept satisfied | input_range, view | Single-pass, destructive traversal |
| Concept explicitly not satisfied | forward_range, common_range | Re-traversal is impossible; begin()/end() types differ |
| Dereference | by value (value_type) | top()/front() reference dangles after pop() |
Side effect of ++it | one 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 container | begin() == end() immediately | No special-casing needed at call sites |
| Composability | Works 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++20and-std=c++23, both with-Wall -Wextra, zero warnings.-fsanitize=address,undefinedfor the full test run — no ASan/UBSan reports.- Coverage:
stack(LIFO order),queue(FIFO order),priority_queue(descending heap order), pipe syntax, composition withviews::take(verifying partial-drain leaves the remainder untouched), ownership of an rvalue-returned adaptor,std::ranges::fold_leftover the view, an empty container producingbegin() == end(), andstatic_asserts pinning down the exact concept membership (input_iteratoryes,input_rangeyes,viewyes,forward_rangeno). - A second, header-only smoke test confirmed the library compiles and runs
correctly under plain
-std=c++20with no-std=c++23dependency.
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/queuefrom a range, not extracting a range back out of one. - A similar design tension shows up outside C++: a
dart-lang/collectionGitHub 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 operationpoprather 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 likepop_rangeshows 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-copyvalue_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 ifAexposes a method to move the top element out before popping, butstd::stack,std::queue, andstd::priority_queuedon't offer one, and the exercise intentionally targeted the standard adaptors as-is.- No
size()/sized_range. Deliberately omitted: since consuming elements changessize()while iterating, exposing it risks implying a stronger guarantee (likesized_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
cmember forstack/queuevia 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).
