What is the advantage of a views::filter vs an if-continue?

100 Views Asked by At

I've been using ranges for a while, and each time I use filtering I feel like it can be easily replaced with a plain old if and continue statement. Consider these snippets of code:

for (auto value : values | ranges::views::filtered([](auto value) { /* ... */ })) {
    // ... do something with value
}

for (auto value : values) {
    if (/* ... */) continue;
        // ... do something with value
}

To me the second option looks way more readable. Besides, the first option may potentially involve some additional call overhead. So what are the advantages of filtering? Are there some guidelines on when I should choose one or another?

4

There are 4 best solutions below

4
galinette On

Second version (continue) is much easier to read, compatible with older c++ standards, and very close to the actual execution behind. First version will most likely be optimized to a very close binary code, but its syntax is in my opinion, overly complex and involves a lot of compile time optimizations of the template classes behind.

First version has zero advantages. Your thought is good, with recent c++ features people tend to overthink and bloat iterations.

EDIT : Comments pointed that I'm wrong about the temporary filtered list allocation. So both codes might produce the same exact instructions at the end. However, the arguments that second version is much simpler and readable as well as compatible with older c++ standards without drawbacks, still stand.

You also have a slightly different version:

for (auto value : values) {
    if (!...) {
        //Do stuff here
    }
}

I tend to prefer that one, because the control flow is more visually obvious than a continue. But that's a fully personal taste matter.

2
Sebastian Redl On

The first version is victim to C++'s awkward lambda syntax. If you already have a ready-made predicate or use a lambda library for short lambdas, plus a namespace alias, then it becomes much nicer to look at.

namespace srv = std::ranges::views;
for (auto const& value : values | srv::filter(&Widget::is_visible)) {
  // ...
}

Now, it has the advantage of being very clear in its intent, instead of just being a pattern like the early continue. You will likely get very similar assembly from the two versions in an optimized build.

6
Vivick On

This feels like an opinion-based question, but lemme give you the same perspective I give when asked this question in JavaScript or any other language with a collection/sequence manipulation library:

(almost) Everything you do with fancy views and ranges can be done via a call to reduce (which has many names in C++ std::ranges::fold_left from C++23, std::reduce from C++17 and std::accumulate).

Everything you can do with reduce can be done with a for-loop.

When you have a single operation, there's almost always an algorithm for it. If there isn't, you might write one yourself.

Things get more complex when you have multiple operations within your loop. In which case, ranges will usually become more readable, more declarative, and be clearer on its intent (i.e. self-documenting code).

I'll try to come up with an example that highlights this:

auto input = get_vector<int>();
std::vector<widget_type> output{};
output.reserve(input.size());

for (auto item : input) {
  if (item % 2 == 0) continue;

  auto widget = get_widget_by_index(item + 1);

  if (widget.hidden()) continue;

  output.emplace_back(std::move(widget));
}

output.shrink_to_fit();

can be easy to read, but compare it to this:

auto output = views::all(get_vector<int>()) // views::all is to allow the immediate use of temporaries without lifetime issues
| views::filter([](auto i) { return i % 2 != 0; })
| views::transform([](auto i) { return i + 1 })
| views::transform(&get_widget_by_index)
| views::filter(&widget_type::hidden)
| ranges::to<std::vector>();

I personally am not a fan of using views directly in the for-loop expression. I like to keep them separate as much as possible, so my loop still reads nice and clear. That is, if I still need one at all: sadly AFAIK there's no std::ranges::for_each that we can pipe into, which is a shame.

0
Rerito On

The views allow you to abstract things clearly. And since they are lazily evaluated, you can pass around complete processing pipelines through views. This is especially powerful when building generic functional blocks.

auto processingPipeline =
    std::ranges::views::transform([](auto&& v) { return std::forward< decltype(v) >(v).second; }) |
    std::ranges::views::filter([](const auto i) { return i & 1; });

std::map< int, int > myMap{ { 1, 2 }, { 2, 3 } };

for (int i : myMap | processingPipeline) // iterate over all odd values in myMap
{
    // ...
}

Of course this is a simple example, and the views allow for all kinds of use cases:

  • An object might hold a range and you want to apply a processing pipeline to it? Pass in the appropriate views (here you could imagine object.process(processingPipeline);)
  • If your code can be generic, use ranges to allow the calling code to pass all sorts of things (here we could imagine otherObject.doStuffOnIntRange(myMap | processingPipeline);)

Now which is clearer or better looking is opinion-based. I tend to think that using std::accumulate or ranges::views is a bit overkill for very simple processing. But they do shine when you need the genericity.

After that, it takes some getting-used-to but in the end the range syntax is IMHO very readable and shows intent clearly.