A tale of three functions
The year is 2005. As part of a parser utility library at your job, you write the following function to read a known-size list of formatted values from a stream into a vector:
template <typename T>
static std::vector<T> read_vector(std::istream& is, std::size_t size)
{
std::vector<T> out;
T val;
while (size-- && is >> val) {
out.push_back(val);
}
return out;
}
You also add a few unit tests as an afterthought:
TEST_CASE("Read whole input", "[read_vector]") {
std::istringstream input{"26 42 73 0 1312"};
REQUIRE(read_vector<int>(input, 5) == std::vector<int>{26, 42, 73, 0, 1312});
}
TEST_CASE("Read partial input", "[read_vector]") {
std::istringstream input{"26 42 73"};
REQUIRE(read_vector<int>(input, 2) == std::vector<int>{26, 42});
}
TEST_CASE("Read nothing", "[read_vector]") {
std::istringstream input{"26"};
REQUIRE(read_vector<int>(input, 0).empty());
}
TEST_CASE("Read two vectors", "[read_vector]") {
std::istringstream input{"26 42 73 0 1312"};
REQUIRE(read_vector<int>(input, 2) == std::vector<int>{26, 42});
REQUIRE(read_vector<int>(input, 3) == std::vector<int>{73, 0, 1312});
}
You call it an early day and hurry home to get in costume before the Revenge of the Sith opening night. Last chance for these prequels to get good.
The year is 2015. After your team adopts C++11, one of your coworkers watches Sean Parent’s exhortation to exorcise raw loops from your codebase and eagerly rewrites the function:
template <typename T>
static auto read_vector(std::istream& is, std::size_t size) -> std::vector<T>
{
std::vector<T> out;
std::copy_n(std::istream_iterator<T>(is), size, std::back_inserter(out));
return out;
}
Clean! Modern! Passing all unit tests! A flawless success. That is, until a downstream
application starts crashing in production after receiving some malformed input. You reproduce the
issue locally and quickly find the culprit: copy_n() will blindly read past the end of the
stream, summoning the nasal demons of undefined behaviour. You revert the change and add a new unit
test to cover this scenario, wondering what idiot forgot such an obvious test case but also
resenting your colleague who couldn’t leave a perfectly good function alone. You add this to your
growing “list of reasons to quit” file and move on to the next ticket.
The year is 2025. You get bitten by the ranges bug. You can now write the definitive version of this function in full C++23 glory:
template <typename T>
static auto read_vector(std::istream& is, std::size_t size) -> std::vector<T>
{
return ranges::to<std::vector>(views::istream<T>(is) | views::take(size));
}
Isn’t it beautiful? With take_view you even avoid reading past the end of the input, unlike some
of your careless colleagues who shall remain unnamed. Days like this you can almost ignore that
niggling feeling that you should be learning Rust instead. You run your unit tests before pushing
the change and…
-----------------------------------------------------------------
Read two vectors
-----------------------------------------------------------------
test_parsing.cpp:50
.................................................................
test_parsing.cpp:53: FAILED:
REQUIRE( read_vector<int>(input, 3) == std::vector<int>{73, 0, 1312} )
with expansion:
{ 0, 1312 (0x520) }
==
{ 73, 0, 1312 (0x520) }
Wait, what? It looks like the first element of the second vector got discarded entirely. The code couldn’t be more self-explanatory; what’s going on here?
You have unwittingly tripped over a somewhat obscure but long-standing hazard of the standard
library. Rooting out its origin will take us on a tour of C++ philosophy, the standardisation
process, and implementation compromises, but first let’s get to the bottom of this behaviour. You
may already suspect that our first call to read_vector() somehow consumed one too many values from
the input. A quick peek at the istream_view
specification tells us it reads from the underlying stream
in the begin() method and whenever its iterator is incremented; counted_iterator, used by
take_view, increments its base
iterator whenever it itself is incremented. When we reach the end of this counted range, then, the
istream_view holds the next value to process, if any, which is discarded once the function returns
and the view is destroyed.
Who’s at fault here: the specification or our own expectations? And how can we fix our code?
What’s in a range?
We’ll start by considering counted_iterator. Should it skip incrementing its base iterator when
the count reaches zero?
This was the idea behind
P2406, originally proposed
in 2021 to change the specification of counted_iterator to address, among others, precisely the
scenario where istream_view is chained with take_view. It appears to have been dropped in 2023
after Tim Song wrote
a counter-proposal
defending the current behaviour as the more idiomatic one. The crux of his argument was that C++
ranges are, and have always been, half-open; changing counted_iterator to support closed ranges
would be unintuitive and cause more problems than it would solve. He also wrote of our motivating
example:
Here, the actual problem is that
istream_viewexpects all access to the stream to happen through its iterators. Nothing is actually lost if its iterator is consistently used. In the example above, we can extract theistream_viewiterator out of the past-the-endcounted_iteratorusing the allegedly uselessbase()function:auto iss = std::istringstream("0 1 2"); auto tv = rn::istream_view<int>(iss) | rv::take(1); auto it = tv.begin(); for(; it != tv.end(); ++it) std::cout << *it << '\n'; auto next = std::move(it).base(); auto i = *next; assert(i == 1); // OK
I find this fix something of a kludge, directly manipulating the range iterators and cutting across abstraction layers. It also ignores other cases where, from a user’s perspective, reading more values than expected from the stream is objectively wrong or has bad performance:
auto input = std::ifstream{path_to_pipe};
const auto batch = read_vector(input, 5);
// hangs until it can read *six* values (or fails) or the pipe closes
Using istream_view consistently (as opposed to carrying the iterator around like it’s 1998) would,
alas, also not save us from dropping values:
template <typename T>
static auto
read_vector(ranges::istream_view<T>& rg, std::size_t size) -> std::vector<T>
{
return ranges::to<std::vector>(ranges::ref_view{rg} | views::take(size));
}
auto rg = views::istream(input);
const auto first = read_vector(rg, 2);
const auto second = read_vector(rg, 3);
The reason this doesn’t work can be understood from our earlier explanation: since
istream_view::begin() consumes the next value in the stream, iterating the range again discards
the value currently stored in the view just as if we had constructed a new one. Time to take
a closer look at istream_view.
Leaky standardisation
The addition of istream_view to the standard library was originally proposed in 2018, back in the
heady days of the Ranges TS, with P1035. The initial
revision made it clear that
the proposed istream_range adaptor was an abstraction over the venerable istream_iterator for
a more civilised era, and as such it mimicked its behaviour closely: the initial value was consumed
from the stream in the adaptor constructor1, as well as on each iterator increment. This was
also the behaviour of the equivalent types in Boost.Range and Eric Niebler’s range-v3. In fact, as
early as 2013 Eric explained
how this approach avoided otherwise surprising user-visible consequences, much as our last example
illustrates2.
By the time
P1035R1
rolled around a few months later, however, and despite the addition of a shout-out to range-v3,
istream_view was irrevocably altered: the first value was now consumed by begin(), rather than
the constructor, rendering our potential solution null and void. It’s unclear where this change
came from; the fact that neither range-v3 nor Boost.Range were ever modified to match it seems to
indicate it was not widely discussed. This
issue also shows that Christopher di Bella, the
original author of P1035, was aware of the range-v3 behaviour. The intention may have been to
postpone access to the stream until the view was iterated so the constructor would not block
execution, for cases where the two are done in different parts of the program. Whatever the reason,
we are now unfortunately saddled with a stateful range adaptor which cannot easily be
reused.3
The std::copy_n() exception
But wait—what about the overly-optimistic implementation of read_vector() we had to revert, which
used istream_iterator:
std::copy_n(std::istream_iterator<T>{is}, size, std::back_inserter(out));
Sure, it failed when there were fewer values than expected, but it did pass our test case with sequential calls despite the fact that the value consumed by the last increment should’ve been discarded. What gives?
It turns out people have, perhaps not surprisingly, been running into (and getting frustrated by)
this scenario ever since copy_n() was introduced in C++11. The original libc++ code, the trivial
one-liner you might expect, was
changed in
2011 to accommodate copies from istream_iterator by skipping the final increment; libstdc++
followed not
long after, apparently without debate.
Because the standard specifies the exact number of assignments performed by copy_n() but not the
number of iterator increments, this is fair game for the implementation.
When the issue was formally raised a few years later,
LEWG appears to have thrown up its hands in disgust. It’s hard to blame the library authors though:
because copy_n() returns the next output iterator but not the input one, there is no good way to
continue from where it left off. ranges::copy_n(), on the other hand, does return the final value
of the input iterator, and both libc++ and libstdc++ increment it N times.
Trapdoor iterators
Here’s another situation where we run into our original problem in a different guise. Say we have a non-blocking concurrent queue, perhaps something like:
template <typename T>
class concurrent_queue
{
public:
void put(T&&);
std::optional<T> get();
// ...
};
We can design an adaptor over this container similar to istream_view: store the last consumed
value in the adaptor, use a sentinel for the end of the range, and read the first value in the
constructor4. If we’re using this queue to distribute work over multiple threads, for
example, and each thread processes items in batches, we might naturally write (calling our adaptor
concurrent_view):
template <typename T>
void worker(concurrent_queue<T>& queue)
{
auto items = concurrent_view{queue};
while (true) {
ranges::for_each(items | views::take(batch_size), process_item);
// ...
}
}
There is a subtle issue here though. After processing a batch of items, the thread will presumably
go off to do something else. Meanwhile, any item it might’ve pulled from the queue in the last
for_each increment will languish inside the concurrent_view, unavailable to any other worker, to
be processed when the event loop restarts. Not great for our latency tails! And that’s if the
thread doesn’t terminate in the meantime, at which point the item would simply be lost in the ether.
But maybe you don’t particularly care about this; after all, developers writing low-latency code are already used to some loss of ergonomics in pursuit of performance. Can we just agree that reusing the adaptor and reading the initial value in its constructor would solve all of our problems?
Not so fast. Let’s look at a different scenario involving our modified istream_view with a no-op
begin(), where we repeatedly read lists of integers of bounded size followed by an optional
boolean flag (this example is somewhat contorted, but it demonstrates the more general point):
auto values = views::istream<int>(stream);
stream.setf(std::ios_base::boolalpha); // don't parse integers as booleans
while (const auto vs = ranges::to<std::vector>(values | views::take(max_size));
!vs.empty()) {
bool flag = false;
if (!(stream >> flag)) stream.clear();
// ...
}
Does this work as expected? No integers are discarded, and we are careful to handle the case where
the flag is absent by clearing the stream’s failbit before continuing. However, the curse of the
final increment strikes again: if the flag is present, the last read from the counted range will
leave the stream in failed state (since it expects an integer) and operator>> will refuse to read
from it. What’s worse, because we only read from the stream when incrementing the iterator, we’ll
also process the wrong values in the next loop even if we clear the stream state.
The shared characteristic of istream_view and concurrent_view is that advancing the range
iterator requires reading from the underlying sequence5, and reading it mutates its state.
This is a common property of weakly-incrementable types, particularly input iterators, which are
suitable only for single-pass algorithms because their increment operators are not necessarily
equality-preserving. When combined with any adaptor that might “truncate” the range rather than
iterating it through its end (such as take_view, take_while_view, or zip), we always risk
running into a situation of the sort we have described.
A new hope
Let’s return to Tim Song’s
critique of the proposal
for lazy_counted_iterator and associated range adaptors. Although I have some quibbles with his
overall argument, the idea of a generic close_view adaptor from closed to half-open ranges is
promising. For example, some ranges could easily provide an ending() iterator method relying on
internal details—such as the current count of a take_view or the value of a iota_view—to know
whether the next increment might be past the end, and the adaptor iterator would only forward
increments if this method returned false. Our code might then look something like:
for (auto value : views::istream<int>(input) | views::take(5) | views::close) {
// ...
}
This would elegantly solve our original use case! It would even satisfyingly resolve our
constructor-or-begin() conundrum to read the first value from an input range: whether the adaptor
is reused or not, the proper place to initialise the value would now be in the begin() method.
Unfortunately, this is still not a general solution for input ranges; in fact, such a solution cannot exist. To see why, consider the following example that zips together multiple ranges and an index:
static auto enumerate_up_to(std::size_t n, auto&&... rgs)
{
return views::zip(views::iota(0, n), rgs...) | views::close;
}
for (auto [i, x, s] : enumerate_up_to(10,
views::istream<int>(a),
views::istream<std::string>(b))) {
// ...
}
How many values will we read from each stream, if the zip_view iterator is endowed with
a brand-new ending() method capable of telling close_view when the iota_view range is about to
run past its end? The answer, of course, is that it depends: if we have fewer than ten values in
one of the streams, the closed-range adaptor won’t be able to prevent incrementing the iterators for
all ranges, reading an extra value from the longer stream and leaving us right back where we
started.6 There is no way to work around this, since it’s the same fundamental limitation
of input ranges as we discussed in the previous section.
Embracing state
Let’s take stock of the situation. Our original problem was that, when reading from an input stream
multiple times through a range, we lost the last value implicitly pulled from the stream, either
because we destroyed the view or, if we reused it, because its begin() method immediately reads
the next value. We also saw that even with a reusable view that consumes the stream in its
constructor we can run into subtle performance and correctness issues, in addition to potentially
blocking execution unexpectedly. Finally, it’s now clear that we can never fully avoid reading past
the end of a truncated (counted or otherwise) input range in a general scenario.
If we are willing to let go of the requirement to skip the last increment, focusing only on
preventing discarded values with reasonable semantics, we can get the best of both remaining worlds:
a stateful range adaptor that keeps track of the last iterator value we’ve seen, initialised in
begin() if one doesn’t exist, updated on increment, and cleared when past the end of the adapted
range. This makes begin() idempotent between increments but capable of handling transient errors
in underlying streams, while ensuring the user always sees every retrieved value and program
execution does not block on construction. Here’s a bare-bones implementation:
template <ranges::view V> requires ranges::input_range<V>
class input_cache_view : public ranges::view_interface<input_cache_view<V>>
{
V base_;
std::optional<ranges::iterator_t<V>> cur_{};
public:
class iterator
{
input_cache_view<V>* parent_;
public:
using difference_type = std::ptrdiff_t;
using value_type = ranges::range_value_t<V>;
constexpr explicit iterator(input_cache_view<V>& parent)
: parent_{std::addressof(parent)} {}
iterator& operator++()
{
if (++*parent_->cur_ == parent_->base_.end()) parent_->cur_.reset();
return *this;
}
void operator++(int) { ++*this; }
value_type& operator*() const { return **parent_->cur_; }
friend bool operator==(const iterator& it, std::default_sentinel_t)
{ return !it.parent_->cur_; }
};
constexpr explicit input_cache_view(V view) : base_{view} {}
constexpr auto begin()
{ if (!cur_) cur_ = base_.begin(); return iterator{*this}; }
constexpr std::default_sentinel_t end() const noexcept
{ return std::default_sentinel; }
};
And here’s what our earlier parsing example would look like with this new adaptor:
auto values = views::istream<int>(stream) | input_cache;
stream.setf(std::ios_base::boolalpha); // don't parse integers as booleans
while (const auto vs = ranges::to<std::vector>(values | views::take(max_size));
!vs.empty()) {
stream.clear();
bool flag = false;
if (!(stream >> flag)) stream.clear();
// ...
}
Notice that we still need to clear failbit before reading the flag, but unlike last time the
adaptor happily chugs along afterwards. The extra check on increment is essentially free since it
gets rolled up with the loop condition. And unlike
lazy_take_view, this solution can also handle adaptors like take_while_view:
auto values = views::istream<int>(stream) | input_cache;
for (auto small : values | views::take_while([](auto x) { return x < 100; })) {
// handle initial small values
}
for (auto large : values) {
// handle the rest
}
Conclusion
I like the idea of a close_view, despite its shortcomings, but I have some doubts about whether
such a proposal (in whatever form) would ever be accepted by LEWG, given the somewhat niche purpose
and potentially complex edge cases. Then again, what is C++ for if not to come up with ever more
baroque and destructive footguns?
In our current world, however, and with input ranges in general, we must pick our poison. If
avoiding that last increment is critical to you, you can write your own lazy_take_view;
otherwise, if you just want less error-prone semantics, you can wrap your input range in our input
cache adaptor or, if that doesn’t
work for whatever reason, maybe a very basic eager constructor
adaptor. You can also use functions
like ranges::copy which return the next input iterator, at the cost of having to manually build
sub-ranges for subsequent operations.
Or, of course, you can swallow your pride, accept that ranges might not be the best hammer for every nail, and welcome back the humble while loop.