В публикации Ленивые операции над множествами в 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);

Почему же это так важно, и что стоит за этой записью?


Ответ на вопрос "почему это важно" понятен. Во-первых, это красиво. Кроме того, удобно.
А вот что за этим стоит — и есть суть данной публикации.



Содержание


  1. Проблематика
  2. Стирание типа диапазона
  3. Сделай. Пожалуйста
  4. Массив диапазонов
  5. Диапазон из контейнера
  6. Собираем всё вместе
  7. std::tie
  8. Заключение
  9. Ссылки


Проблематика


Ключевой момент дизайна ленивых итераторов над диапазонами (см. Дизайн->Быстрое копирование) — это их легковесность. Если коротко — в итераторе не лежит ничего лишнего, кроме пары других итераторов.


Это даёт гибкость, но и накладывает определённые ограничения.


Допустим, мы хотим слить несколько разнотипных диапазонов. Но под капотом у итератора слияния сидит алгоритм, который хранит сливаемые диапазоны в одном контейнере и может переупорядочивать их (см. Алгоритм слияния).
Хранить в одном контейнере разнотипные объекты можно (например, использовать std::variant или динамический кортеж), но в нашем случае довольно накладно. Поэтому нужно привести их к одному типу.


— Но как, Холмс?

— Элементарно, мой дорогой Ватсон. Использовать стирание типов.


Стирание типа диапазона


Канонический пример стирания типов — std::any.
Наш "стёртый" диапазон — это any-диапазон. Собственно, в Бусте он так и называется — boost::any_range.


Для приведения разных диапазонов к единому типу нужно:


  1. Найти минимальную категорию из категорий исходных диапазонов;
  2. Выделить тип итерируемых элементов;
  3. Создать 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, то мы по-прежнему можем создать диапазон для слияния руками.



Ссылки


Комментарии (8)


  1. sergegers
    26.10.2021 15:47
    +1

    Хранить в одном контейнере разнотипные объекты можно (например, использовать std::variant или динамический кортеж), но в нашем случае довольно накладно. 

    На всякий случай добавлю, что boost::any_range не бесплатен, он выделяет динамическую память и использует виртальные функции, вряд ли дешевле variant'а.


    1. izvolov Автор
      26.10.2021 17:17

      Справедливо. Надо бы замерить.


    1. izvolov Автор
      27.10.2021 19:44

      Предварительные результаты замеров в попугаях:
      Обычные итераторы: 26
      boost::any_iterator: 215
      variant_iterator (самописный): 32


      Также эксперементирую самописным any_iterator без виртуальных фунций, но быстрее пока не выходит.


      1. sergegers
        27.10.2021 20:17

        А как может быть any_iterator без виртуальных функций? Тогда он должен знать все типы, которые он должен затайперэйзить, а это уже не будет any_iterator. Концептуально, по моему, быстрее варианта ничего не получится. Из-за знания типов итераторов исходных рэнжей на этапе компиляции можно избавиться от динамической аллокации, а вместо виртуальных функций - switch по типу, который стоит примерно как виртуальный вызов.


        1. izvolov Автор
          27.10.2021 20:26

          Без виртуальных функций можно делать через механизм обобщённых обработчиков. Писал об этом тут: https://habr.com/ru/post/302372/#manager


          Или можно посмотреть реализацию std::function в любой современной стандартной библиотеке.


          1. sergegers
            27.10.2021 21:22

            Но это ничем не отличается от std::vaiant по производительности, но нужо знать конкретные классы, то есть ваш any_iterator Будет шаблоном, а весь сок в том, что это не шаблон.


            1. izvolov Автор
              27.10.2021 21:26

              Нет, типы знать не нужно. Шаблоном не будет :). Ну то есть будет, но параметры — не итераторы, а тип значения и категория.
              Как-то так это можно сделать: https://pastebin.com/Z6wemSjB


              1. sergegers
                27.10.2021 21:55

                А, ну здесь аллокация на куче в boost::any и самодельные виртуальные методы. От судьбы не уйдёшь.