19

Consider the following code using the ranges library (from c++20)

#include <iostream>
#include <ranges>
#include <vector>

int main() {
    std::vector<int> inputs{1, 2, 3, 4, 5, 6};

    auto square_it = [](auto i) {
        std::cout << i << std::endl;
        return i * 2; };

    auto results = inputs | std::views::transform(square_it) | std::views::filter([](auto i){ return i % 3 == 0; });

    for(auto r : results) {
        // std::cout << r << std::endl;
    }
}

The cout in the square function is to log when the square function is called by the ranges library. This code prints

1
2
3
3
4
5
6
6

The question is, why are values that match the filter's predicated are printed twice?

I have seem this code in a presentation in CppCon 2020, where the presenter explains why this happens. According to him, filter iterates until its predicate is satisfied (and of course if needs to call transform each time). Then filter stops and it is ready to be iterated on. After that the actual iteration is started and a value is read from filter, which then calls transform again a second time for the same input.

It is not clear to me why this is necessary. Since ranges::views compute values lazily and every view operation pulls data from the one before it, why can't filter just pass the value to whoever is after it in the pipeline as soon as it finds a match?

gonidelis
  • 885
  • 10
  • 32
darcamo
  • 3,294
  • 1
  • 16
  • 27
  • 6
    I'm behind on talks so I'm not sure if this was ever mentioned, but it's worth noting that range-v3 has `views::cache1` that you can toss in if you need to cache the value instead of calling the function more than once. – chris Oct 05 '20 at 03:42
  • That would be useful to have in standard ranges as well, but it seems there is no cache in c++ ranges. I wonder what would be the drawback of always caching the value in filter if matches filter's predicate. After all, someone down in the pipeline needs it. – darcamo Oct 05 '20 at 04:23
  • 1
    I believe the problem there mainly lies in having a cost that people have to pay for even if not caching is just as fast for them. With `cache1`, they can opt in to caching and it's clear the cost is there. Of course, this transform-filter combo is a common case that comes up for people not expecting the extra function call and there's no explicit indication of that in the code either. Anyway, I agree it would be a useful thing to have in standard ranges, but at least you can plug in an external one. – chris Oct 05 '20 at 05:26
  • with `cache1`, what's the `cost` other than having an extra register? Isn't in most cases that `cache1` is no worse than otherwise? Thanks – dragonxlwang Jan 29 '21 at 05:11
  • 1
    @darcamo "always caching the value" is incorrect if your transform returns by reference, or is uncopyable – Caleth Feb 11 '21 at 13:06
  • @Caleth Why? Is it because some other operation might mess with the value afterwards? – gonidelis Apr 24 '21 at 15:24

1 Answers1

13

why can't filter just pass the value to whoever is after it in the pipeline as soon as it finds a match?

Because in the iterator model, positioning and accessing are distinct operations. You position an iterator with ++; you access an iterator with *. These are two distinct expressions, which are evaluated at two distinct times, resulting in two distinct function calls that yield two distinct values (++ yields an iterator, * yields a reference).

A filtering iterator, in order to perform its iteration operation, must access the values of its underlying iterator. But that access cannot be communicated to the caller of ++ because that caller only asked to position the iterator, not to get its value. The result of positioning an iterator is a new iterator value, not the value stored in that iterated position.

So there's nobody to return it to.

You can't really delay positioning until after accessing because a user might reposition the iterator multiple times. I mean, you could implement it that way in theory by storing the number of such increments/decrements. But this increases the complexity of the iterator's implementation. Especially since resolving such delayed positioning can happen through something as simple as testing against another iterator or sentinel, which is supposed to be an O(1) operation.

This is simply a limitation of the model of iterators as having both position and value. The iterator model was designed as an abstraction of pointers, where iteration and access are distinct operations, so it inherited this mechanism. Alternative models exist where iteration and access are bundled together, but they're not how standard library iteration works.

Nicol Bolas
  • 449,505
  • 63
  • 781
  • 982
  • 2
    For input iterators (i.e. single pass range) are there any downsides to the alternative model and if there are none was it not possible for the Ranges feature in c++20 to wrap the iterator model to fit the alternative model so that this unexpected/inefficient behavior is avoided? – Eric Roller Apr 01 '21 at 16:34
  • 1
    @EricRoller: Which "alternative model" are you referring to? I specified that in the plural as there are *many* "alternative models" that one could pick from. The job of the Range TS was not to create a new iteration model; it was to take the existing model and extend it to the concept of ranges. It did make a few changes to the details of the model, but it wasn't going to invent an entirely new way to handle input iterators. Remember: the STL iterator model is designed to abstract pointers, where access and increment are separate operations. That has upsides and downsides. – Nicol Bolas Apr 01 '21 at 17:01
  • C# does it right. I don't know their iterator model but if we replace `transform` by `Select` and `filter` by `Where`, the transform function is called only once per element. – V. Semeria May 23 '23 at 13:56
  • @V.Semeria: "*C# does it right.*" Define "right". Every iteration model has benefits and downsides. A model that might be an advantage in this respect can have disadvantages elsewhere. – Nicol Bolas May 23 '23 at 14:13
  • In this case the definition of right is what C# does. And OCaml, and Haskell, and F#, and powershell. I mean seriously, can you not improve this in the next version of C++? – V. Semeria May 23 '23 at 14:16
  • @V.Semeria. "*In this case the definition of right is what C# does.*" Um... why? "*can you not improve this in the next version of C++?*" Are you really asking if C++ is going to break everything in the next version of the language by adopting a fundamentally incompatible iteration model, along with all of the different algorithms and such? No, they're not going to do that. – Nicol Bolas May 23 '23 at 16:46
  • @V.Semeria. From looking at `IEnumerable`, it seems to be a [push-based iteration model](https://stackoverflow.com/questions/60550491/why-can-ranges-not-be-used-for-the-pipes-library-functionality/60560232#60560232). C++ iterators are a pull-based model. These models have different advantages and disadvantages, but you cannot switch from one to another transparently. – Nicol Bolas May 23 '23 at 16:52