Полиморфные структуры данных и производительность

7c5040fcbca6df583d949a5b29b1c049.png

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

про std: any

std: any неприменим нигде кроме каких-то костылей в dll и про него лучше забыть, благо есть аналоги с гораздо большим потенциалом, но об этом в другой статье

Рассмотрим эти способы подробнее:

  1. виртуальные функции:

    позволяют создать контейнер указателей на базовый тип, а потом складывать туда любых наследников.

struct base {
  virtual ~base() = default;
};

...
std::vector things;

Сразу проблемы:

  • если нужен указатель, то как управлять памятью?

  • если нужно копирование контейнера, то оно будет неправильным (скопируются указатели). И так как контейнер использует внутри конструкторы, нам ничего не остаётся кроме как написать свою обёртку или функцию копирования — что, вообще-то, крайне неудобно, затратно и опасно в плане багов

  • так как в контейнере лежат элементы с разными указателями на vtable, процессор и компилятор не состоянии предугадать vtable какого типа лежит следующим и это приводит к постоянным кеш-мисам, что значительно замедляет и итерацию и в целом использование подобного контейнера.

нестандартные альтернативы

проблемы с лишними аллокациями памяти и копированием/удобством в целом отлично решаются решениями основанными на стирании типов, например https://github.com/kelbon/AnyAny, но это не тема этой статьи

  1. std: variant

    позволяет складывать любые из *известных заранее* типов в контейнер

using myvar = std::variant;
...
std::vector things;

Это решает проблемы с конструкторами и выделением памяти, к тому же элементы теперь расположены более менее локально. Но это вносит кучу своих проблем:

  • типы должны быть известны заранее, невозможны «рекурсивные» структуры данных, например AST на варианте уже не напишешь

  • Любое увеличение количества типов должно изменить ВСЕ сигнатуры всех функций использующих этот вариант. Как минимум придётся перекомпилирвать весь проект

    void foo (const std: variant&);

    просто сломается при введении ещё одного типа

  • Проблемы с исключениями и неэффективность при большой разнице sizeof

К тому же у обоих рассмотренных вариантов нет опции быстро посмотреть только интересующие типы, придётся идти и к тому же заниматься динамическим кастом/визитом


Заметим, что часто нам нужен именно контейнер полиморфных типов, а не просто одна штука. Также я уже упомянул про то, что неплохо бы уметь предсказывать какой тип будет следующим. Это приводит нас к идее сортировать значения внутри контейнера по типу! Хм, интересно. И это действительно значительно улучшает производительность при итерации, но согласитесь как-то неудобно и затратно, к тому же придётся каждый раз при вставке и удалении думать об этом.

А как это исправлять?

Я пошёл дальше, почему бы не сделать контейнер ведущий себя как контейнер из variant, но на самом деле хранящий каждый тип в отдельном контейнере? Тогда мы сразу совершенно избавились бы от кастов/прыжков по vtable, std: visit и прочего. В действительности мы избавились бы от полиморфизма вообще, хотя он и оставался бы со стороны публичного интерфейса.

Кстати, об интерфейсе, нам нужны операции:

  • вставка для каждого типа T из Ts…

  • удаление для каждого типа T из Ts…

  • просмотр контейнера для типа T

  • аналог посещения (visit) для всех значений контейнера

К тому же кажется, что контейнер может быть разным в зависимости от задачи, так что сделаем его кастомизируемым. Назвал я это чудо variant_swarm (буквально рой вариантов)

Итак, как реализовать это? Всё довольно просто:

// БАЗА с настомизируемым контейнером
template