Диапазоны C ++ 20, также известные как STL v2, эффективно заменяют существующие алгоритмы и средства STL. В этой статье я проведу вас через изменения, которые вводят диапазоны, расскажу о представлениях, которые представляют собой новый составной подход к алгоритмам, и покажу примеры FizzBuzz с использованием трех разных методов, в каждом из которых используются некоторые аспекты диапазонов.

Однако обратите внимание, что диапазоны - это одна из функций, реализованных в C ++ 20 в полусфере. C ++ 23 должен приблизить нас к всесторонней поддержке. Поэтому в некоторых примерах будет использоваться библиотека диапазона v3.

Диапазоны по сравнению со старым STL

Как уже упоминалось, диапазоны - это прямая замена STL. Однако они вносят как внутренние, так и пользовательские изменения, которые в целом повышают их полезность.

Концепции

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

Типичный пример - это попытка отсортировать std :: list. К сожалению, это легко сделать, если вы новичок в C ++.

#include <iostream>
#include <ranges>
#include <list>
#include <algorithm>
int main() {
    std::list<int> dt = {1, 4, 2, 3};
    std::ranges::sort(dt.begin(), dt.end());
    std::ranges::copy(dt.begin(), dt.end(), 
        std::ostream_iterator<int>(std::cout, ","));
}

Вместо того, чтобы получать сбивающую с толку ошибку об операторе минус, теперь мы получаем точную проблему как первую ошибку:

include/c++/12.0.0/bits/ranges_algo.h:1810:14: note: because 'std::_List_iterator<int>' does not satisfy 'random_access_iterator'

Мы можем изучить концепции, определенные библиотекой Ranges, поскольку они являются частью стандарта. Например, концепция диапазона очень проста и просто требует, чтобы выражения std::ranges::begin(rng) и std::ranges::end(rng) были действительными. Если вы хотите узнать больше о концепциях, ознакомьтесь с моим руководством по концепциям.

Основное изменение здесь состоит в том, что end() больше не нужно возвращать тот же тип, что и begin(). Возвращаемый дозорный должен быть только сопоставим с типом итератора, возвращаемым begin().

Помимо упрощения определенных вариантов использования, он также позволяет использовать бесконечные диапазоны и потенциальное улучшение производительности.

std::vector<int> dt = { 1, 2, 3, 4, 5, 6, 7, 8, 9};
std::ranges::shuffle(dt, std::mt19937(std::random_device()()));
auto pos = std::ranges::find(dt.begin(), 
                             std::unreachable_sentinel,
                             7);
std::ranges::copy(dt.begin(), ++pos, 
                  std::ostream_iterator<int>(std::cout, ","));

std::unreachable_sentinel всегда возвращает false по сравнению с итератором. Поэтому компилятор оптимизирует проверку границ it != end, поскольку это выражение всегда истинно.

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

И, наконец, с введением концепции диапазона мы также можем сэкономить на написании и использовать варианты алгоритмов, принимающие диапазон.

std::vector<int> dt = {1, 4, 2, 3};
std::ranges::sort(dt);

Прогнозы

Огромная новая функция, которая на первый взгляд кажется тривиальной, - это поддержка проекций. Проекция - это унарный вызов, который применяется к каждому элементу.

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

struct Account {
    std::string owner;
    double value();
    double base();
};
std::vector<Account> acc = get_accounts();
// member
std::ranges::sort(acc,{},&Account::owner);
// member function
std::ranges::sort(acc,{},&Account::value);
// lambda
std::ranges::sort(acc,{},[](const auto& a) { 
    return a.value()+a.base(); 
});

Без прогнозов нам пришлось бы включить эту логику как часть настраиваемого компаратора.

std::vector<int> dt = { 1, 2, 3, 4, 5, 6, 7, 8, 9};
std::vector<int> result;
std::ranges::transform(dt, 
                       dt | std::views::reverse,
                       std::back_inserter(result),
                       std::minus<void>(),
                       [](int v) { return v*v; },
                       [](int v) { return v*v; });
std::ranges::copy(result, 
                  std::ostream_iterator<int>(std::cout, ","));

Это небольшое предзнаменование для представлений, но я хотел включить еще один пример, в котором в качестве входных данных использовались два диапазона. В таком случае мы получаем две отдельные проекции. Обратите внимание, что эти прогнозы также могут возвращать разные типы возвращаемых данных, если они соответствуют операции (здесь std::minus).

Мелочи

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

auto good = "1234567890";
auto sep1 = std::ranges::find(std::string_view(good), '0');
std::cout << *sep1 << "\n";
auto bad = 1234567890;
auto sep2 = std::ranges::find(std::to_string(bad), '0');
std::cout << *sep2 << "\n";

Вы можете узнать здесь проблему. Если бы мы не использовали диапазонные варианты алгоритмов, «плохой» вариант вылетал бы во время выполнения. Однако с диапазонами этот код не будет компилироваться. Когда алгоритм на основе диапазона вызывается с временным диапазоном, которому принадлежат его элементы, алгоритм возвращает специальный итератор std::ranges::dangling.

Обратите внимание, что первый вариант с std::string_view будет работать нормально. Строковое представление - это тип диапазона, который не владеет своими элементами, а его итераторы являются автономными (они не зависят от экземпляра string_view), поэтому вполне допустимо передать такое временное значение в алгоритм, основанный на диапазоне.

Чтобы ваши диапазоны работали как временные, вам необходимо специализировать константу enable_borrowed_range:

template<typename T>
inline constexpr bool 
    std::ranges::enable_borrowed_range<MyView<T>> = true;

Составные представления

Одна из основных проблем старых алгоритмов STL заключается в том, что их нелегко компоновать. В результате код, использующий алгоритмы, часто бывает довольно многословным и при работе с неизменяемыми данными требует дополнительных копий.

Представления пытаются решить эту проблему, делая код, основанный на стандартных алгоритмах, менее подробным и более явным.

Взгляды

Представления - это просто диапазоны, которые дешево копировать и перемещать (за постоянное время). Из-за этого представление не может владеть просматриваемыми элементами. Единственным исключением является std::views::single, которому принадлежит единственный просматриваемый элемент.

Представления составляются во время компиляции с ожиданием, что компилятор встроит код.

Например, следующий код распечатает последние три элемента диапазона. Сначала мы меняем диапазон, затем берем первые три элемента и, наконец, снова меняем диапазон (обратите внимание, что есть std::views::drop, который делает это напрямую).

namespace rv = std::ranges::views;
std::vector<int> dt = {1, 2, 3, 4, 5, 6, 7};
for (int v : rv::reverse(rv::take(rv::reverse(dt),3))) {
    std::cout << v << ", ";
}
std::cout << "\n";

Просмотр объектов закрытия

Из-за частой глубокой вложенности функциональный синтаксис составления представлений может быть громоздким для записи и чтения.

К счастью, диапазоны дают нам еще один подход к составлению представлений. Представления в пространстве имен std::views на самом деле являются объектами закрытия представления. Это встроенные константы constexpr с каждым std::ranges::xxx_view отображением на std::views::xxx объект. Эти объекты перегружают operator() для функционального синтаксиса, как показано выше, и operator| для компоновки в стиле конвейера.

namespace rv = std::ranges::views;
std::vector<int> dt = {1, 2, 3, 4, 5, 6, 7};
for (int v : dt | rv::reverse | rv::take(3) | rv::reverse) {
    std::cout << v << ", ";
}
std::cout << "\n";

Обратите внимание, что хотя представления не владеют своими элементами, они не изменяют изменчивость базовых данных. Здесь мы перебираем нечетные элементы массива и умножаем их на два.

namespace rv = std::ranges::views;
std::vector<int> dt = {1, 2, 3, 4, 5, 6, 7};
auto odd = [](std::integral auto v) { return v % 2 == 1; };
for (auto& v : dt | rv::filter(odd)) {
    v *= 2;
}

FizzBuzz тремя способами

Давайте посмотрим на несколько конкретных примеров диапазонов. Мы напишем три версии FizzBuzz:

  • генератор сопрограмм с ограниченным диапазоном
  • генеративный подход с использованием алгоритмов
  • композиционный подход с использованием представлений

Как упоминалось в начале статьи, текущей поддержки в C ++ 20 немного не хватает. Поэтому я буду полагаться на библиотеку range v3.

Генератор сопрограмм

Написание сопрограммы генератора FizzBuzz почти идентично типичной реализации:

ranges::experimental::generator<std::string> fizzbuzz() {
    for (int i = 1; ; i++) {
        std::string result;
        if (i % 3 == 0) result += "Fizz";
        if (i % 5 == 0) result += "Buzz";
        if (result.empty()) co_yield std::to_string(i);
        else co_yield result;
    }
}

Однако, если мы используем библиотеку generator<> from range v3, мы также можем использовать вызванную сопрограмму в качестве диапазона.

for (auto s : fizzbuzz() | ranges::views::take(20)) {
    std::cout << s << "\n";
}

Основная магия здесь заключается в реализации типа итератора (обратите внимание, этот код не из библиотеки range v3).

// Resume coroutine to generate new value.
void operator++() { 
    coro_.resume(); 
}
// Grab current value from coroutine.
const T& operator*() const {
    return *coro_.promise().current_value;
}
// We are at the end if the coroutine is finished.
bool operator==(std::default_sentinel_t) const { 
    return !coro_ || coro_.done(); 
}

std::default_sentinel_t - это удобный тип, предусмотренный стандартом, предназначенный для использования для различения сравнений с end(). При этом нам просто нужно вернуть этот итератор из возвращаемого типа generator<>:

Iter begin() {
    if (coro_) {
        coro_.resume();
    } 
    return Iter{cor_};
}
std::default_sentinel_t end() { 
    return {}; 
}

Генерация с использованием алгоритмов

У нас есть несколько вариантов генеративного подхода, наиболее очевидным из которых является generate_n, который позволит нам напрямую генерировать выходные данные.

ranges::generate_n(
    std::ostream_iterator<std::string>(std::cout, "\n"), 
    20,
    [i = 0]() mutable {
        i++;
        std::string result;
        if (i % 3 == 0) result += "Fizz";
        if (i % 5 == 0) result += "Buzz";
        if (result.empty()) return std::to_string(i);
        return result;
});

Композиция с использованием представлений

Оба предыдущих подхода очень похожи. Оба они реализуют FizzBuzz процедурно. Однако мы также можем реализовать FizzBuzz совершенно другим способом.

FizzBuzz включает два цикла. Fizz с периодом три и Buzz с периодом пять.

std::array<std::string, 3> fizz{"", "", "Fizz"};
std::array<std::string, 5> buzz{"", "", "", "", "Buzz"};

Во-первых, нам нужно превратить эти циклы в бесконечные диапазоны.

const auto inf_fizz = fizz | ranges::views::cycle;
const auto inf_buzz = buzz | ranges::views::cycle;

Затем мы можем объединить их, используя zip_with:

const auto inf_fizzbuzz = ranges::views::zip_with(
    std::plus<>(), 
    inf_fizz, 
    inf_buzz);

Теперь у нас есть бесконечный диапазон, в котором каждый 3-й элемент - это «Fizz», каждый 5-й элемент - «Buzz», каждый 15-й элемент - «FizzBuzz», а остальные - пустые строки.

Нам не хватает простых номеров для элементов, которые не являются Fizz of Buzz. Итак, давайте построим бесконечный диапазон индексов (начиная с одного):

const auto indices = ranges::views::indices
    | ranges::views::drop(1);

И, наконец, нам нужно соединить эти два диапазона и вывести окончательный результат.

const auto final_range = ranges::views::zip_with(
    [](auto i, auto s) { 
        if (s.empty()) return std::to_string(i); 
        return s;
    },
    indices,
    inf_fizzbuzz
);
ranges::copy_n(ranges::begin(final_range), 20,
    std::ostream_iterator<std::string>(std::cout, "\n"));

Ссылки и технические примечания

Все примеры кода и скрипты доступны по адресу: https://github.com/HappyCerberus/article-cpp20-ranges.

Библиотека range v3, используемая для примеров FizzBuzz, доступна по адресу: https://github.com/ericniebler/range-v3.

Спасибо за чтение

Спасибо, что прочитали эту статью. Вам понравилось?

Также публикую видео на YouTube. У тебя есть вопросы? Напишите мне в Twitter или LinkedIn.