Ranges (C++20)
At work-work, we try to stick to the forefront of C++ language development: C++20 all the time, C++23 as it shows up and is available in compilers. It’s a weird mix sometimes with a codebase that has a lengthy history. A while back I bumped into a for loop, tried to be clever and then hit limitations of Clang – and those limitations are sometimes relevant for KDE code that lands in FreeBSD, which is why I’m writing about it.
Some of this is inspired by Tina Ulbrich’s talk at Meeting C++2021, although I swear I wrote it before watching the whole thing through. It’s a decent talk, in particular because it also presents considerations: you can be too clever with ranges.
Code Sample
The code snippets in this blog post come from a complete
program here which you can compile
with a suitable GCC (11 or 12 will do) with g++ -std=c++20 ranges1.cc
.
Data Structure
The code in question uses a struct-of-arrays data structure. In this data structure, there are multiple fields (data members) where each field is an array; the data held at corresponding array indexes is related.
struct MyData {
int dataPoint[8] = {};
bool dataPointIsValid[8] = {};
};
This is, a priori, an unfortunate choice of data structures. We know that dataPoint[3]
is valid only if
the corresponding dataPointIsValid[3]
is true
, but that isn’t clear from looking only at the data points.
An often (but not always!) nicer data structure is the array-of-structs:
struct MyDataPoint {
int value = 0;
bool isValid = false;
};
struct MyNicerData {
MyDataPoint data[8];
};
In this data structure, a data point’s validity is carried with the data itself, rather than being in some other array. Let’s look at some algorithms applied to the two different data layouts.
Taking Sums
Let’s suppose we need the sum of the data points.
int sumOfDataPoints(const MyData& data)
{
int sum = 0;
for (int i = 0; i < 8; ++i) {
sum += data.dataPoint[i];
}
return sum;
}
With the “nicer” data structure it looks very similar, only the indexing happens somewhere slightly different in the expression.
int sumOfNicerDataPoints(const MyNicerData& data)
{
int sum = 0;
for (int i = 0; i < 8; ++i) {
sum += data.data[i].value;
}
return sum;
}
Feel free to suggest
constexpr size_t array_size = 8;
or “why notstd::array<>
?”
Explicit for-loops like this are considered a bit “old-school”, at least in my work-work codebase. While they are easy to read from a “basics of the language” point of view, they do not express intent very well. You have to read the body of the for-loop to know what it’s doing. Here, naming can help, or maybe a comment, but wouldn’t it be nice if the code itself expressed intent.
On the topic of naming, I cannot reccomment Kate Gregory Naming is Hard: Let’s Do Better enough. She’s given the talk many times, here’s a CppCon 2019 YouTube video if you want a recap.
Algorithms
C++ algorithms are great, if you know them. Finding out which algorithms are there is a matter of practice. I can recommend Ivan Čukić as a mind-bending introduction. For a visual representation, the world map of C++ algorithms helps. It’s on the wall at work-work.
Standard algorithms provide a standard “vocabulary” of things you can do with a container.
Having them around gives you a richer vocabulary. Much like the two “sum” functions
defined above – except they are standard and so you can expect anyone who
claims to speak C++20 to know about them. With good good descriptive function
names you can skirt the issue, but at some point “std::accumulate
on MyNicerData.data
”
becomes a natural way to say “iterate over the elements of MyNicerData.data
and
add them together”.
Taking Sums, Algorithmically
There is a standard algorithm (which unfortunately lives in the header <numeric>
), called accumulate, which is a fancy word for “sum”
that adds up values over a collection. It has some variations and gotchas
but it turns the “sum” function into a one-liner.
int stdSumOfDataPoints(const MyData& data)
{
return std::accumulate(std::begin(data.dataPoint), std::end(data.dataPoint), 0);
}
I’m prepared to argue over whether this is “better”, but we had better have a cup of tea and some biscuits to argue over, and it will be polite. Here there is already a good name available, so the interior of the function may as well be a black box. But if we use standard vocabulary terms, we might not need to introduce special names because the generic term encompasses all the specific implementations – in other words, we could write the one-liner at the call site.
Looking at that snippet, it’s a bit wordy, but you can read it as a fancy for loop:
- from the
begin
of the array, - to the
end
of the array, - counting from
0
, - accumulate (sum up) the values.
Let’s take a look at the same code with the “nicer” data structure. Here, we can’t just “add” the values
stored in the array because they are not integers – they are MyDataPoint
values, which have an
integer value. There is an overload of std::accumulate
for that which takes an operation to apply:
int stdSumOfNicerDataPoints(const MyNicerData& data)
{
return std::accumulate(std::begin(data.data), std::end(data.data), 0,
[](int v, const MyDataPoint& p) { return v + p.value; });
}
The lambda at the end is the bit of code that tells how to “add” an int
and a MyDataPoint
.
It adds the point’s value
to the integer. Alternatively, we could define an operator+()
on
MyDataPoint
, but +
is a very generic name and it is difficult to say
if any particular implementation is “the right one” generically. Better to
have bespoke functions at the call site.
Iterators
The standard algorithms work on iterators. An interator is, generically, an object that helps iterate over / enumerate / travers / point at the elements of a collection, in some order.
Iterators can take on many forms, and if you squint, you can see one in the code code for sumOfValidDataPoints
, the first code
snippet in this blog entry.
The iterator there is i
, which is the object (an int
) that helps iterate over the elements of the
collection (two collections, really: the arrays dataPoint
and dataPointIsValid
).
More standard, iterators are made by the std::begin()
and std::end()
functions,
or by methods on a collection (e.g. std::vector::begin()
). They’re all over the place,
sometimes implicitly.
Taking Sums, Algorithmic Take Two
So far, we have ignored the “valid data point” boolean entirely. Let’s change the functions to only sum the valid ones. With for-loops, this can be done in many ways.
int sumOfValidDataPoints(const MyData& data)
{
int sum = 0;
for (int i = 0; i < 8; ++i) {
if (data.dataPointIsValid[i]) {
sum += data.dataPoint[i];
}
}
return sum;
}
With the “nicer” data structure it looks very similar, again only differing in the indexing.
int sumOfNicerValidDataPoints(const MyNicerData& data)
{
int sum = 0;
for (int i = 0; i < 8; ++i) {
if (data.data[i].isValid) {
sum += data.data[i].value;
}
}
return sum;
}
But for-loops are old-school. Algorithms are the hotness.
It’s relatively easy to adapt the algorithm
with the nicer data structure: all we need to do is change the definition
of how to add an integer and a MyDataPoint
. If the data point isn’t valid, leave the integer unchanged.
int stdSumOfValidNicerDataPoints(const MyNicerData& data)
{
return std::accumulate(std::begin(data.data), std::end(data.data), 0,
[](int v, const MyDataPoint& p) { return p.isValid ? v + p.value : v; });
}
But with the other, less-nice, data structure, there’s a problem:
there is no way to get to the “other” array that we need.
The iterator in the algorithm is an iterator over the dataPoint
array,
which doesn’t have the isValid
information we need.
int stdSumOfValidDataPoints(const MyData& data)
{
return std::accumulate(std::begin(data.dataPoint), std::end(data.dataPoint), 0,
/* How to get data.dataPointIsValid here? */);
}
An iota of cleverness
There’s a Dutch saying “ik snap er geen iota van” which means “this is totally incomprehensible”.
At this point, I’m really reaching: I insist on applying an algorithms-based
approach to the struct-of-arrays, and I don’t want to define a custom iterator myself
(one which would, for instance, produce indexes suitable for use on the arrays inside MyData
).
Fortunately the standard ranges library (since C++20) already has something for me.
An iota_view
provides a
way to treat a sequence integers as something that can be iterated over.
Consider this “range”:
auto r = std::ranges::iota_view(std::size_t(0), std::size(data.dataPoint));
This says “when iterating over r
, the dereferenced iterator will produce values size_t(0)
up to, but not including, std::size(data.dataPoint)
”. In other words, values 0 through 7.
We can then create a suitable lambda to give to the accumulate algorithm:
it can use the dereferenced iterarator value as an index
into the data structure:
int iotaSumOfValidDataPoints(const MyData& data)
{
auto r = std::ranges::iota_view(std::size_t(0), std::size(data.dataPoint));
return std::accumulate(r.begin(), r.end(), 0,
[&data](int v, std::size_t index)
{ return data.dataPointIsValid[index] ? v + data.dataPoint[index] : v; });
}
I’d like to claim that this is terrible code. Obscure, overly-clever. It’s based on piling poor choice (insisting on algorithms) on top of poor choices (struct-of-arrays) and then hitting things with a hammer until it fits.
Don’t do this.
Fortunately, Clang 15 does not have a std::ranges::iota_view
at all,
so this code does not compile on FreeBSD (or in other contexts where Clang would be used).
Standard Library Quality-of-Implementation
This somewhat rambling post has gone from “here are two data structures” via “algorithms are cool” to “data structure influences what algorithms can be applied” to “here’s a convoluted and contrived example of how to Make It Fit”. And then Clang torpedoed the contrived example.
But iota_view
is, in general, a useful thing. I can imagine it ending up
in KDE code at any moment, in some algorithms-based code that needs indexes.
And if it does, then things blow up in the Clang world. KDE CI would catch
it, but I would be hard-pressed to explain why good code, readable code,
standard-compliant code, fails with a particular compiler. “Fix your compiler”
seems like a reasonable instruction at that point.
This is visible already with NeoChat, which uses a libQuotient function that fails in template specialization – NeoChat hasn’t built successfully for production FreeBSD releases in quite some time.