Ranges And Views: Good Or Bad?

Dangling Iterator Protection

  • A range is one object describing a … well … a range of elements

  • Traditionally (pre-20), described by a pair of iterators [begin, end)

    #include <vector>
    #include <algorithm>
    #include <iostream>
    
    int main()
    {
        auto numbers = {42, 666, -1, 5};
        auto iter = std::min_element(numbers.begin(), numbers.end());
        std::cout << *iter << std::endl;
        return 0;
    }
    
  • Ranges do the same, using one single object of range (or view) type

    #include <vector>
    #include <algorithm>                                   // <--- adds to std::ranges namespace
    #include <iostream>
    
    int main()
    {
        auto numbers = {42, 666, -1, 5};
        auto iter = std::ranges::min_element(numbers);     // <--- numbers satisfies the 
                                                           //      std::ranges::forward_range concept
        std::cout << *iter << std::endl;
        return 0;
    }
    
  • ⟶ much more possibilities

For example …

  • Passing temporaries adds to readability

  • … but often also to bugginess

#include <vector>
#include <algorithm>
#include <iostream>

int main()
{
    auto iter = std::ranges::min_element(
        std::vector{42, 666, -1, 5});                  // <--- temporary used!
    std::cout << *iter << std::endl;                   // <--- error: no match for ‘operator*’ 
                                                       //      (operand type is ‘std::ranges::dangling’)
    return 0;
}
  • std::ranges::dangling when passing an RValue

  • compiler error

Performance: views::drop Is Easy

  • How fast can .size() be determined on a drop_view (the result of std::views::drop())

  • ⟶ It is simply a matter of subtracting 2 from the underlying range’s .size()

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

int main()
{
    std::vector numbers = {2, 1, 4, 3, 5};
    auto even = numbers | std::views::drop(2);
    std::cout << even.size() << std::endl;             // <--- ok: fast
    return 0;
}

Performance: views::filter Is Not Easy

  • How long would .size() take on a filter_view?

  • Apply filter, and count how many are accepted

  • Rewind?

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

int main()
{
    std::vector numbers = {2, 1, 4, 3, 5};
    auto even = numbers | std::views::filter([](auto elem) { return elem%2==0; });
    std::cout << even.size() << std::endl;             // <--- compiler error: no size()
    return 0;
}
  • Compiler error, saying that there is no .size() on it

  • Trying to say that .size() cannot be computed in constant time

  • “… constraints not satisfied …”

size-filter-nok.cpp:9:27: error: no matching function for call to ‘std::ranges::filter_view<std::ranges::ref_view<std::vector<int, std::allocator<int> > >, main()::<lambda(auto:17)> >::size()’
    9 |     std::cout << even.size() << std::endl;             // <--- compiler error
      |                  ~~~~~~~~~^~
In file included from /usr/include/c++/12/ranges:47,
                 from size-filter-nok.cpp:2:
/usr/include/c++/12/bits/ranges_util.h:131:7: note: candidate: ‘constexpr auto std::ranges::view_interface<_Derived>::size() requires (forward_range<_Derived>) && (sized_sentinel_for<decltype(std::ranges::__cust::end((declval<_Tp&>)())), decltype(std::ranges::__cust_access::__begin((declval<_Container&>)()))>) [with _Derived = std::ranges::filter_view<std::ranges::ref_view<std::vector<int, std::allocator<int> > >, main()::<lambda(auto:17)> >]’
  131 |       size() noexcept(noexcept(_S_size(_M_derived())))
      |       ^~~~
/usr/include/c++/12/bits/ranges_util.h:131:7: note: constraints not satisfied

Constraints: How To Read?

  • Careful reading …

candidate: ‘constexpr auto std::ranges::view_interface<_Derived>::size() requires (forward_range<_Derived>) && (sized_sentinel_for<...
  • forward_range: Can be iterated from beginning to end multiple times

  • sized_sentinel_for:

    The sized_sentinel_for concept specifies that an object of the
    iterator type I and an object of the sentinel type S can be subtracted
    to compute the distance between them in constant time.
  • A-ha! .size() is not there because filter_view is linear time

And drop()? std::vector Is Easy

  • Drop 2 elements means ignoring the first two elements

  • Cheap with a std::vectorv.begin() + 2 (simple arithmetic)

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

void print(const auto& coll)
{
    for (const auto& elem: coll)
        std::cout << elem << std::endl;
}

int main()
{
    std::vector numbers = {2, 1, 4, 3, 5}; 
    print(numbers | std::views::drop(2));
    return 0;
}

And drop()? std::list Is Not Easy

  • Expensive with std::list ⟶ manually forwarding a linked list to 2nd element

#include <list>
#include <ranges>
#include <iostream>

void print(const auto& coll)                           // <--- note the const!
{
    for (const auto& elem: coll)
        std::cout << elem << std::endl;
}

int main()
{
    std::list numbers = {2, 1, 4, 3, 5};
    print(numbers | std::views::drop(2));              // <--- compiler error
    return 0;
}
  • Compiler error, saying that print() cannot be instantiated because …

  • passing ‘const std::ranges::drop_view as ‘this’ argument discards qualifiers

drop-nok.cpp: In instantiation of ‘void print(const auto:17&) [with auto:17 = std::ranges::drop_view<std::ranges::ref_view<std::__cxx11::list<int, std::allocator<int> > > >]’:
drop-nok.cpp:14:10:   required from here
drop-nok.cpp:7:5: error: passing ‘const std::ranges::drop_view<std::ranges::ref_view<std::__cxx11::list<int, std::allocator<int> > > >’ as ‘this’ argument discards qualifiers [-fpermissive]
    7 |     for (const auto& elem: coll)
      |     ^~~
In file included from drop-nok.cpp:2:
/usr/include/c++/12/ranges:2460:7: note:   in call to ‘constexpr auto std::ranges::drop_view<_Vp>::begin() requires !((__simple_view<_Vp>) && (random_access_range<const _Vp>) && (sized_range<const _Vp>)) [with _Vp = std::ranges::ref_view<std::__cxx11::list<int, std::allocator<int> > >]’
 2460 |       begin()
      |       ^~~~~
  • Point is: drop_view cannot be passed as const because it caches begin()

  • Bullshit

  • ALL SORTS OF BUGS!

One Of Many Bugs, Introduced By .begin() Caching

  • Caching means that const is not easily pretended

  • mutable is only a lie

  • Everything that I expect does not hold - at least not in any case

  • In some cases I cannot read-only iterate a const range

  • In some cases concurrent read-only iteration is not safe ⟶ race condition in .begin()

What is the output of the following program?

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

int main()
{
    std::vector numbers = {1, 3, 4, 3, 5, 2};
    auto even = numbers | std::views::filter([](auto elem) { return elem%2==0; });
    for (auto& elem: even)
        elem += 1;
    for (auto elem: even)
        std::cout << elem << std::endl;
    return 0;
}

Answer:

$ ./c++11-ranges-modify-view-nok
5
  • 5 is at index [2] which is cached as ex 4 - the first even number in the ex-vector

  • be careful!

Exceptions From Rules ⟶ Dogmatic Don’ts (Sigh, C++)

  • Don’t re-use views

  • Don’t modify collections through views

  • Don’t use multiple threads (not a good idea anyway)

  • Many more, I’m certain - watch out!

Thanks to Nicolai Josuttis who is bashing the C++ standards comittee in this video. He is much less polite than I am.