Why do the C++ standard library's range algorithms like min_element
return a std::ranges::dangling
placeholder in case their argument container was an rvalue?
It could be totally possible to implement them in a way they would return an object holding a moved copy of that temporary container, so that nothing would dangle, along with the algorithm's main result (an iterator to the minimal element, in this case). Which could be quite convenient in some cases - e.g., when I receive a vector
from some function call and want to immediately find the value of its minimal element.
So, why doesn't the C++ standard library do that?
Here's the illustration of my idea how such a "better min_element
" that could handle both lvalue and rvalue arguments could be implemented:
#include <iterator>
#include <algorithm>
#include <type_traits>
#include <utility>
namespace better_std_ranges
{
template<typename Range>
constexpr auto min_element(Range& range)
{
using std::begin;
using std::end;
return std::min_element(begin(range), end(range));
}
template<typename Range>
constexpr auto min_element(Range&& range)
{
static_assert(!std::is_reference_v<Range>, "wrong overload chosen");
class _result_iterator_type // todo: inherit from some crtp base that will provide lacking operators depending on _underlying_iterator_type::iterator_category
{
using _underlying_iterator_type = std::decay_t<decltype(std::begin(std::declval<Range&>()))>;
public:
explicit constexpr _result_iterator_type(Range&& range) noexcept(std::is_nothrow_move_constructible_v<Range>)
: _underlying_range{std::move(range)}
, _underlying_iterator(::better_std_ranges::min_element(_underlying_range))
{
}
using difference_type = typename _underlying_iterator_type::difference_type;
using value_type = typename _underlying_iterator_type::value_type;
using pointer = typename _underlying_iterator_type::pointer;
using reference = typename _underlying_iterator_type::reference;
using iterator_category = typename _underlying_iterator_type::iterator_category;
constexpr decltype(auto) operator*() const
{
return *_underlying_iterator;
}
// todo: define other member functions that were not provided by the inheritance above
private:
Range _underlying_range;
_underlying_iterator_type _underlying_iterator;
};
return _result_iterator_type{std::move(range)};
}
}
#include <vector>
#include <iostream>
auto make_vector()
{
return std::vector{100, 200, 42, 500, 1000};
}
int main()
{
auto lvalue_vector = make_vector();
auto lvalue_vector_min_element_iterator = better_std_ranges::min_element(lvalue_vector);
std::cout << *lvalue_vector_min_element_iterator << '\n';
auto rvalue_vector_min_element_iterator = better_std_ranges::min_element(make_vector());
std::cout << *rvalue_vector_min_element_iterator << '\n';
}
Output:
42
42
There are two problems with this approach.
First, it breaks the semantics of the algorithm. The point of min_element
(and any other algorithm that returns an iterator) is to return an iterator into the range. You're not doing that - you're returning an iterator into a different range. That really confuses the notion of what the return even means in this case. What would you even compare this iterator to? There's no corresponding .end()
?
Second, the iterator model in C++ is based very strongly around the notion that iterators are cheap to copy. Every algorithm takes iterators by value and copies them around freely. Iterators are assumed to be light-weight and, importantly, non-owning. For forward iterators, copies of an iterator are assumed to be interchangeable.
Everything about this breaks if you suddenly have an iterator that has member std::vector<T>
that it refers into. Copying iterators becomes very expensive. And now each distinct iterator copy is actually an iterator into a completely different range?
You can do a little bit better by having the iterator have a member std::shared_ptr<std::vector<T>>
instead of a std::vector<T>
. This way copies are much cheaper and no longer independent, so you have something closer to a legitimate iterator. But now you have to do an extra allocation (to create the shared pointer), you still have the issue where the iterator you're returning is into a different range than the algorithm was given, and you have the issue where the algorithm has very different semantics based on whether you provide an lvalue or rvalue range.
Basically, min_element
on an rvalue range needs to either:
dangling<I>
could still let you get at the underlying I
)I don't think there's another option here, really.