Библиотека Ranges для C++20 предлагает альтернативы для большинства алгоритмов. На этот раз я хочу показать вам десять немодифицирующих операций. Мы сравним их со «старой» стандартной версией и увидим их преимущества и ограничения.
Подробности — к старту нашего курса по разработке на C++.
Прежде чем мы начнём
Ключевые наблюдения для алгоритмов std::ranges
:
- Алгоритмы диапазонов определяются в заголовке
<algorithm>
, а инфраструктура диапазонов и основные типы данных определяются в заголовочном файле<ranges>
. - Обычно существует как минимум две перегрузки алгоритмов диапазона: с парой итераторов и перегрузка с одним аргументом диапазона.
- Версия, которая возвращает поддиапазон (subrange) или итератор и принимает диапазон, возвращает заимствованный диапазон или заимствованный итератор. Это помогает обнаруживать итераторы для временных диапазонов.
- Версии диапазона имеют «проекции», что может дать больше гибкости; например, можно выполнить сортировку по некоторым выбранным элементам или выполнить дополнительные преобразования перед сравнением.
- См. мою отдельную статью об этой функции с мощным потенциалом: C++20 Ranges, Projections, std::invoke и if constexpr — C++ Stories
- В версии с диапазонами нет опции параллельного выполнения (нельзя передать политику выполнения —
std::execution
). - Алгоритмы диапазонов, как и стандартные алгоритмы C++20, также являются
constexpr
. - Начиная с C++20, числовые алгоритмы диапазонов, соответствующие заголовку
<numeric>
, отсутствуют.
Ниже приведены примеры, демонстрирующие как стандартный алгоритм, так и его альтернативную версию с диапазонами. Они иллюстрируют некоторые базовые концепции, в них заложено стремление не использовать расширенную композицию диапазонов или представления. Мы воспользуемся порядком, указанным в cppreference/algorithms, и рассмотрим в этой части «немодифицирующие операции с последовательностями».
1. all_of
, any_of
, none_of
Стандартный алгоритм:
#include <algorithm>
#include <vector>
#include <iostream>
#include <ranges>
int main() {
const std::vector nums = {1, 2, 3, -4, 5, 6, 7, 8 };
auto is_positive = [](const auto& v) { return v > 0; };
// standard version:
auto res = std::all_of(begin(nums), end(nums), is_positive);
std::cout << "std::all_of: " << res << '\n';
res = std::any_of(begin(nums), end(nums), is_positive);
std::cout << "std::any_of: " << res << '\n';
}
И версия с диапазонами:
// ranges version:
res = std::ranges::all_of(nums, is_positive);
std::cout << "std::ranges::all_of: " << res << '\n';
res = std::ranges::any_of(nums, is_positive);
std::cout << "std::ranges::any_of: " << res << '\n';
Запустить в @Compiler Explorer
Можно написать пример сложнее, где сканируется контейнер пользовательских типов:
#include <algorithm>
#include <vector>
#include <iostream>
#include <ranges>
struct Product {
std::string name_;
double value_ { 0.0 };
};
int main() {
const std::vector<Product> prods {
{ "box", 10.0 }, {"tv", 100.0}, {"none", -1.0}
};
auto is_positive = [](const auto& v) { return v > 0; };
auto is_positive_val = [](const Product& p) {
return p.value_ > 0;
};
// standard version:
auto res = std::all_of(begin(prods), end(prods), is_positive_val);
std::cout << "std::all_of: " << res << '\n';
res = std::any_of(begin(prods), end(prods), is_positive_val);
std::cout << "std::any_of: " << res << '\n';
// ranges version:
res = std::ranges::all_of(prods, is_positive, &Product::value_);
std::cout << "std::ranges::all_of: " << res << '\n';
res = std::ranges::any_of(prods, is_positive, &Product::value_);
std::cout << "std::ranges::any_of: " << res << '\n';
}
Запустить @Compiler Explorer
В версии с диапазонами мы по-прежнему можем использовать общий предикат is_positive
, но я использовал проекцию, которая только «берёт» Product::value_
и передаёт его в предикат. В стандартном случае пришлось бы написать пользовательскую лямбда-функцию с учётом типа Product
.
2. for_each
Альтернатива хорошему диапазону на основе цикла for:
#include <algorithm>
#include <vector>
#include <iostream>
#include <ranges>
struct Product {
std::string name_;
double value_ { 0.0 };
};
int main() {
const std::vector<Product> prods {
{ "box", 10.0 }, {"tv", 100.0}, {"none", -1.0}
};
auto out = [](const auto& v) { std::cout << v << ", "; };
// standard version:
std::cout << "std::for_each: \n";
std::for_each(begin(prods), end(prods), [](const Product& p){
std::cout << p.name_ << ", " << p.value_ << '\n';
});
std::cout << "std::for_each only names reverse: \n";
std::for_each(rbegin(prods), rend(prods), [](const Product& p){
std::cout << p.name_ << '\n';
});
// ranges version:
std::cout << "std::ranges::for_each: \n";
std::ranges::for_each(prods, [](const Product& p) {
std::cout << p.name_ << ", " << p.value_ << '\n';
});
std::cout << "std::ranges::for_each only names in reverse: \n";
std::ranges::for_each(prods | std::views::reverse,
out, &Product::name_);
}
Запустить в @Compiler Explorer
Самое интересное заключается в том, что для печати в обратном порядке в стандартной версии требуется итераторы rbegin/rend
, а затем пользовательская унарная функцию для вывода точного члена данных из класса Product
. В то время как с диапазонами можно применить views::reverse
, использовать простую функцию вывода, а затем проекцию.
Чего не хватает, так это параллельной версии алгоритмов диапазонов:
// standard:
std::for_each(std::execution::par, begin(prods), end(prods), /*...*/);
// no ranges version...
// std::ranges::for_each(std::execution::par, prods, /*... */); // doesn't compile...
Параллельные версии отсутствуют для алгоритмов всех диапазонов, а не только для for_each
.
3. count_if
Ниже посчитаем товары, название которых начинается с "no":
#include <algorithm>
#include <vector>
#include <iostream>
#include <ranges>
struct Product {
std::string name_;
double value_ { 0.0 };
};
int main() {
const std::vector<Product> prods {
{ "box", 10.0 }, {"tv", 100.0}, {"none", -1.0},
{ "car", 1000.0 }, {"toy", 40.0}, {"none", 0.0}
};
// standard version:
auto res = std::count_if(begin(prods), end(prods), [](const Product& p){
return p.name_.starts_with("no");
});
std::cout << "std::count_if: " << res << '\n';
// ranges version:
res = std::ranges::count_if(prods, [](const Product& p) {
return p.name_.starts_with("no");
});
std::cout << "std::ranges::count_if: " << res << '\n';
// alternative version for "none":
res = std::ranges::count(prods, std::string{"none"}, &Product::name_);
std::cout << "std::ranges::count: " << res << '\n';
}
Запустить в @Compiler Explorer
Выше показаны три подхода, последний использует проекцию проверки только члена данных Product::name_
. При таком подходе мы ищем именно "none"
, это строже, чем starts_with
.
4. find_if
До сих пор наши текстовые алгоритмы возвращали логические или целочисленные значения, но в случае с функциями find*
поставляются итераторы (или поддиапазоны), которые показывают то же вхождение:
#include <algorithm>
#include <vector>
#include <iostream>
#include <ranges>
struct Product {
std::string name_;
double value_ { 0.0 };
};
int main() {
const std::vector<Product> prods {
{ "box", 10.0 }, {"tv", 100.0}, {"rocket", 10000.0},
{ "car", 1000.0 }, {"toy", 40.0}, {"none", 0.0}
};
// standard version:
auto it = std::find_if(begin(prods), end(prods), [](const Product& p){
return p.name_.starts_with("ro");
});
if (it != end(prods))
std::cout << "std::find_if: " << it->name_ << '\n';
// ranges version:
auto res = std::ranges::find_if(prods, [](const Product& p) {
return p.name_.starts_with("ro");
});
if (res != end(prods))
std::cout << "std::ranges::find_if: " << res->name_ << '\n';
}
Запустить в @Compiler Explorer
Как и во многих других алгоритмах, существует также «обычная» версия, в которой можно передать два итератора:
it = std::ranges::find_if(begin(prods), end(prods), [](const Product& p) {
return p.name_.starts_with("ro");
});
Версия, которая принимает один диапазон, особенная, ведь она возвращает заимствованные (borrowed) итераторы. Этот специальный тип позволяет проверять наличие временных/постоянных проблем с объектами. Это невозможно, когда вы передаёте два итератора (потому что контейнер где-то присутствует), но возможно с одним временным диапазоном:
struct Product {
std::string name_;
double value_ { 0.0 };
};
std::vector<Product> GetProds() {
return {
{ "box", 10.0 }, {"tv", 100.0}, {"rocket", 10000.0},
{ "car", 1000.0 }, {"toy", 40.0}, {"none", 0.0}
};
}
int main() {
auto it = std::ranges::find_if(GetProds(), [](const Product& p) {
return p.name_.starts_with("ro");
});
std::cout << "std::ranges::find_if: " << it->name_ << '\n';
}
Это не скомпилируется, и вы увидите следующую ошибку:
error: base operand of '->' has non-pointer type 'std::ranges::dangling'
22 | std::cout << "std::ranges::find_if: " << it->name_ << '\n';
| ^~
Как видите, компилятор проверил, что GetProds()
возвращает временное значение, и найденный нами итератор повиснет.
Смотрите код в @Compiler Explorer
5. find_first_of
Давайте взглянем на альтернативу find*
, которая ищет несколько элементов одновременно.
#include <algorithm>
#include <vector>
#include <iostream>
#include <ranges>
struct Product {
std::string name_;
double value_ { 0.0 };
friend bool operator==(const Product& a, const Product& b) {
return a.name_ == b.name_ && abs(a.value_ - b.value_) < 0.0001;
}
};
int main() {
const std::vector<Product> prods {
{ "box", 10.0 }, {"default", 0.0 }, {"tv", 100.0}, {"rocket", 10000.0},
{ "car", 1000.0 }, {"toy", 40.0}, {"none", 0.0 }, { "ball", 40.0 }
};
const std::vector<Product> invalids {
{"default", 0.0 }, {"none", 0.0 }
};
// standard version:
auto it = std::find_first_of(begin(prods), end(prods), begin(invalids), end(invalids));
if (it != end(prods)) {
std::cout << "std::find_first_of: " << it->name_ << " at: "
<< std::distance(begin(prods), it) <<'\n';
auto it2 = std::find_first_of(std::next(it), end(prods), begin(invalids), end(invalids));
if (it2 != end(prods))
std::cout << "std::find_first_of: " << it2->name_ << " at: "
<< std::distance(begin(prods), it2) <<'\n';
}
// ranges version:
const std::array<std::string, 2> arrInvalids{"default", "none"};
auto res = std::ranges::find_first_of(prods, arrInvalids,
std::ranges::equal_to{}, &Product::name_);
if (res != end(prods)) {
const auto pos = std::distance(begin(prods), res);
std::cout << "std::ranges::find_first_of: " << res->name_
<< " at: " << pos <<'\n';
auto res2 = std::ranges::find_first_of(prods | std::views::drop(pos+1), arrInvalids,
std::ranges::equal_to{}, &Product::name_);
if (res2 != end(prods)) {
std::cout << "std::ranges::find_first_of: " << res2->name_
<< " at: " << std::distance(begin(prods), res2) <<'\n';
}
}
}
Запустить в @Compiler Explorer
std::find_first_of
принимает две пары итераторов. В этом примере я хотел найти «невалидные» товары в последовательности prod
. Я сравниваю товары, поэтому для структуры пришлось определить operator==
. А вот другой подход: я могу предоставить бинарную операцию, а затем сравнить только имена:
auto cmpNames = [](const Product& a, const Product& b) {
return a.name_ == b.name_;
};
auto it = std::find_first_of(begin(prods), end(prods),
begin(invalids), end(invalids), cmpNames);
if (it != end(prods)) {
// ...
}
Чтобы добиться того же самого, в версии c диапазонами я могу использовать проекции и компаратор по умолчанию:
const std::array<std::string, 2> arrInvalids{"default", "none"};
auto res = std::ranges::find_first_of(prods, arrInvalids,
std::ranges::equal_to{}, &Product::name_);
Далее интересно то, что для второго поиска можно воспользоваться drop
, чтобы пропустить первые n
элементов из диапазона:
auto res2 = std::ranges::find_first_of(prods | std::views::drop(pos+1),
arrInvalids, std::ranges::equal_to{}, &Product::name_);
Решить эту задачу можно и при помощи версии с двумя парами итераторов:
auto res2 = std::ranges::find_first_of(std::next(res), end(prods),
begin(arrInvalids), end(arrInvalids),
std::ranges::equal_to{}, &Product::name_);
6. mismatch
С помощью алгоритма mismatch
можно найти первое место, где два диапазона различаются:
#include <algorithm>
#include <vector>
#include <iostream>
#include <ranges>
#include <iomanip> // quoted
int main() {
const std::string firstStr = "Hello Super World";
const std::string secondStr = "Hello Amazing World";
std::cout << "mismatch for " << std::quoted(firstStr)
<< " and " << std::quoted(secondStr) << '\n';
// standard version:
auto [first, second] = std::mismatch(begin(firstStr), end(firstStr), begin(secondStr));
{
const auto pos = std::distance(begin(firstStr), first);
std::cout << "std::mismatch: at pos " << pos << '\n';
}
// ranges version:
auto res = std::ranges::mismatch(firstStr, secondStr);
{
const auto pos = std::distance(begin(firstStr), res.in1);
std::cout << "std::ranges::mismatch: at pos " << pos << '\n';
}
}
Запустить в @Compiler Explorer
Версия с диапазонами возвращает:
template<class I1, class I2>
using mismatch_result = ranges::in_in_result<I1, I2>;
Это пара итераторов, но получить к ним доступ можно через .in1
и .in2
.
А почему не простой диапазон? По ссылке cpp увидим такое предложение:
В отличие отstd::pair
иstd::tuple
, этот шаблон класса имеет элементы данных со значимыми именами.
Результат отлично работает со структурированной привязкой (структурированным биндингом), поэтому можно написать так:
auto [n1, n2] = std::ranges::mismatch(firstStr, secondStr);
const auto pos = std::distance(begin(firstStr), n1);
std::cout << "std::ranges::mismatch: at pos " << pos << '\n';
Код почти такой же, как и в стандартной версии.
7. search
Поиск шаблонов в другом диапазоне/контейнере:
#include <algorithm>
#include <vector>
#include <iostream>
#include <ranges>
#include <functional> // searchers
#include <iomanip>
int main() {
const std::string testString = "Hello Super World";
const std::string needle = "Super";
std::cout << "looking for " << std::quoted(needle)
<< " in " << std::quoted(testString) << '\n';
// standard version:
auto it = std::search(testString.begin(), testString.end(),
std::boyer_moore_searcher(needle.begin(), needle.end()));
if (it != testString.end()) {
const auto pos = std::distance(testString.begin(), it);
std::cout << "std::search: found at pos " << pos << '\n';
}
// ranges version:
auto res = std::ranges::search(testString, needle);
if (!res.empty()) {
const auto first = std::distance(testString.begin(), res.begin());
const auto last = std::distance(testString.begin(), res.end());
std::cout << "std::ranges::search: found between "
<< first << " and " << last << '\n';
}
}
Запустить в @Compiler Explorer
Стандартная версия возвращает итератор к первой строке к месту, где начинается вторая строка (или к end()
, если там её нет). Версия диапазонов возвращает поддиапазон (или borrowed_subrange
).
Воспользоваться проекциями можно и для проверки без учёта регистра:
// ranges version:
const std::string testString2 = "hello abc world";
const std::string needle2 = "ABC";
std::cout << "looking for " << std::quoted(needle2) << " in "
<< std::quoted(testString2) << '\n';
res = std::ranges::search(testString2, needle2,
std::ranges::equal_to{}, ::toupper, ::toupper);
if (!res.empty())
{
const auto first = std::distance(testString2.begin(), res.begin());
const auto last = std::distance(testString2.begin(), res.end());
std::cout << "std::ranges::search: found between "
<< first << " and " << last << '\n';
}
Запустить в @Compiler Explorer
Подробнее о поиске можно прочитать в этих двух моих статьях:
- Speeding up Pattern Searches with Boyer-Moore Algorithm from C++17 — C++ Stories
- Preprocessing Phase for C++17’s Searchers — C++ Stories
Другая функция ranges::search_n
удобна для поиска n
вхождений заданного значения во входном диапазоне:
#include <algorithm>
#include <iostream>
#include <ranges>
#include <iomanip>
int main() {
const std::string sequence = "CTGCCCAGGGTTT";
const char letter = 'C';
const size_t count = 3;
std::cout << "looking for " << count << " "
<< letter << "'s in " << std::quoted(sequence) << '\n';
// standard version:
auto it = std::search_n(begin(sequence), end(sequence), count, letter);
if (it != end(sequence))
{
const auto pos = std::distance(begin(sequence), it);
std::cout << "std::search_n: found at pos " << pos << '\n';
}
// ranges version:
auto res = std::ranges::search_n(sequence, count, letter);
if (!res.empty())
{
const auto first = std::distance(begin(sequence), res.begin());
const auto last = std::distance(begin(sequence), res.end());
std::cout << "std::ranges::search_n: found between "
<< first << " and " << last << '\n';
}
}
Запустить в @Compiler Explorer
В стандартной версии нет специальных поисковиков; вызвать поиск можно только с использованием параллельных алгоритмов.
Заключение
Мы рассмотрели семь различных «типов» алгоритмов в категории немодифицирующих операций: проверка некоторого предиката для всех/ни одного/некоторых элементов, поиск, нахождение, общая итерация. Всего более 10 примеров.
Алгоритмы диапазонов предлагают упрощённый способ передачи «целого» контейнера — всего один аргумент, а не как с итераторами. Они также допускают проекции, в них предусмотрен способ обнаружения итераторов во временном диапазоне. У них есть ограничения, такие как отсутствие расширенных поисковиков или режима параллельного выполнения.
Научим вас аккуратно работать с данными, чтобы вы прокачали карьеру и стали востребованным IT-специалистом. Скидки до 50% по промокоду HABR.
Data Science и Machine Learning
- Профессия Data Scientist
- Профессия Data Analyst
- Курс «Математика для Data Science»
- Курс «Математика и Machine Learning для Data Science»
- Курс по Data Engineering
- Курс «Machine Learning и Deep Learning»
- Курс по Machine Learning
Python, веб-разработка
- Профессия Fullstack-разработчик на Python
- Курс «Python для веб-разработки»
- Профессия Frontend-разработчик
- Профессия Веб-разработчик
Мобильная разработка
Java и C#
- Профессия Java-разработчик
- Профессия QA-инженер на JAVA
- Профессия C#-разработчик
- Профессия Разработчик игр на Unity
От основ — в глубину
А также
Комментарии (29)
MUTbKA98
21.12.2022 10:43+1Я тоже эстетически страдаю, когда вижу, как впихивают в C++ уже совсем невпихуемое.
Все на свете устаревает и выходит из употребления, и придется расставаться так или иначе - ну так может уже пора? Однажды надо прекратить подставлять костыли, и сделать мощный рефактор (или взять один из имеющихся). Но чтобы дать ему свободную дорогу - этот С++ должен уйти.
Взять какой-нибудь Фортран - в него ж не пытаются засунуть все эти новомодные фичи почему-то.
domix32
21.12.2022 12:55Ну, плюсы ещё долго хоронить придётся, а быть модным-молодёжным хочется уже сейчас. 21 стандарт по идее и был мощным рефактором с его ренжами и вьюхами, правда к запланированному концу сессии не всё пропозалы успели дойти и что-то уйдёт в 23 стандарт. Ну и имплементации для стандартов тоже не быстро делаются, а те кто пишет на c++ в основном хочет, чтобы была кроссплатформа. Разные компиляторы поддерживают разные куски стандарта. Как итог - дорога свободна, но это мост над пропастью с дырками.
valeramikhaylovsky
21.12.2022 12:47+2Не знаю, чем вам не угодили диапазоны, меня они периодически выручают, например как-то была задача:
Нужно из массива удалить все элементы удовлетворяющие какому-то условию (в моём случае - времени жизни), затем убрать дубликаты и получить не более 50 первых элементов для отображения в GUI, а остальные убить.
На диапазонах это выглядело так:m_spots |= ranges::actions::sort | ranges::actions::unique | ranges::actions::take(MaxCount);
Ну как бы одна строчка и столько пользы!
KanuTaH
21.12.2022 13:16+4Статья просто бесполезная, вместо того, чтобы демонстрировать то, как конструкции ranges работают друг с другом, что такое views и чем они выгодны, и так далее, статья на 99% состоит из "вот std::find_if, а вот его аналог std::ranges::find_if", и действительно у неподготовленного читателя появляются закономерные вопросы "а в чем разница-то?"
Kelbon
21.12.2022 15:30ну кажется вы потеряли тут перфоманс.
Это выражется вauto it = std::remove_if(begin(arr), end(arr), pred); MaxCount = std::min(std::distance(begin(arr), it), MaxCount); std::partial_sort(begin(arr), std::next(begin(arr), MaxCount), it); arr.erase(it, end(arr));
Или как-то так
domix32
21.12.2022 22:19Оно у вас дубликаты не убирает, поэтому без
sort
|unique
не обойтись.wataru
22.12.2022 15:32+1Но, если пожертвовать однострочностью и краткостью ranges, то с использованием std::set, можно соптимизировать это с O(n log n) до O(n log MaxCount). Не на порядок, конечно, но в 2 раза эффективнее для 10000 элементов будет.
ReadOnlySadUser
23.12.2022 14:19При условии, что повторяющихся элементов немного, то в среднем мы вообще скорее всего получим O(MaxCount)
wataru
23.12.2022 14:52Не понял. Где получим? Как?
ReadOnlySadUser
23.12.2022 15:18Ну, если я правильно понял, то мы отказываемся от сортировки и просто линейно проходим по массиву, проверяя есть ли такой элемент в std::set. Соответственно и получаем N log MaxCount.
Однако, если повторяющихся элементов мало, мы будем просматривать первые MaxCount элементов и завершать цикл. Тут я конечно ошибся, ведь сложность будет O ( MacCount log MaxCount ).
Понятное дело, что в худшем случае мы всё равно просмотрим все N элементов, но я рассуждаю именно об амортизированном случае когда повторяющихся элементов "мало". Расчетами для конкретизации "мало" мне лень заниматься.
wataru
23.12.2022 15:25Ну set используется не только для проверки, что элемент новый но и для проверки, что этот новый элемент входит в MaxCount минимальных (сравниваем и вытесняем максимальный элемент из set).
Однако, если повторяющихся элементов мало, мы будем просматривать первые MaxCount элементов и завершать цикл. Тут я конечно ошибся, ведь сложность будет O ( MacCount log MaxCount ).
Вы, видимо, хотите набрать первые MaxCount уникальных элементов. Тогда, да, можно выйти заранее, не рассмотрев все элементы. Но это не совсем то же, что и изначально в этом треде. Ибо там берутся минимальные уникальные элементы.
Но в вашем варианте подойдет и хеш таблица, тогда в лучшем случае будет O(MaxCount), в худшем O(n).
valeramikhaylovsky
22.12.2022 08:25Смысл диапазонов, как по мне, это краткость и надёжность, а самое главное - комбинирование алгоритмов без дополнительных аллокаций в виде промежуточных буферов. Меня они периодически выручают. Есть шикарная книга по функциональному программированию на С++ от Ивана Чукича. Там очень хорошо описано применение диапазонов на практике.
DeepFakescovery
21.12.2022 16:47Господи, что стало с C++. Без грусти я не могу на это смотреть. Надеюсь Rust не постигнет та же участь.
KanuTaH
21.12.2022 18:19-2Особенно сильно "не могут на это смотреть" писатели на питончике, которые все равно толком не знакомы ни с тем, ни с другим :)
ReadOnlySadUser
Много лет пишу на С++, но каждый раз грущу когда вижу всё вот это синтаксическое уродливое безумие, которое необходимо совершить, чтобы сделать простейшие вещи.
Что касаемо приведённых примеров, никакой существенной разницы между классическим подходом и ranges не увидел. Тут прям теорема Эскобара в действии.
Kelbon
потому что это блог компании skillfactory с кривым переводом. Потому и не увидели
ReadOnlySadUser
Другие примеры с ranges выглядят еще более монструозно)
domix32
там из всех плюсов только избавление от begin/end и возможность комбинировать алгоритмы через | вместо миллиона вложенностей и скобок. Но в статье тех плюсов так и не показали толком, да ещё и намонстрячили в рэнжовые же примеры копипасты.
Kelbon
Ещё разные типы итератора и сентинела, изменения некоторых алгоритмов и специализации алгоритмов для конкретных типов например std::erase_if