В публикации Ленивые операции над множествами в C++ я показал, как можно проектировать ленивые операции над несколькими диапазонами. Теперь я хочу подробнее рассказать о важном решении, делающем такие операции удобными в использовании.
Один из основных моментов в интерфейсе ленивых операций над диапазонами — это возможность следующей записи
burst::merge(std::tie(range1, range2, ...));
То есть возможность работать с произвольным набором исходных диапазонов.
В коде это будет выглядеть как-то так:
const auto odd = std::vector{1, 3, 5, 7};
const auto even = std::list{0, 2, 4, 6, 8};
const auto merged_range = burst::merge(std::tie(odd, even));
const auto expected = {0, 1, 2, 3, 4, 5, 6, 7, 8};
assert(merged_range == expected);
Почему же это так важно, и что стоит за этой записью?
Ответ на вопрос "почему это важно" понятен. Во-первых, это красиво. Кроме того, удобно.
А вот что за этим стоит — и есть суть данной публикации.
Содержание
- Проблематика
- Стирание типа диапазона
- Сделай. Пожалуйста
- Массив диапазонов
- Диапазон из контейнера
- Собираем всё вместе
- std::tie
- Заключение
- Ссылки
Проблематика
Ключевой момент дизайна ленивых итераторов над диапазонами (см. Дизайн->Быстрое копирование) — это их легковесность. Если коротко — в итераторе не лежит ничего лишнего, кроме пары других итераторов.
Это даёт гибкость, но и накладывает определённые ограничения.
Допустим, мы хотим слить несколько разнотипных диапазонов. Но под капотом у итератора слияния сидит алгоритм, который хранит сливаемые диапазоны в одном контейнере и может переупорядочивать их (см. Алгоритм слияния).
Хранить в одном контейнере разнотипные объекты можно (например, использовать std::variant или динамический кортеж), но в нашем случае довольно накладно. Поэтому нужно привести их к одному типу.
— Но как, Холмс?
— Элементарно, мой дорогой Ватсон. Использовать стирание типов.
Стирание типа диапазона
Канонический пример стирания типов — std::any
.
Наш "стёртый" диапазон — это any
-диапазон. Собственно, в Бусте он так и называется — boost::any_range
.
Для приведения разных диапазонов к единому типу нужно:
- Найти минимальную категорию из категорий исходных диапазонов;
- Выделить тип итерируемых элементов;
- Создать
any
-диапазон, который знает только свою категорию и тип итерируемых элементов.
Если совсем по-простому, стирание типов — это механизм, который позволяет "потерять" тип объекта на этапе компиляции, но "запомнить" этот тип в обработчике, который будет вызван когда-нибудь потом.
#include <iostream>
#include <string>
template <typename T>
void handle (const void * erased_object)
{
std::cout << *static_cast<const T *>(erased_object) << std::endl;
}
struct erased_t
{
// Обработчик принимает указатель на void.
using handler_type = void (*) (const void *);
const void * object;
handler_type handler;
};
template <typename T>
erased_t erase (const T & object)
{
// Запомнили указатель на объект и нужный ему обработчик.
return {&object, &handle<T>};
}
int main ()
{
auto s = std::string("123");
// Стёрли тип.
auto erased = erase(s);
// ...
// Много кода.
// ...
// Вызвали запомненный обработчик.
erased.handler(erased.object);
}
Сделай. Пожалуйста
Итак, у нас есть возможность стирать типы диапазонов. Напишем функцию uniform_range_tuple_please
, которая из кортежа ссылок на различные диапазоны должна сделать кортеж ссылок на одинаковые диапазоны.
Тут я использую идиому, которую называю "please" (если у неё есть какое-то общераспространённое название, напишите в комментариях, пожалуйста). Заключается она в том, что некая функция делает что-то только в том случае, если это требуется. А если не требуется, то возвращает то же, что получила на входе.
template <typename... Ranges>
auto uniform_range_tuple_please (std::tuple<Ranges &...> ranges)
{
return uniform_range_tuple_please_impl(ranges, are_same<Ranges...>{});
}
Если изначальные кортежи неодинаковые, то мы их приводим к единому типу.
template <typename... Ranges>
auto uniform_range_tuple_please_impl (std::tuple<Ranges &...> ranges, std::false_type)
{
static_assert(not are_same_v<Ranges...>, "");
using iterator_category = minimum_category_t<range_category_t<Ranges>...>;
using boost_traversal =
typename boost::iterator_category_to_traversal<iterator_category>::type;
return by_all(to_any_range<boost_traversal>, ranges);
}
Если же они изначально одинаковые, то ничего делать не нужно. Возвращаем исходный объект.
template <typename... Ranges>
auto uniform_range_tuple_please_impl (std::tuple<Ranges &...> ranges, std::true_type)
{
static_assert(are_same_v<Ranges...>, "");
return ranges;
}
Массив диапазонов
После того, как исходные диапазоны были приведены к единому типу, нужно их сложить в контейнер. Здесь просто складываем их в std::vector
. Для этого я использую самописную утилиту burst::make_range_vector
. Всё.
Диапазон из контейнера
Дальше требуется ещё одна хитрость.
Как уже было сказано, в итераторе слияния должна храниться только пара итераторов на контейнер, в котором лежат сливаемые диапазоны. А после предыдущего шага у нас на руках как раз-таки контейнер. И его нужно, с одной стороны, где-то сохранить (он должен жить, пока жив итератор слияния), а с другой — в итераторе слияния должны быть только итераторы на начало и конец нашего контейнера.
Для этого есть владеющий итератор. Он хранит итерируемый контейнер (в умном указателе) и итератор этого контейнера. Время жизни итерируемого контейнера ограничено созданием первого и уничтожением последнего экземпляров владеющего итератора. Кроме того, владеющий итератор сохраняет все характеристики итератора хранимого контейнера. Все действия с ним — продвижение, разыменование, сравнение — производятся так, как будто бы с итератором хранимого контейнера.
Собираем всё вместе
А теперь осталось только взять всё, что у нас имеется, и написать нужную обвязку:
template <typename ... Ranges>
auto make_merge_iterator (std::tuple<Ranges &...> ranges)
{
auto common_ranges = detail::uniform_range_tuple_please(ranges);
return make_merge_iterator(own_as_range(burst::apply(make_range_vector, common_ranges)));
}
std::tie
Ленивый итератор над диапазонами оперирует только ссылками и итераторами на исходные диапазоны. Поэтому нужно, чтобы был внешний (по отношению к нашему ленивому итератору) владелец этих диапазонов.
Я долго думал над тем, какой именно интерфейс нужно предоставить для того, чтобы передавать произвольный набор исходных диапазонов: то ли создать специальную структуру, то ли использовать вариадик и enable_if
-магию. Но потом понял, что в стандартной библиотеке уже есть то, что мне нужно: std::tie
.
Во-первых, std::tie
создаёт кортеж. Во-вторых, запись получается достаточно лаконичной. Ну и в-третьих, std::tie
принимает только lvalue, то есть временные объекты он принимать откажется. Значит, тем, что мы передаём в std::tie
, уже кто-то владеет, и объект не умрёт в процессе слияния. Никаких висячих ссылок.
Заключение
Мы получили удобный интерфейс для ленивого слияния (или любой другой операции над множествами) и достаточно эффективную реализацию этого механизма.
При этом, если для нас недопустимы накладные расходы на, например, std::shared_ptr
в недрах burst::owning_iterator
, то мы по-прежнему можем создать диапазон для слияния руками.
sergegers
На всякий случай добавлю, что boost::any_range не бесплатен, он выделяет динамическую память и использует виртальные функции, вряд ли дешевле variant'а.
izvolov Автор
Справедливо. Надо бы замерить.
izvolov Автор
Предварительные результаты замеров в попугаях:
Обычные итераторы: 26
boost::any_iterator: 215
variant_iterator (самописный): 32
Также эксперементирую самописным any_iterator без виртуальных фунций, но быстрее пока не выходит.
sergegers
А как может быть any_iterator без виртуальных функций? Тогда он должен знать все типы, которые он должен затайперэйзить, а это уже не будет any_iterator. Концептуально, по моему, быстрее варианта ничего не получится. Из-за знания типов итераторов исходных рэнжей на этапе компиляции можно избавиться от динамической аллокации, а вместо виртуальных функций - switch по типу, который стоит примерно как виртуальный вызов.
izvolov Автор
Без виртуальных функций можно делать через механизм обобщённых обработчиков. Писал об этом тут: https://habr.com/ru/post/302372/#manager
Или можно посмотреть реализацию std::function в любой современной стандартной библиотеке.
sergegers
Но это ничем не отличается от std::vaiant по производительности, но нужо знать конкретные классы, то есть ваш any_iterator Будет шаблоном, а весь сок в том, что это не шаблон.
izvolov Автор
Нет, типы знать не нужно. Шаблоном не будет :). Ну то есть будет, но параметры — не итераторы, а тип значения и категория.
Как-то так это можно сделать: https://pastebin.com/Z6wemSjB
sergegers
А, ну здесь аллокация на куче в boost::any и самодельные виртуальные методы. От судьбы не уйдёшь.