суббота, 13 сентября 2025 г.

[prog.c++] Наткнулся на любопытное с std::ranges::views::iota

Вчера мое утро началось с того, что под Linux-ом компилятор GCC 13 отказался компилировать код вроде вот такого (это не реальный код, а минимизированая версия на которой проблема воспроизводится):

templatetypename T, typename Range >
[[nodiscard]]
T
make_from( const Range & r )
{
   return T{ r.begin(), r.end() };
}

int main()
{
   std::string src{ "Hello, World" };
   auto v = make_from< values_container >(
         std::ranges::views::iota( 0u, src.size()) );

   std::cout << v.front() << " - " << v.back() << std::endl;
}

Изначально код был написан под другую платформу с более свежим С++ным компилятором, поэтому там make_from был реализован иначе, проблем с компиляцией не было. А вот под уже довольно древним по современным меркам GCC возникли проблемы: компилятор не видел у values_container конструктора, который бы получал два итератора.

Конкретно в моем случае в качестве values_container был folly::fbvector, но это не суть. Вполне мог бы быть и std::vector, проблема была бы точно такой же.

Ошибка несколько обескуражила, т.к. я точно знал, что у values_container есть конструктор, принимающий итераторы first и last.

Но с этим-то удалось разобраться достаточно быстро: first и last должны быть одного типа. А в моем случае объект-рэндж, возвращенный вызовом iota, имел разные типы для begin() и для end(). Соответственно, раз типы итераторов разные, то и компилятор не может найти подходящий конструктор у values_container.

Ну OK, раз типы у итераторов разные, то можно к этой ситуации приспособить реализацию make_from. Например, написать ее так:

templatetypename T, typename Range >
[[nodiscard]]
T
make_from( const Range & r )
{
   T result;

   auto f = r.begin();
   const auto l = r.end();
   for( ; f != l; ++f )
      result.push_back( *f );

   return result;
}

Но как бы так не есть хорошо, потому что получается излишне пессимизированная версия, ведь мы теряем возможность более эффективной реализации когда типы итераторов совпадают.

Ну значит попробуем это учесть:

templatetypename T, typename Range >
[[nodiscard]]
T
make_from( const Range & r )
{
   using begin_it_t = std::decay_t< decltype(r.begin()) >;
   using end_it_t = std::decay_t< decltype(r.end()) >;

   if constexpr( std::same_as< begin_it_t, end_it_t > )
      return T{ r.begin(), r.end() };
   else
   {
      T result;

      auto f = r.begin();
      const auto l = r.end();
      for( ; f != l; ++f )
         result.push_back( *f );

      return result;
   }
}

Но и эта версия не самая эффективная, т.к. когда у нас типы итераторов не совпадают, то мы не имеем возможности сделать предварительный reserve. Или можем?

В принципе, можем. Есть такой концепт как sized_sentinel_for. С его помощью реализация принимает вид:

templatetypename T, typename Range >
[[nodiscard]]
T
make_from( const Range & r )
{
   using begin_it_t = std::decay_t< decltype(r.begin()) >;
   using end_it_t = std::decay_t< decltype(r.end()) >;

   if constexpr( std::same_as< begin_it_t, end_it_t > )
      return T{ r.begin(), r.end() };
   else
   {
      T result;

      auto f = r.begin();
      const auto l = r.end();

      if constexpr( std::sized_sentinel_for< end_it_t, begin_it_t > )
         result.reserve( std::ranges::distance( f, l ) );

      for( ; f != l; ++f )
         result.push_back( *f );

      return result;
   }
}

И вот когда новая версия make_from была получена меня посетил сильно запоздалый вопрос: а почему, собственно, iota возвращает объект-рэндж у которого begin() и end() имеют разные типы итераторов?

Ладно бы iota создавался для бесконечного интервала значений. Но ведь в моем случае есть фиксированный отрезок, так почему же для такого отрезка iota ведет себя подобным образом?

И вот тут "Зоркий глаз" наконец замечает, что в iota передаются значения двух разных типов: 0u -- это unsigned int, тогда как src.size() -- это std::size_t.

Может быть поэтому iota и делает для end() другой тип итератора, нежели для begin()?

ОК, заменяем вызов iota на вот такой:

auto v = make_from< values_container >(
      std::ranges::views::iota( std::size_t0u }, src.size()) );

И, действительно, типы итераторов для begin() и end() теперь одинаковые.

Но код все равно не компилируется!

Причина в том, что begin() и end() теперь возвращают итераторы, у которых iterator_category определена как std::output_iterator_tag. Тогда как конструктор для вектора хочет итераторы категории std::input_iterator_tag (при этом folly::fbvector поддерживает еще и std::forward_iterator_tag).

Я оказался перед выбором: либо продолжать дорабатывать make_from, чтобы она могла работать и в случае с output-interators, либо же предложить переписать фрагмент с iota, дабы избежать всех этих наворотов внутри make_from.

Решил пойти по второму пути и начал описывать в текстовом документе собранные мной грабли для того, чтобы коллеги по проекту на них не наступали. А в процессе написания этого документа стал проверять разные варианты вызова iota. На разных компиляторах.

Оказалось, что вот такой вот вариант:

auto v = make_from< values_container >(
      std::ranges::views::iota( std::size_t0u }, src.size()) );

не принимается GCC, но принимается VC++. Правда VC++ при этом выдает кучу предупреждений о неявном преобразовании от std::size_t к int.

И вот тут-то до меня дошло, что нужно глянуть на определение values_container. А values_container был вектором int-ов.

Не unsigned int, не std::size_t, а именно int. Но ведь объект-рэндж, возвращенный iota, производит не int-ы! В оригинале он давал unsigned int, в "исправленном" варианте -- std::size_t. Но не int.

Ладно, раз так, то меняем:

auto v = make_from< values_container >(
      std::ranges::views::iota( 0static_cast<int>(src.size())) );

И все работает даже с первоначальной примитивной реализацией make_from:

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

templatetypename T, typename Range >
[[nodiscard]]
T
make_from( const Range & r )
{
   return T{ r.begin(), r.end() };
}

using values_container = std::vector<int>;

int main()
{
   std::string src{ "Hello, World" };
   auto v = make_from< values_container >(
         std::ranges::views::iota( 0static_cast<int>(src.size())) );

   std::cout << v.front() << " - " << v.back() << std::endl;
}

Т.е. с int-ами у объекта-рэнджа и одинаковые типы итераторов для begin() и end(), и сами эти итераторы попадают в категорию input-iterators.

В итоге, вместо того, чтобы писать навороченную версию make_from, потребовалось всего лишь исправить вызов iota подставив туда правильные типы значений:

auto v = make_from< values_container >(
      std::ranges::views::iota(
            values_container::value_type{ 0 },
            static_cast<values_container::value_type>(src.size())) );

PS. Если честно, то я не понимаю, почему в случае с std::size_t объект-рэндж использовал итераторы категории output-iterators. А разбираться с этим сейчас откровенно лень.

PPS. Если кому-то интересно, что за цирк происходит с категориями итераторов, то вот минималистичный пример с демонстрацией https://godbolt.org/z/cK3MKbTeT, а объяснение сего феномена есть на Stackoverflow.

Комментариев нет: