Возможности С++: от стандартных алгоритмов до диапазонов (Ranges)

от автора

Привет, Хабр! Меня зовут Николай, я разработчик С++ в SimbirSoft. В предыдущей статье мы с вами рассмотрели применение стандартных алгоритмов в повседневном коде и их преимущества над обычными циклами. В продолжение этой темы мне хотелось бы рассказать о недостатках стандартных алгоритмов и способах их решения с помощью библиотеки Ranges. Практические примеры я разбил на три части: в первой показаны обычные циклы, во второй — вариант написания с помощью алгоритмов (но не всегда можно это сделать), в третьей — с использованием Ranges. Этот материал будет полезен тем разработчикам, которые хотят применять новые стандарты и подходы у себя на проектах.

Источник иллюстрации: ithare.com

Источник иллюстрации: ithare.com

Мы не будем подробно разбирать библиотеку 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<int, 4> buffer = { 0,0,0,0 }; }; struct ContainerRect { std::vector<Rect> 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<int> 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<int> temp; std::ranges::transform_view(temp, [](auto item) { return item * 10; });

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

Пример провисшего итератора:

std::vector<int> get_vector() { std::vector<int> 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<Point>; using VecString = std::vector<std::string>; 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<VecString>(); }

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

4) Проблема использования полей структуры или класса в алгоритмах

struct User { int id; std::string name; }; std::vector<int> get_id_users(const std::vector<User> &users) { std::vector<int> vector_id; for (auto&& i : users) { vector_id.push_back(i.id); } return vector_id; }

Вариант с использованием алгоритма transform:

std::vector<int> get_id_users(const std::vector<User>& users) { std::vector<int> 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<int> get_id_users(const std::vector<User>& users) { std::vector<int> vector_id; std::ranges::transform(users, std::back_inserter(vector_id), &User::id); return vector_id; }

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

std::vector<int> 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<int> 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<int> 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<int> 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<std::string>(); auto res2 = buffer | drop(buffer.size() - 4) | to<std::string>(); auto res3 = buffer | drop(4) | take(2 * 4) | chunk(2) | join_with('-') | to<std::string>(); return std::vector<std::string_view>{ res1, res3, res2 } | join_with('-') | to<std::string>(); }

Пример 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<std::string>(); chunk_buf.push_back('\0'); return chunk_buf; }

6) Использование элементов одного контейнера в вычислении

В примере приведен вектор с множеством точек, где между двумя точками нужно вычислить промежуточные точки с помощью функции get_splitting_segment.

struct Point { int x; int y; int z; }; std::vector<Point> get_points(const std::vector<Point>& points) { auto get_splitting_segment = [](const Point& p1, const Point& p2) { std::vector<Point> points; //... return points; }; std::vector<Point> 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<Point> get_points(const std::vector<Point>& points) { auto get_splitting_segment = [](const Point& p1, const Point& p2) { std::vector<Point> points; //... return points; }; std::vector<std::vector<Point>> temp; std::transform(points.begin(), std::prev(points.end()), std::next(points.begin()), temp, get_splitting_segment); std::vector<Point> 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<Point> get_points(const std::vector<Point> &points) { auto get_splitting_segment = [](const Point& p1, const Point& p2) { std::vector<Point> points; //... return points; }; auto view = points | std::ranges::views::adjacent_transform<2>(get_splitting_segment) | std::ranges::views::join; }

Заключение

В этой статье мы с вами рассмотрели практические примеры кода на С++, где применение стандартных алгоритмов проблематично или совсем невозможно, а также решение этих проблем с новым механизмом абстракции Ranges. У библиотеки Ranges есть ряд преимуществ:

  • итерация по числовой последовательность, с ограничением и без;

  • энергичное поведение;

  • отображения для использования внутренних полей класса;

  • новые адаптеры, аналогов которых нет в алгоритмах.

А также свои недостатки: 

  • небольшое количество адаптеров по сравнению со списком алгоритмов; 

  • нельзя распараллелить вычисления;

  • на существующих реализациях компиляторов Ranges плохо оптимизированы. 

Возможно, в будущих стандартах эти проблемы устранят.

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

Спасибо за внимание!

Больше авторских материалов для backend-разработчиков от моих коллег читайте в соцсетях SimbirSoft – ВКонтакте и Telegram.


ссылка на оригинал статьи https://habr.com/ru/articles/828878/


Комментарии

Добавить комментарий

Ваш адрес email не будет опубликован. Обязательные поля помечены *