The Road to Flux 1.0
I’ve just posted a long update about my plans for Flux on the Github discussions page, but I figured it might be worth posting here as well for a wider audience.
If you’ve used Flux before – or you’re just curious about alternatives to std::ranges – then hopefully you’ll find it interesting.
Just to reiterate what it says below – I’m very keen to get feedback about the plans outlined here. If you have any comments or questions then please head on over to the Github page.
Without further ado…
Flux has been around for a couple of years now, and the feedback I get is mostly that people seem to think it’s pretty good. I think it’s pretty good too! But I’m also aware that the current design of the library has some shortcomings. Over the last few months I’ve spent a lot of time thinking about ways in which things can be improved, and experimenting with various alternative approaches.
Having settled on what I believe is a promising design for the library going forward, I wanted to give an update on the planned changes as we move towards Flux 1.0. I’m very keen to get feedback from Flux users (and potential users!) – so if you have any thoughts or comments on the new design, please let me know!
There’s quite a lot here, so please make yourself comfortable, grab a ${PREFERRED_HOT_BEVERAGE}
and we’ll dive in…
Motivation
I guess I should make clear that the goals for Flux haven’t changed: as ever, I want to offer broadly the same feature set as std::ranges, but in a way that is:
- Safer by default
- At least as performant (and hopefully more so in many cases)
- Easier to use, with fewer tricky corner cases
- As compatible as possible with existing STL code
Indeed, the changes proposed here are about furthering these goals.
One piece of feedback I’ve received repeatedly is that while people generally like Flux when they try it out (especially its performance vs ranges), they tend to find that it feels “too different” to the standard library. Flux does many things its own way, making it feel like there’s a lot to learn. This can make it a tough sell, especially when people have already invested the time in learning about ranges.
One of the goals for Flux 1.0 therefore is to reduce the “diff” from standard C++ – that is, to try to make Flux look and feel a bit more familiar.
Another piece of feedback I’ve received several times is that people would love to see either internal iteration or the cursor model (or both) proposed for standardisation. While I (obviously) agree that these would be excellent, with my SG9 hat on it would be an incredibly tough sell to bring the current Flux sequence
model to the committee. It is in a sense both too large and too similar to C++20 ranges.
With this in mind, the next version of Flux will replace the existing sequence
concept with two separate (but complementary) interfaces: one for single-pass algorithms, using internal iteration (iterable
) and a more powerful collection
concept (a simplified version of today’s multipass_sequence
).
All begin well, I hope to propose iterable
for standardisation in the C++29 cycle, for which Flux will serve as a reference implementation. With luck, the reduced scope (and performance and safety benefits) will help convince LEWG that this would be a useful addition to the standard.
Planned changes
Pipes, not dots
Reluctantly, we’ll move from using member functions for chaining to overloading operator|
, as in ranges and the forthcoming stdexec. This is worse for readability, worse for discoverability, worse for error messages and worse for compile times than using member functions – but it’s what the standard library does, and so we’re going to follow suit. This means that Flux pipelines will change from today’s
auto total = flux::ref(vec)
.filter(flux::pred::even)
.map([](int i) { return i * i; })
.sum();
to
auto total = std::cref(vec)
| flux::filter(flux::pred::even)
| flux::map([](int i) { return i * i; })
| flux::sum();
(Yes, it would be possible to support both syntaxes, but I’d rather not do that – it would mean more code that needed to be maintained and tested, and would lead to confusion about which is the “proper” way to write pipelines. Let’s just do it the standard way, even if that’s not ideal.)
Note that, unlike ranges and stdexec (but like Range v3’s actions) I plan on making terminal algorithms like sum()
pipeable, just because I think it reads quite nicely. I’d also quite like to add these pipeable function objects to a nested inline namespace (tentatively, flux::operations
?) so that you could say:
using namespace flux::operations;
auto total = std::cref(vec)
| filter(flux::pred::even)
| map([](int i) { return i * i; })
| sum();
to avoid repeating the namespace name, but without bringing everything in namespace flux
into scope (rather like using namespace std::literals
).
One thing that won’t be changing is the ownership model for adaptors. Flux adaptors will continue to act like sinks, taking ownership of what you pass to them – rather than the confusing situation with range views today, where they sometimes transfer ownership and sometimes act as reference types. (We’ll also continue to reject non-trivially-copyable lvalues, so you’ll have to explicitly copy things like vector
s into an adaptor.)
This means that, as ever, you’ll need to be explicit if you want to pass a sequence reference into a Flux adaptor. The best way to do this will be with std::ref
or std::cref
– the current flux::ref
and flux::mut_ref
functions will be retired in favour of using the standard functions.
Goodbye sequence
, hello iterable
Background
STL input iterators have always been a bit weird. For forward ranges, an iterator acts like a pointer to a range element; but for single-pass ranges this abstraction falls apart. Trying to use iterators with single-pass ranges is the proverbial square peg in a round hole, and has always felt that way in practice.
The same is true of cursors in Flux today. Cursors simply aren’t a good fit for single-pass iteration. I was aware of this when I first started working on the library, but I felt that it was better to try to come up with a single, generalised interface for all sequences, and that perfect was the enemy of good. With the benefit of hindsight, I’ve come to think that trying for a single, universal interface was probably the wrong decision.
A position-based interface (be it cursors or iterators) naturally means separating the “read” and “advance” operations. This is ideal for multipass sequences, where elements are readily accessible in memory or can be cheaply recreated. But single-pass sequences are isomorphic to streams, where in most cases “read and advance” is more naturally implemented as single operation. This mismatch is how we end up with things like the notoriously poor codegen for views::filter()
, the infamous transform | filter
“terrible problem”, and the “take(5)” issue for stream wrappers.
Separately, when I started working on Flux, internal iteration was something of an afterthought – I noticed it was possible to add it as a customisation point, and so I did so. It was only later that I realised how important this is for performance – and that what most people like best about Flux happens because they’re using code-paths where the internal iteration optimisation kicks in.
So, given that the current single-pass iteration scheme isn’t very good, and internal iteration is great, we’re going to start again, and introduce a new single-pass iteration model based around internal iteration.
Iteration contexts
At the heart of the new model is what’s called an iteration_context
. If we had proper concepts in the language, the iteration_context
interface would be defined like this:
enum class iteration_result : bool {
incomplete, complete
};
template <typename T>
explicit concept iteration_context {
typename element_type;
template <typename Pred>
requires callable<Pred&, bool(element_type)>
auto run_while(Pred&& pred) -> iteration_result;
};
That is, an iteration context needs to provide an element_type
typedef, and a run_while
member function which takes a unary predicate and returns an iteration_result
.
When run_while
is called, the context invokes the user’s predicate with each element of the sequence in succession; if the predicate returns false, then run_while
stops and returns iteration_result::incomplete
. Otherwise, when the context reaches the end of the sequence, it returns iteration_result::complete
. If run_while
returns incomplete
, it is expected that we can immediately call run_while
again to restart iteration from the next element.
Iteration contexts will typically hold pointers or references to their parent sequences, and are expected to be non-copyable and non-movable – this both discourages people from using them as anything except short-lived local variables, and saves implementors from having to guarantee validity after a move (which might be tricky in some cases).
Because iteration contexts hold references, they should be considered as “borrowing” from their parent sequence, to use Rust terminology. This means that, for safety, we should adhere to the so-called “law of exclusivity”: we must not call a mutating function on the sequence while an iteration context is “live”, and if we have a mutating iteration context then we should avoid any use of the parent sequence. One way of thinking about this is as if the sequence gets moved into the context when we create it, and restored in the context destructor.
The simplest possible iteration context might be something like so:
struct fours {
using element_type = int;
iteration_result run_while(auto&& pred) {
while (true) {
if (!pred(4)) {
return iteration_result::incomplete;
}
}
}
};
This infinitely generates the number 4 until the user is fed up enough to return false
from their predicate.
A more useful example is the following:
template <std::ranges::forward_range R>
struct range_iteration_context {
using Iter = std::ranges::iterator_t<R>;
using Sent = std::ranges::sentinel_t<R>;
Iter iter;
Sent sent;
using element_type = std::iter_reference_t<Iter>;
iteration_result run_while(auto&& pred)
{
while (iter != sent) {
if (!pred(*iter++)) {
return iteration_result::incomplete;
}
}
return iteration_result::complete;
}
};
This is a generic iteration context for any forward_range
. A generic context for any input_range
is only very slightly more complicated, since we don’t have postfix operator++
on iterators in that case.
Algorithms on iteration contexts are as follows:
-
run_while
template <iteration_context Ctx, typename Pred> requires callable<Pred&, bool(Ctx::element_type)> auto run_while(Ctx& ctx, Pred&& pred) -> iteration_result
Just returns
ctx.run_while(pred)
-
step
template <iteration_context Ctx, typename Func> requires callable<Func&, void(Ctx::element_type)> auto step(Ctx& ctx, Func func) /* -> see below */;
Performs a single iteration step and calls
func
with the next sequence element, wrapping the return value offunc
in an optional. For example, given ourfours
context above, we could writeauto opt = step(fours, [](int i) { return i * i; }); assert(opt.value() == 16);
Formally, let
R
beinvoke_result_t<Func&, Ctx::element_type>
. Ifis_void_v<R>
is true,step
is equivalent tobool called = false; run_while(ctx, [&](auto&& elem) { std::invoke(func, FWD(elem)); called = true; return false; }); return called;
Otherwise, let
S
bestd::decay_t<R>
ifR
is an rvalue reference type, orR
otherwise. Thenstep
is equivalent to:optional<S> out = nullopt; run_while(ctx, [&](auto&& elem) { out.emplace(std::invoke(func, FWD(elem))); return false; }); return out;
-
next_element
template <iteration_context Ctx> auto next_element(Ctx& ctx) -> optional<Ctx::element_type>;
Equivalent to
step(ctx, std::identity{})
– that is, it returns an optional containing the next sequence element, or anullopt
if there were no more elements.This function allows single-step iteration through an iterable sequence, equivalent to the Rust iteration model.
If the element_type is a reference then care must be taken with this function, as the reference may be invalidated by a subsequent call to
run_while
,step
ornext_element
.
Iterables
While iteration contexts actually perform the low-level operations, one of the design goals is that they should mostly be considered an implementation detail. Most users won’t interact with iteration contexts directly. Rather, the intention is that end users will compose pipelines out of higher-level algorithms on iterables – that is, things we can iterate over.
The iterable
concept is defined as:
template <typename T>
concept iterable = requires (T& t) {
{ iterate(t) } -> iteration_context;
};
i.e. it is satisfied when flux::iterate(it)
is valid and returns an iteration_context
. In turn,flux::iterate(it)
returns
flux::iterable_traits<R>::iterate(it)
, whereR
isstd::remove_cvref_t<decltype(it)>
, ifiterable_traits<R>
is not generated from the primary template- Otherwise,
it.iterate()
if that is a valid function call - Otherwise, a specialisation of
range_iteration_context
ifdecltype(it)
satisfiesinput_range
If none of these apply then the call to iterate()
is ill-formed.
Translated into english, this means that we’re iterable
if we have a member function iterate()
which returns an iteration_context
, or if we provide a specialisation of flux::iterable_traits
along the lines of
template <>
struct flux::iterable_traits<MyIterable> {
static auto iterate(MyIterable& it) -> MyIterContext
{
...
}
};
It’s worth noting here that many iterables will provide both const and non-const overloads of iterate()
, returning different context types. Unlike STL iterators however, there is no requirement for convertibility between non-const and const iteration contexts.
The iterable
concept is equivalent to today’s single-pass sequence
. The plan for Flux is that all of the current single-pass algorithms and adaptors will be modified to operate on iterable
s instead.
For example, for_each
can be implemented on iterables as:
template <iterable It, typename Func>
requires callable<Func&, void(iterable_element_t<It>)>
auto for_each(It&& it, Func func) -> Func
{
auto ctx = iterate(it);
run_while(ctx, [&](auto&& elem) {
(void) std::invoke(func, FWD(elem));
return false;
});
return func;
}
while fold()
can be written as roughly:
template <iterable It, typename Func, typename Init = iterable_value_t<It>>
requires foldable<...>
auto fold(It&& it, Func func, Init init = {}) -> Init
{
auto ctx = iterate(it);
run_while(ctx, [&](auto&& elem) {
init = std::invoke(func, move(init), FWD(elem));
return true;
});
return init;
}
Algorithms which operate on multiple sequences in lock-step generally can’t be written directly in terms of run_while
; instead, we can use step
or next_element
to advance each sequence by a single element at a time. For example, here is how we could write equal
:
template <iterable It1, iterable It2>
requires std::equality_comparable_with<
iterable_element_t<It1>,
iterable_element_t<It2>>
auto equal(It1&& it1, It2&& it2) -> bool
{
iteration_context auto ctx1 = iterate(it1);
iteration_context auto ctx2 = iterate(it2);
while (true) {
auto opt1 = next_element(ctx1);
auto opt2 = next_element(ctx2);
if (opt1 && opt2) {
if (*opt1 != *opt2) {
return false;
}
} else if (opt1 || opt2) {
return false;
} else {
return true;
}
}
}
As ever, writing iterable adaptors involves a bit more work than terminal algorithms, but generally less so than today’s sequence
adaptors (and much less so than most C++20 range adaptors). Typically, an adaptor will provide an iteration context which wraps the context of its base iterable. For example, the map
adaptor (transform_view
in C++20) would look something like this:
template <iterable Base, typename MapFn>
struct map_adaptor {
Base base;
MapFn map_fn;
template <typename BaseCtx, typename Fn>
struct context_type {
BaseCtx base_ctx;
Fn fn;
using element_type =
std::invoke_result_t<Fn&, typename BaseCtx::element_type>;
auto run_while(auto&& pred) -> iteration_result
{
return base_ctx.run_while([&](auto&& elem) {
return std::invoke(pred, std::invoke(fn, FWD(elem)));
});
}
};
auto iterate()
{
return context_type{.base_ctx = flux::iterate(base),
.fn = std::ref(map_fn)};
}
auto iterate() const requires iterable<Base const>
{
return context_type{.base_ctx = flux::iterate(base),
.fn = std::ref(map_fn)};
}
};
Reverse iteration
Reverse iteration in Flux today works in almost exactly the same was as it does in the STL. We have a bidirectional_sequence
concept which “inherits from” multipass_sequence
and allows the user to decrement cursors. We then have a reverse()
adaptor which swaps over the increment and decrement operations on cursors, with some fiddly off-by-one messing about in the read operation (again, just like reverse iterators).
Happily, the new iterable model allows us to make a dramatic simplification to this arrangement.
We can define a reverse_iterable
concept as
template <typename T>
concept reverse_iterable = iterable<T> &&
requires (T& t) {
{ reverse_iterate(t) } -> iteration_context;
};
where flux::reverse_iterate(it)
is defined similarly to iterate()
. In other words, we require that reverse_iterate()
returns an iteration context – one that just happens to generate the elements from back to front.
With this concept in place, we can provide a generic reverse-iterating context for all bidirectional, common ranges:
template <std::ranges::bidirectional_range R>
requires std::ranges::common_range<R>
struct range_reverse_iteration_context {
using Iter = std::ranges::iterator_t<R>;
Iter const start;
Iter iter;
using element_type = std::iter_reference_t<Iter>;
auto run_while(auto&& pred) -> iteration_result
{
while (iter != start) {
if (!pred(*--iter)) {
return iteration_result::incomplete;
}
}
return iteration_result::complete;
}
};
It’s worth noting that unlike std::reverse_iterator
, this formulation doesn’t need to perform an extra decrement before each dereference, reducing the number of per-element operations and likely allowing more optimisation opportunities. Also, importantly, it means that iterables can be reversible without being multipass, unlike the STL and current Flux models.
Most adaptors can support reverse iteration without too much trouble. For example, our map_adaptor
above requires very little modification:
template <iterable It, typename MapFn>
struct map_adaptor
{
// As before...
auto reverse_iterate() requires reverse_iterable<Base>
{
return context_type{.base_ctx = flux::reverse_iterate(base),
.fn = std::ref(map_fn)};
}
auto reverse_iterate() const requires reverse_iterable<Base const>
{
return context_type{.base_ctx = flux::reverse_iterate(base),
.fn = std::ref(map_fn)};
}
};
Providing a reverse_adaptor
(like std::views::reverse
) is also very simple – we just switch over the calls to iterate
and reverse_iterate
:
template <reverse_iterable Base>
struct reverse_adaptor {
Base base;
auto iterate() { return flux::reverse_iterate(base); }
auto iterate() const requires reverse_iterable<Base const>
{
return flux::reverse_iterate(base);
}
auto reverse_iterate() { return flux::iterate(base); }
auto reverse_iterate() const requires iterable<Base const>
{
return flux::iterate(base);
}
};
Iterables and ranges
As we’ve seen, under the new model, every C++20 input_range
is automatically a flux::iterable
, and every bidirectional common range is reverse_iterable
. This is a huge improvement on the status quo, where only sized, contiguous ranges are automatically Flux sequence
s.
This means that going from the ranges world to the Flux world couldn’t be easier. You can pass any C++20 range into a Flux algorithm, exactly as you would for a std::ranges
algorithm. You can also pass any range into a compatible Flux adaptor, without needing to do anything special. With the switch to pipes as well, this means that Flux will (hopefully) become a near drop-in replacement for view pipelines, but with improved performance in many common cases.
Going in the opposite direction, from the Flux iterable world to ranges, is a little bit trickier, for a couple of reasons:
- Firstly, in C++20, iterators are required to be (at least) movable, but Flux iteration contexts are not – so we cannot store an iteration context inside an iterator, even if we wanted to, without unappealing workarounds like allocating.
- Secondly, with iterators, repeated calls to read (without an intervening advance) are valid – but iteration contexts fuse together the read and advance operations, so we can’t do one without the other. This means that we will need to cache an element somewhere – but again, we cannot necessarily do this in an iterator, because there is no requirement that sequence elements be moveable.
What this means is that we need to use an adaptor class – a range object – to hold both the iteration context and the cached element. This range can then hand out input-only iterators, like so:
template <iterable It>
struct as_range_adaptor
{
private:
It iterable;
iteration_context_t<It> ctx = iterate(iterable);
next_element_t<It> elem = next_element(ctx);
struct iterator : move_only {
as_range_adaptor* parent;
using value_type = iterable_value_t<It>;
using difference_type = std::ptrdiff_t;
auto operator*() const -> decltype(auto)
{
return *parent->elem;
}
auto operator++() -> iterator&
{
parent->elem = next_element(parent->ctx);
return *this;
}
void operator++(int) { ++*this; }
auto operator==(std::default_sentinel_t) const -> bool
{
return !parent->elem.has_value();
}
};
public:
auto begin() -> iterator { return iterator{.parent = this}; }
static auto end() -> std::default_sentinel_t { return {}; }
};
Users can then move from Flux to ranges by piping into as_range
, which returns an as_range_adaptor
. For example:
auto rng = flux::iota(0, 10)
| flux::filter(flux::pred::even)
| flux::map([](int i) { return i * i; })
| flux::reverse
| flux::as_range;
my_range_algorithm(rng.begin(), rng.end());
One thing that’s worth noting is that while rng
here is a range whose elements are lazily generated, it is not a view
, because views are required to be at least moveable. This is turn means that we can’t pass an rvalue “as_range” directly into a C++20 view function – we need to save it as a named variable first, and then pass it as an lvalue:
/* Error: as_range returns a non-movable range, which we cannot pass
into the transform_view */
auto view = flux::iota(0, 10)
| flux::filter(flux::pred::even)
| flux::as_range
| std::views::transform([](int i) { return i * i; }); // ERROR!
/* Okay: pass the range as an lvalue instead */
auto rng = flux::iota(0, 10)
| flux::filter(flux::pred::even)
| flux::as_range;
auto view = rng | std::views::transform([](int i) { return i * i; });
While this is somewhat unfortunate, the fact that this only applies to single-pass iterables, there is an easy workaround, and Flux provides a wealth of adaptors of its own, will hopefully mean that it doesn’t come up too often in practice.
Special consideration: the range-based for loop
Probably the most common reason that people will want to convert iterables to ranges is so that they can use a range-based for loop. While they can do so with as_range
, it’s a bit annoying for users to need to remember to add that to the end of their Flux pipelines in order to use the convenient built-in syntax.
Fortunately there is something we can do. It turns out that if you look at the way the range-based for loop desugars, the requirements on the “iterator” returned by begin()
are much less strict than the C++20 std::ranges
concepts. In particular, there is no requirement that such “iterators” be moveable or copyable.
This means that we could, in theory, add begin()
and end()
methods to Flux iterable adaptors which return a non-moveable “pseudo-iterator”, specifically for use with range-for loops:
template <iterable It>
struct pseudoiterator {
private:
iteration_context_t<It> ctx;
next_element_t<It> elem;
public:
explicit pseudoiterator(It& it)
: ctx(iterate(it)),
elem(next_element(ctx))
{}
auto operator*() const -> decltype(auto) { return *elem; }
auto operator++() -> pseudoiterator
{
elem = next_element(ctx);
return *this;
}
auto operator==(std::default_sentinel_t) const -> bool
{
return !elem;
}
};
This would mean that we could use range-based for loops “naturally”:
for (int i : flux::iota(0, 10) | flux::filter(is_even)) // works
do_something(i);
The downside of this approach is that it might be confusing for users if .begin()
is valid to call, and returns something that looks a lot like an iterator, but won’t work with C++20 (or many C++17) algorithms without using as_range
.
(Of course, the ideal solution to this problem would be to teach the language for loop about the iterable
concept. Maybe one day…)
Collections
As we’ve mentioned, the iterable
concept is all about single-pass iteration, and it turns out that you can do an awful lot just with this. But some algorithms require that we are able to visit the same location in a sequence more than once – that is, multi-pass iteration.
The current version of Flux uses the multipass_sequence
concept for this purpose. In the next version, this will be replaced by the much simplified collection
interface instead.
Positions
In Flux today, a cursor
for a multipass sequence represents a position in that sequence, rather like a generalisation of an array index. (This is in contrast to iterators, which are a generalisation of array pointers.)
In moving to collection
, what a cursor represents won’t change – but the name will. Freed from the need to support single-pass sequences (where there is no meaningful notion of “place”), we can switch to calling cursors what they really are: positions.
As with multipass cursors in today’s Flux, collection positions are required to model std::regular
– that is, they must be default constructible, copyable, and equality comparable. We are also adding an addition requirement on positions: they must have a strict total order.
In other words, given two positions p1
and p2
, as well as being able to ask (as ever) whether p1 == p2
, we also want to be able to ask whether p1 < p2
, that is, if p1
represents an earlier position in a collection than p2
.
The goal for this is safety. If positions are always ordered, then we can easily perform bounds checking for all collections. In particular, it means that we can also perform bounds checking for arbitrary slices of collections. This is particularly important if different mutable slices get passed to different threads, where it is vital that we ensure that each thread does not write beyond its assigned bounds.
Formally, the position
concept looks like this:
template <typename P>
concept position =
std::regular<P> &&
std::three_way_comparable<P, std::strong_ordering>;
Collection concept
In order to define a type as a collection
, users need to provide implementations of four functions. As with the iterable
concept, these can either be member functions of the type itself, or they can be static members of a user-defined specialisation of flux::collection_traits<T>
.
The required functions are:
-
begin_pos
template <typename C> auto begin_pos(C const&) -> position auto;
As you might expect, returns the start
position
of the collection. -
end_pos
template <typename C> auto end_pos(C const&) -> position_t<C>;
This returns the end position of the collection, which must be the same type as
begin_pos
-
inc_pos
template <typename C> void inc_pos(C const&, position_t<C>&);
Increments the given position so that it points to the next element of the collection.
-
read_at_unchecked
template <typename C> decltype(auto) read_at_unchecked(C&, position_t<C> const&);
Returns the element at the given position. The “unchecked” part means that the implementor may assume that
is_readable_position
(see below) istrue
on entry to this function – that is, someone else has already taken care of bounds checking. This allows us to implement things liketry_read_at()
without double bounds checks.
One thing to note is that the first three of these functions take the collection by const reference. This is a change from the existing multipass_sequence
requirements, with the goal of making life easier for people implementing the collection protocol, as well as making things conceptually simpler overall. As always, it is possible (and common) for collections to provide two read
overloads for const- and non-const element access.
Another thing to note is that all collections are required to be able to provide an end position. This means that, unlike iterables, collections are always finite. (I don’t believe it’s possible to write an infinite collection with ordered positions without some sort of BigInt
type anyway, but if I’m wrong about that please let me know.) The current Flux bounded_sequence
concept is no more, as all collections are now “bounded”.
We can now define some further collection algorithms, using the above functions:
-
next_pos
template <collection C> auto next_pos(C const& col, position_t<C> pos) -> position_t<C>;
Returns the next position after
pos
as a functional update. Equivalent toinc_pos(col, pos); return pos;
-
is_valid_position
template <collection C> auto is_valid_position(C const& col, position_t<C> const& pos) -> bool
Equivalent to
pos >= begin_pos(col) && pos <= end_pos(col)
-
is_readable_position
template <collection C> auto is_readable_position(C const& col, position_t<C> const& pos) -> bool
Equivalent to
pos >= begin_pos(col) && pos < end_pos(col)
(Note that
end_pos(c)
is a valid but not readable position.) -
read_at
template <collection C> decltype(auto) read_at(C& col, position_t<C> const& pos)
Performs bounds-checked read access. This should be the default read operation for collections.
Equivalent to
if (!is_readable_position(col, pos)) { runtime_error(); // fail-fast } return read_at_unchecked(col, pos)
except that bounds checking may be performed at compile time if possible (see this blog post for details).
-
try_read_at
template <collection C> auto try_read_at(C& col, position_t<C> const& pos) -> optional<collection_element_t<C>>
If
is_readable_pos(col, pos)
is true, returns an optional containing the result ofread_at_unchecked(col, pos)
. Otherwise, returns a disengaged optional.
We can also implement a generic iteration context for collections:
template <collection C>
struct collection_iteration_context : immovable {
C* ptr;
position_t<C> pos;
position_t<C> const end;
using element_type = collection_element_t<C>;
auto run_while(auto&& pred) -> iteration_result
{
while (pos < end) {
if (!pred(read_at_unchecked(*ptr, std::exchange(pos, next_pos(*ptr, pos))))) {
return iteration_result::incomplete;
}
}
return iteration_result::complete;
}
};
Thus every collection can be iterable, and indeed the collection
concept subsumes iterable
. Of course, the above context type is just used as the fallback default; individual collection
implementations may chose to provide a custom iterable
implementation instead.
Collection refinements
Permutable collection
A permutable collection additionally implements an extra customisation point, swap_at_unchecked
:
template <collection C>
void swap_at_unchecked(C& col, position_t<C> const& p1, position_t<C> const& p2);
As you might imagine, this swaps the elements at the two positions, with the assumption that both positions are readable. A default implementation of this function is provided when collection_element_t<C>
is a non-const lvalue reference.
Of course, we also provide a bounds-checking wrapper, which should be preferred:
template <collection C>
void swap_at(C& col, position_t<C> const& p1, position_t<C> const& p2)
{
if (!(is_readable_pos(col, p1) && is_readable_pos(col, p2)) {
runtime_error();
}
swap_at_unchecked(col, p1, p2);
}
The purpose of this function is so that we can implement in-place algorithms like rotate()
and swap()
on collections which return proxy references, like zip
.
(It will also be necessary if we ever get a borrow checker for C++…)
Bidirectional collection
A collection can be made bidirectional by providing a dec_pos
implementation which, unsurprisingly, decrements a position. We also provide a prev_pos
function as a counterpart to next_pos
. Obviously, all bidirectional collections are also reverse_iterable
.
[Indeed, now that we have reverse_iterable
it might be worth looking at whether bidirectional
“pulls its weight” in the concept hierarchy.]
Random-access collection
A random-access collection additionally provides implementations of two more customisation points:
-
distance
template <collection C> auto distance(C const& col, position_t<C> const& from, position_t<C> const& to) -> int_t;
Returns the number of elements between
from
andto
, which may be negative ifto < from
, in constant time. The size of a random-access collection can be found withdistance(c, begin_pos(c), end_pos(c))
-
offset_pos
template <collection C> void offset_pos(C const& col, position_t<C>& pos, int_t offset);
Adjusts
pos
byoffset
places (which may be negative) in constant time.If
offset > distance(col, pos, end_pos(col))
oroffset < distance(col, pos, begin_pos(col))
then the resulting position will be invalid. It is unspecified whether this function raises a runtime error in this case.[Note: some implementations may need to catch an out-of-bounds jump immediately, to prevent undefined behaviour. Other implementations may choose to defer the bounds check until the next attempted read.]
Contiguous collection
A contiguous collection is a random-access collection whose elements are stored in a contiguous array in memory.
The contiguous_collection
concept requires that the collection’s element type is an lvalue reference, and that the collection implements the as_span
customisation point:
-
as_span
template <random_access_collection C> auto as_span(C& col) -> std::span<std::remove_reference_t<collection_element_t<C>>>;
Returns a
span
over the elements ofcol
.[Note:
contiguous_range
and the current Fluxcontiguous_sequence
instead require adata()
function which returns a raw pointer to the start of the underlying storage. Returning aspan
instead is moderately safer, as the element count has less chance of getting lost.]
Collections and ranges
For safety, collections need to be able to perform bounds checking. We can do this cheaply for sized, contiguous ranges, by using an integer index as the position type and doing ranges::data()[idx]
in the read operation – in other words, exactly what we do in Flux today. We can also extend this to all sized random-access ranges by using ranges::begin()[idx]
in the read
implementation – which may be slightly more expensive than using a raw pointer, but is still guaranteed to be O(1).
So, going from the ranges world to the Flux world can be summarised as follows:
- If
R
is a sized, random-access range, then it is also automatically Fluxrandom_access_collection
. IfR
is additionally acontiguous_range
then it is also acontiguous_collection
. This applies to things like raw arrays,std::array
,vector
,string
,string_view
,span
etc, as well as various adapted views. - Otherwise,
R
is a Fluxiterable
only
For node-based data structures like std::list
or std::unordered_map
, there is no simple way for us to enumerate elements or perform bounds checking, so they will be iterable
only. (But it’s worth remembering that Flux’s iterable
is isomorphic to Rust’s iterator
, which people seem broadly satisfied with – there is still a lot of functionality available even without collection
conformance.)
Going in the other direction, things are much simpler for collection
s than for iterable
s: every Flux collection
can be (at least) a forward_range
, without any need for as_range
or pseudoiterators or anything like that. Just as we do today for multipass_sequence
s, we can define a collection_iterator
:
template <collection C>
struct collection_iterator {
C* ptr;
position_t<C> pos;
using reference = collection_element_t<C>;
using value_type = collection_value_t<C>;
using difference_type = std::ptrdiff_t;
using iterator_category = std::forward_iterator_tag;
auto operator*() const -> reference
{
return read_at(*ptr, pos);
}
auto operator++() -> iterator&
{
inc_pos(*ptr, pos);
return *this;
}
auto operator++(int) -> iterator
{
auto tmp = *this;
++*this;
return tmp;
}
auto operator==(iterator const& rhs) const -> bool = default;
};
…along with about a billion more conditional operator overloads for bidir and random-access iterator support. We can then provide every collection
implementation with begin()
and end()
members returning collection_iterator
(just as we do today with the almost-identical sequence_iterator
).
A special note about filter
When we use a filtering adaptor – in Flux, ranges, or any similar library – the result of the filter predicate determines which elements are part of the output sequence.
That sounds obvious, but it has an important consequence. It means that mutating elements in a way that changes the result of filter predicate is equivalent to adding or removing elements from the resulting sequence. Doing so while we’re iterating over the sequence is really asking for trouble, in the same way as adding or removing elements of a vector while iterating over it.
Now as it turns out, if we’re only doing a single pass over the data – or more precisely, if we only evaluate the predicate once per element – then we can get away with arbitrary mutation, because it never actually gets observed. But if we’re doing multiple passes, then changing membership of the filtered sequence “on the fly” is very very likely to cause problems. Again, this is true whether we’re talking about C++ ranges, Flux, D ranges, or any other library (yes, even Rust).
This would be fine, except that mutating elements while filtering is something that people like to do fairly often. With Flux, fortunately, the worst that will happen is usually a failed bounds check at run time. But it would still be better to prevent people from getting into difficulty in the first place, if we can.
For this reason, I’m proposing that the new flux::filter
will not be a collection
: that is, it will be single-pass only. Thanks to our new iteration model, filter can still be reverse_iterable
, so I’m hopeful that most of what people want to do can still be achieved even without multi-pass.
For the remaining cases, I propose to add a second adaptor, tentatively called flux::filter_collection
. As the name suggests, this will model collection
(or bidirectional_collection
if the base allows), but importantly will yield “constified” elements. This doesn’t completely prevent people from mutating elements while iterating – there’s always const_cast
! – but it will hopefully serve as a deterrent.
Finally, let’s talk about caching. Ranges has the requirement that begin()
and end()
are constant-time, and Flux will have the same requirement for begin_pos()
and end_pos()
on collections. In order to satisfy this requirement, ranges’ filter_view
caches the first element which matches the filter, so that the first call to begin()
is O(N), but subsequent calls are O(1). Because of this, filter_view
does not support const iteration.
Given the definition of our new collection
protocol above, we’re in a tricky situation when it comes to implementing filter_collection
. We require that begin_pos()
is callable on a const instance of a collection, while also requiring that it returns an answer in constant time. If we wanted to use caching in order to satisfy the second requirement (at least at the second time of asking), then the only solution would be a mutable
member variable, which would in turn necessitate a mutex (or similar mechanism) to achieve thread safety – definitely not something you want in what is intended to be a high-performance library.
My proposed solution to this conundrum is to simply not do any caching at all in filter_collection
, and instead slap a big fat warning in the documentation that it might not meet the complexity requirements – which is exactly what Swift does, in fact.
I feel fairly confident that this is the right trade off, in part because the current flux::filter
doesn’t do any caching either, and I’m pretty sure no-one has ever noticed. In the new Flux, using filter_collection
will probably be quite rare, and using in specific ways that would trigger quadratic behaviour should hopefully be very, very rare indeed.
In conclusion
This has been quite a mammoth update, so thank you for making it to the end!
I’m pretty excited about the upcoming changes, and I believe Flux will become even more awesome than it already is once everything is finished. But I’m also very keen to gather feedback from the community. Does all this sound good? Are there improvements I should incorporate? Am I barking completely up the wrong tree? Please let me know below.
Meanwhile, I’ve started implementing these changes on the devel branch on Github. Right now there isn’t an awful lot to see but if you’re interested in following the latest developments, that’s the place to do it.
– Tristan