Возможности С++: от стандартных алгоритмов до диапазонов (Ranges)
Привет, Хабр! Меня зовут Николай, я разработчик С++ в SimbirSoft. В предыдущей статье мы с вами рассмотрели применение стандартных алгоритмов в повседневном коде и их преимущества над обычными циклами. В продолжение этой темы мне хотелось бы рассказать о недостатках стандартных алгоритмов и способах их решения с помощью библиотеки Ranges. Практические примеры я разбил на три части: в первой показаны обычные циклы, во второй — вариант написания с помощью алгоритмов (но не всегда можно это сделать), в третьей — с использованием Ranges. Этот материал будет полезен тем разработчикам, которые хотят применять новые стандарты и подходы у себя на проектах.
Мы не будем подробно разбирать библиотеку Ranges, поскольку о ней в сети есть много различных статей, например, как это руководство. Поэтому для начала обратимся к описанию используемых адаптеров в Ranges (так как не совсем корректно называть этот термин функцией, здесь и далее буду использовать слово адаптер):
ranges: iota_view (views: iota) — данный адаптер генерирует последовательность элементов путем многократного увеличения начального значения. Хотя в описании сказано, что функция генерирует последовательность, но на самом деле возвращается легковесный объект, который выдаёт значения в ограниченном или в бесконечном диапазоне.
ranges: stride_view (views: stride) — возвращает последовательность из элементов другой последовательности с заданным шагом.
ranges: filter_view (views: filter) — аналог copy_if, за исключением того, что он не создаёт новый объект, а возвращает отображения на диапазон элементов, удовлетворяющих условию.
ranges: transform_view (views: transform) — аналог std: transform.
ranges: to — создает новый объект на основании входного отображения.
ranges: take_view (views: take) — возвращает первые N элементов последовательности.
ranges: drop_view (views: drop) — возвращает элементы последовательности, начиная с N.
ranges: chunk_view (views: chunk) — разделяет заданный диапазон на поддиапазоны с заданным количеством элементов. Последний поддиапазон может не совпадать с заданным количеством элементов.
ranges: join_with_view (views: join_with) — объединяет несколько диапазонов в один, добавляя между ними разделитель.
ranges: join_view (views: join) — объединяет несколько диапазонов в один.
ranges: adjacent_transform_view (views: adjacent_transform) — адаптер применяет функцию к смежным элементам, их количество задается шаблонным параметром.
Итак, перейдем к практической части, где мы рассмотрим проблемы, возникающие при работе со стандартными алгоритмами в С++ .
1) Непростой квест с обычной последовательностью чисел
struct Rect
{
//...
std::array buffer = { 0,0,0,0 };
};
struct ContainerRect
{
std::vector rectangles;
void calc(int index, int width, int height) {
Rect rect;
//...
rectangles.push_back(std::move(rect));
}
};
void fill(int count_rect, int width, int height) {
ContainerRect container;
for (int i = 0; i < count_rect; ++i)
{
container.calc(i, width, height);
}
//...
}
Так будут выглядеть функции fill
, если использовать алгоритмы.
void fill(int count_rect, int width, int height) {
ContainerRect container;
std::vector numbers;
numbers.resize(count_rect);
std::iota(std::begin(numbers), std::end(numbers), 0);
std::for_each(std::begin(numbers), std::end(numbers), [&](auto item) {
container.calc(item, width, height);
});
//...
}
Из примера выше видно — для того чтобы пройтись по числовой последовательности, необходимо создать контейнер соответствующего размера, а это — дополнительное использование памяти. Также нужно заполнить контейнер последовательностью значений. Если значение count_rect
невелико, то выделение памяти не так и уж критично, но при большом диапазоне стандартными инструментами пользоваться будет накладно.
Вид функций fill
при использовании Ranges:
void fill(int count_rect, int width, int height) {
ContainerRect container;
const auto numbers = std::ranges::iota_view{ 0, count_rect};
std::for_each(std::begin(numbers), std::end(numbers), [&](auto item) {
container.calc(item, width, height);
});
//...
}
Если нужно итерироваться с определённым шагом, то стандартные алгоритмы вряд ли помогут, но в Ranges есть такой адаптер, как std::ranges::views::stride
.
И данный цикл:
for (int i = 0; i < count; i += 3) {
...
}
будет эквивалентен следующей конструкции:
for (auto&& i : std::ranges::iota_view{0, count_rect} | std::ranges::views::stride(3)) {
...
}
Выглядит громоздко, но в данном случае, когда итерируется на определённое количество шагов, то разработчики обычно имеют ввиду, что будут работать с поддиапазоном. В этом случае можно применять специальные адаптеры для разбиения (пример разбиения и слияния рассмотрим ниже).
Для контейнеров применение std::ranges::views::stride
будет выглядеть таким образом:
std::string s = "qwertyuiop";
auto res = s | std::ranges::views::stride(4);
2) Ограничитель
Из предыдущего примера можно заметить, что в функцию std::for_each
передавался ограничитель (std::end)
. Алгоритмы из пространства имён Ranges позволяют передавать как сам объект std::ranges::iota_view
, так и контейнеры без использования begin
и end
. После этого функция fill
принимает следующий вид:
void fill(int count_rect, int width, int height) {
ContainerRect container;
std::ranges::for_each(std::ranges::iota_view{ 0, count_rect }, [&](auto item) {
container.calc(item, width, height);
});
//...
}
Использование контейнера в адаптере будет выглядеть следующим образом:
std::vector temp;
std::ranges::transform_view(temp, [](auto item) {
return item * 10;
});
Здесь хотелось бы кратко отметить: если не писать ограничитель, то можно нарваться на проблему провисшего итератора, но она решается с помощью типа std::ranges::dangling
, что позволит компилятору выдавать сообщение об ошибке.
Пример провисшего итератора:
std::vector get_vector() {
std::vector temp;
//...
return temp;
}
int main()
{
auto item = std::ranges::find(get_vector(), 10);
std::cout << item << std::endl;
}
3) Проблема энергичного поведения
struct Point
{
int x;
int y;
};
using Points = std::vector;
using VecString = std::vector;
VecString calc(const Points& points, int pos)
{
VecString temp;
for (auto&& it : points)
{
if (it.y > pos)
{
temp.push_back("x=" + std::to_string(it.x) + " y=" + std::to_string(it.y));
}
}
return temp;
}
В данном примере нужно использовать два алгоритма — это copy_if
и transform
. Так как алгоритмы выполняются последовательно, необходим дополнительный контейнер для хранения отфильтрованных данных. В итоге функция calc
будет выглядеть следующим образом:
VecString calc(const Points& points, int pos)
{
Points t;
std::copy_if(points.begin(), points.end(), std::back_inserter(t), [pos](auto it) {
return it.y > pos;
});
VecString temp;
std::transform(t.begin(), t.end(), std::back_inserter(temp), [](auto it) {
return "x=" + std::to_string(it.x) + " y=" + std::to_string(it.y);
});
return temp;
}
А вот функция, переписанная с помощью Ranges:
VecString calc(const Points& points, int pos)
{
return points | std::ranges::views::filter([pos](auto it) {return it.y > pos; })
| std::ranges::views::transform([](auto it) {
return "x=" + std::to_string(it.x) + " y=" + std::to_string(it.y);
})
| std::ranges::to();
}
Данная реализация похожа на конвейер (что является еще одним преимуществом), где каждый элемент (разделённый оператором »|») проходит все шаги, если он удовлетворяет условию, до конечного результата. Это позволяет выполнять код лениво и без лишних выделений памяти.
4) Проблема использования полей структуры или класса в алгоритмах
struct User
{
int id;
std::string name;
};
std::vector get_id_users(const std::vector &users) {
std::vector vector_id;
for (auto&& i : users) {
vector_id.push_back(i.id);
}
return vector_id;
}
Вариант с использованием алгоритма transform
:
std::vector get_id_users(const std::vector& users) {
std::vector vector_id;
std::transform(users.begin(), users.end(), std::back_inserter(vector_id), [](auto item) {
return item.id;
});
return vector_id;
}
Как видно из примера, для копирования поля структуры необходимо в transform
передать лямбду и оттуда возвращать конкретное поле. В Ranges введено понятие проекций, которое позволяет указывать, какое поле структуры мы хотим использовать. Окончательный вариант будет таким:
std::vector get_id_users(const std::vector& users) {
std::vector vector_id;
std::ranges::transform(users, std::back_inserter(vector_id), &User::id);
return vector_id;
}
В предыдущей статье мы разобрали пример с применением алгоритмов, продублирую его в итоговом варианте:
std::vector getIdUser()
{
int k = std::count_if(users.cbegin(), users.cend(), [](auto i) {
return i.flag_2;
});
if (std::exchange(k, count - k) >= count) {
return {};
};
auto end = std::stable_partition(users.begin(), users.end(), [&k](auto i) {
return i.flag_1 && i.flag_2 && (k-- > 0);
});
std::vector id_users;
std::transform(users.begin(), end, std::back_inserter(id_users), [](auto i) {
return i.id;
});
return id_users;
}
Здесь есть явный недостаток в условии stable_partition
(k-- > 0), который будет проверяться для всех элементов. В Ranges есть специальный адаптер для ограничения std::ranges::views::take
. С имеющимися знаниями о Ranges этот вариант можно упростить следующем образом:
std::vector getIdUser_range()
{
int k = std::ranges::count_if(users.cbegin(), users.cend(), &User::flag_2);
if (std::exchange(k, count - k) >= count) {
return {};
};
auto temp = users
| std::ranges::views::filter([](auto item) {return item.flag_1 && item.flag_2; })
| std::ranges::views::take(k);
std::vector id_users;
std::ranges::transform(temp, std::back_inserter(id_users), &User::id);
return id_users;
}
5) Разбиение и объединение диапазонов
Пример 1
В этом примере функция convert
преобразует строку buffer
(постоянный размер этой строки — 14), вставляя на позиции 3,5,7,9 разделитель '-'.
std::string convert(const std::string& buffer)
{
std::string uuid;
for (int i = 0, count = buffer.size(); i < count; ++i)
{
uuid.push_back(buffer[i]);
if ((i == 3) || (i == 5) || (i == 7) || (i == 9))
{
uuid.push_back('-');
}
}
return uuid;
}
Для данного примера нет алгоритмов, которые смогли бы решить данную задачу. Поэтому сразу перейду к Ranges. Здесь будем использовать следующие адаптеры: std::ranges::views::chunk
и std::ranges::views::join_with
. В итоге функция convert
примет вид:
std::string convert(const std::string& buffer)
{
using namespace std::ranges::views;
using namespace std::ranges;
auto res1 = buffer | take(4) | to();
auto res2 = buffer | drop(buffer.size() - 4) | to();
auto res3 = buffer | drop(4) | take(2 * 4) | chunk(2) | join_with('-') | to();
return std::vector{ res1, res3, res2 } | join_with('-') | to();
}
Пример 2
В этом пример необходимо расшифровать строку (длина которой кратна 4) по определённому правилу, где 4 символа преобразуются в 3.
int convert(char symbol) {
int value;
//...
return value;
}
std::string decode(std::string temp)
{
size_t buffer_size = temp.length() * 3 / 4 + 1;
std::string buffer;
buffer.resize(buffer_size);
for (size_t i = 0; i < temp.length(); i += 4)
{
int b_index = i * 3 / 4;
unsigned char values[4];
values[0] = convert(temp[i]);
values[1] = convert(temp[i + 1]);
values[2] = convert(temp[i + 2]);
values[3] = convert(temp[i + 3]);
buffer[b_index] = (values[0] << 0x2) ^ (values[1] >> 0x4);
buffer[b_index + 1] = ((values[1] & 0xF) << 0x4) ^ (values[2] >> 0x2);
buffer[b_index + 2] = ((values[2] & 0x3) << 0x6) ^ values[3];
}
buffer[buffer_size - 1] = 0x0;
return buffer;
}
Здесь можно применить алгоритм transform
, а основной цикл оставить без изменения.
std::string decode(const std::string &temp)
{
std::string str;
std::transform(temp.cbegin(), temp.cend(), std::back_inserter(str), [](auto item) {
return convert(item);
});
std::string buffer(str.length() * 3 / 4 + 1, '\0');
for (size_t i = 0; i < str.length(); i += 4)
{
int b_index = i * 3 / 4;
int index = i % 4;
buffer[b_index] = (str[index] << 0x2) ^ (str[index + 1] >> 0x4);
buffer[b_index + 1] = ((str[index + 1] & 0xF) << 0x4) ^ (str[index + 2] >> 0x2);
buffer[b_index + 2] = ((str[index + 2] & 0x3) << 0x6) ^ str[index + 3];
}
return buffer;
}
Как и в предыдущем примере, будем использовать адаптер std::ranges::views::chunk
для разделения, а для объединения — std::ranges::views::join
, так как нам нужно просто объединить поддиапазоны без разделителя.
std::string decode(const std::string& temp)
{
auto temps = temp
| std::ranges::views::transform([](auto item) {return convert(item); })
| std::ranges::views::chunk(4);
std::string buffer(3 * temps.size(), '\0');
auto chunk_buf = buffer | std::ranges::views::chunk(3)
| std::ranges::views::transform([it = &temps.begin()](auto item) {
auto old = *std::exchange(*it, std::next(*it));
item[0] = (old[0] << 0x2) ^ (old[1] >> 0x4);
item[1] = ((old[1] & 0xF) << 0x4) ^ (old[2] >> 0x2);
item[2] = ((old[2] & 0x3) << 0x6) ^ old[3];
return item;
})
| std::ranges::views::join | std::ranges::to();
chunk_buf.push_back('\0');
return chunk_buf;
}
6) Использование элементов одного контейнера в вычислении
В примере приведен вектор с множеством точек, где между двумя точками нужно вычислить промежуточные точки с помощью функции get_splitting_segment
.
struct Point
{
int x;
int y;
int z;
};
std::vector get_points(const std::vector& points) {
auto get_splitting_segment = [](const Point& p1, const Point& p2) {
std::vector points;
//...
return points;
};
std::vector result;
for (int i = 0, size = points.size() - 1; i < size; ++i) {
auto temp = get_splitting_segment(points[i], points[i + 1]);
for (auto&& i : temp) {
result.push_back(std::move(i));
}
}
return result;
}
С использованием алгоритмов функция get_points будет выглядеть так:
std::vector get_points(const std::vector& points) {
auto get_splitting_segment = [](const Point& p1, const Point& p2) {
std::vector points;
//...
return points;
};
std::vector> temp;
std::transform(points.begin(), std::prev(points.end()), std::next(points.begin()), temp, get_splitting_segment);
std::vector result;
std::for_each(temp.cbegin(), temp.cend(), [&result](auto item) {
std::copy(item.cbegin(), item.cend(), std::back_inserter(result));
});
return result;
}
Здесь std::transform
позволяет проходиться по двум итераторам, но есть минус, связанный с дополнительным контейнером. И вторым минусом, а если нужно было пройти не по двум точкам, а трёх и более, к примеру, для построения сплайнов.
В Ranges для таких случаев существуют специальные адаптеры ranges::views::adjacent
или ranges::views::adjacent_transform
. Второй нам подходит больше. Тогда получаем в итоге:
std::vector get_points(const std::vector &points) {
auto get_splitting_segment = [](const Point& p1, const Point& p2) {
std::vector points;
//...
return points;
};
auto view = points
| std::ranges::views::adjacent_transform<2>(get_splitting_segment)
| std::ranges::views::join;
}
Заключение
В этой статье мы с вами рассмотрели практические примеры кода на С++, где применение стандартных алгоритмов проблематично или совсем невозможно, а также решение этих проблем с новым механизмом абстракции Ranges. У библиотеки Ranges есть ряд преимуществ:
итерация по числовой последовательность, с ограничением и без;
энергичное поведение;
отображения для использования внутренних полей класса;
новые адаптеры, аналогов которых нет в алгоритмах.
А также свои недостатки:
небольшое количество адаптеров по сравнению со списком алгоритмов;
нельзя распараллелить вычисления;
на существующих реализациях компиляторов Ranges плохо оптимизированы.
Возможно, в будущих стандартах эти проблемы устранят.
![ce3c88b2c68444d76f971e8a27aabb53.gif](https://habrastorage.org/getpro/habr/upload_files/ce3/c88/b2c/ce3c88b2c68444d76f971e8a27aabb53.gif)
Но сколько бы мы не писали о преимуществах данной библиотеки, разработчики постоянно пишут через циклы :)
Спасибо за внимание!
Больше авторских материалов для backend-разработчиков от моих коллег читайте в соцсетях SimbirSoft — ВКонтакте и Telegram.