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

Посмотрим, как!

На пути к программированию необработанных петель

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

Нередко можно увидеть что-то подобное в кодовой базе.

int findMax(const std::vector<int>& numbers){
    auto maximum = -1;
    for(auto number : numbers){
        if(number > maximum){
            maximum = number;
        }
    }
    return maximum;
}

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

Теперь, конечно, можно исправить это, чтобы заставить его работать, или мы могли бы также сказать, что в нашем конкретном случае нас интересуют только положительные числа, но вопрос в том, почему мы заново реализуем колесо?

Алгоритм поиска максимального элемента в контейнере уже реализован и доступен в стандартной библиотеке. Бьюсь об заклад, никто не стал бы переписывать алгоритм сортировки с нуля (хотя никогда не знаешь заранее), но вместо этого я видел множество начинающих программистов, пишущих алгоритм для нахождения максимального значения контейнера с нуля. Если вы делаете это, вы ставите себя в ситуацию потенциальных ошибок, так почему бы не использовать STL, то, что уже проверено и используется почти везде?

В этом случае наша функция просто станет

auto max_element = std::max_element(numbers.begin(), numbers.end());

std::max_element возвращает итератор, указывающий на максимальный элемент контейнера. Это просто работает для каждого типа, и его можно легко адаптировать для работы и с пользовательскими типами. Алгоритм уже протестирован и все работает прямо из коробки.

Появление <algorithm> привело к идее стремления к стилю программирования без необработанных циклов. Эта идея была популяризирована Шоном Пэрентом в известном выступлении о C++ Seasoning в 2013 году (ссылка в разделе ресурсов), и я думаю, что это помогает программистам осознавать уровень абстракции его программы, заставляя их думать о длине for-loop и, в целом, помогает им писать лучший код. Если вы хотите узнать больше об этой теме, я настоятельно рекомендую вам дать шанс его видео.

Наиболее частые возражения против этого:

А как насчет производительности? Я плачу за это какие-то накладные расходы?

Что ж, я настоятельно рекомендую вам посмотреть выступление Шона, но вкратце ответ — нет (или, по крайней мере, да, но это не имеет значения). Если вы измерите два фрагмента кода на разных компиляторах с помощью Google Benchmark, результаты в основном будут одинаковыми с точки зрения процессорного времени. Результат можно увидеть здесь

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

Но есть еще одно возражение, которое на самом деле имеет смысл, а именно тот факт, что алгоритмы STL несколько многословны и их трудно комбинировать. Это правда, позвольте мне показать вам быстрый пример.

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

Перед диапазонами сделать это невозможно, и поэтому мы должны сделать что-то вроде:

// positive numbers
std::vector<int> numbers = {1, 2, 3, 4, 5, 6, 7};
// we need a subset of even numbers of our vector
// but the only way to have a subset is to copy the vector
auto even_numbers = std::vector<int>{};
std::copy_if(numbers.begin(), numbers.end(), std::back_inserter(even_numbers), [](int value){
    return value % 2 == 0;
});
auto max_element = std::max_element(even_numbers.begin(), even_numbers.end()); // 6

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

Значит, в таком случае мы должны отказаться от алгоритмов? Ну нет! Даже если у вас нет диапазонов, вы можете кое-что сделать (например, определить свой собственный общий шаблонный алгоритм, который делает это), но диапазоны могут вам помочь :)

Взгляды

Представления — это приятные абстракции, которые позволяют нам выполнять ленивые вычисления с помощью алгоритмов. В качестве побочного эффекта это позволяет реализовать лучшую абстракцию кода и красивую композицию представлений без циклов for, даже сложные алгоритмы!

Давайте сделаем шаг назад: что создает представление?

Эрик Ниблер, член стандартного комитета и автор одной из лучших реализаций диапазонов (см. раздел ресурсов в конце), определяет представление как таковое:

"Представления — это компонуемые адаптации диапазонов, где адаптация происходит лениво по мере повторения представления".

Давая более формальное определение, представление имеет следующие характеристики:

  • Не владеет своими элементами
  • Все* его методы являются O(1), т. е. вы можете копировать, перемещать и назначать представления бесплатно.
  • Это позволяет выполнять ленивую оценку, потому что фактические операции выполняются только на итерации.

*это не совсем так в особых случаях, но в большинстве случаев это так, особенно для целей, о которых мы говорим в этой статье

Что это означает конкретно? Что ж, это означает, что мы можем комбинировать представления для некоторых элементов диапазона, фактически ничего не делая на шаге объявления (помните: все стоит O(1)). Следовательно, мы можем напрямую воздействовать на количество элементов, на которых будет работать алгоритм, применяя последующие условия к этим элементам и ограничивая значения, на которых будет работать наш алгоритм. Все еще не видите потенциал?

Давайте попробуем решить нашу задачу max_even_number:

// the input vector (i.e. the ranges)
std::vector<int> numbers = {1, 2, 3, 4, 5, 6, 7, 8};
// the condition I want to filter my elements on (as a lambda)
auto is_even = [](int const n) {return n % 2 == 0;};
// the view on even numbers in the input vector
auto even_numbers_view = std::ranges::views::filter(numbers, is_even);

std::ranges::views::filter дает нам представление о векторе, который содержит только четные числа. Что мы можем сделать теперь с этим представлением? Ну почти все!

Если вы не знакомы с алгоритмом STL, вы можете перебрать его с помощью цикла for!

for(auto even_number : even_numbers_view){
    std::cout << even_number << std::endl; // 2, 4, 6, 8
}

Но если вы, как и я, занимаетесь программированием без цикла, вы также можете вызывать на нем алгоритмы!

auto max = std::ranges::max_element(even_numbers_view); // 8

Но я же обещал тебе простую комбинацию, верно? Как можно комбинировать этот (на первый взгляд) длинный синтаксис? Что ж, оказывается, вы можете использовать оператор конвейера, чтобы сделать что-то вроде:

auto last_two_even_elements = numbers | rv::filter(is_even) | rv::reverse | rv::take(2); // 6, 4
auto [minimum_number, maximum_number] = rs::minmax(last_two_even_elements); // 4 and 6

Где rv:: — это ярлык для std::ranges::view, а rs:: — это ярлык для std::ranges для простоты.

В этом примере мы комбинируем разные взгляды на числа:

  • с rv::filter(is_even) мы берем только четные числа из numbers
  • с rv::reverse мы меняем порядок просмотра на обратный
  • с rv::take(2) мы берем только два последних элемента в обратном порядке (6 и 4)
  • наконец, мы вызываем алгоритм minmax и получаем минимум и максимум элементов диапазона, указанных этим представлением last_two_even_elements

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

Это может зайти так далеко, как вам нравится:

auto is_even = [](auto number) {return number % 2 == 0; };
auto to_pow = [](auto number) {return number*number;};
auto complex_view = rs::iota_view(1, 11) // 1, 2, 3, 4, 5, 6, 7, 8, 9, 10
                                   | rv::reverse // 10, 9, 8, 7 , 6, 5, 4, 3, 2, 1
                                   | rv::filter(is_even) // 10, 8, 6, 4, 2
                                   | rv::transform(to_pow) // 100, 64, 36, 16, 4
                                   | rv::take(3); // 100, 64, 36
                                // | rs::std::vector<int> pows{}; // not currently in the standard library (but in ranges::v3)
std::vector<int> pows{};
rs::copy(complex_view, std::back_inserter(pows));

Обратите внимание, что в конце концов вы можете скопировать элементы, указанные вашими представлениями, в вектор, используя rs::copy (или даже с оператором конвейера, если вы используете ranges::v3 implementation).

Выводы и ресурсы

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

Большое оговорка: сегодня я только что коснулся подхода без необработанных петель, и я определенно собираюсь написать что-то, посвященное только этой теме, но я хочу сказать, в качестве последнего замечания, что я рассматриваю этот подход не как строгий. правило, а как хороший принцип, который я стараюсь использовать в своем коде. Это означает, что я не отключаю автоматически свой мозг и не пытаюсь везде расставить алгоритмы, а сначала пытаюсь спросить себя, не является ли цикл for, который я пишу, просто замаскированным алгоритмом и могу ли я сэкономить часть своего времени и энергии. используя это вместо моей пользовательской реализации.

Это первая из серии статей, которые я хочу написать о std::ranges, потому что их возможности и потенциал выходят далеко за рамки просмотров, и я думаю, что они заслуживают особого внимания.

Если вам интересно узнать о возможностях диапазонов, вы можете проверить один из следующих ресурсов:

  • Godbolt — онлайн-компилятор, поддерживающий диапазоны C++20, поэтому вам не нужно ничего устанавливать на свою машину, и вы можете начать развлекаться с диапазонами :)
  • Обзор стандартных диапазонов — Тристан Бриндл впервые рассказал о диапазонах на CppCon2019, это хорошо, если вы ищете ненавязчивое введение в диапазоны.
  • От STL к диапазонам — эффективное использование диапазонов от Джеффа Гарланда на CppCon2019, немного более продвинутое, но все же годное для ознакомления.
  • ranges library — официальная документация по диапазонам на cpp-reference
  • range-v3 — реализация диапазонов от Эрика Ниблера, если вы хотите включить диапазоны в свой проект, но не можете получить доступ к компилятору C++20.