Динамический неоднородный плотно упакованный контейнер
Определение 1. Однородный контейнер – это такой контейнер, в котором хранятся объекты строго одного типа.
Определение 2. Неоднородный контейнер — это такой контейнер, в котором могут храниться объекты разного типа.
Определение 3. Статический контейнер — это контейнер, состав которого полностью определяется на этапе компиляции.
Под составом в данном случае понимается количество элементов и их типы, но не сами значения этих элементов. Действительно, бывают контейнеры, у которых даже значения элементов определяются на этапе компиляции, но в данной модели такие контейнеры не рассматриваются.
Определение 4. Динамический контейнер — это контейнер, состав которого частично или полностью определяется на этапе выполнения.
По такой классификации, очевидно, существуют четыре вида контейнеров:
-
Статические однородные
Например, обычный массив —
int[n]
. -
Статические неоднородные
Наиболее яркий пример такого контейнера — это кортеж. В языке C++ он реализуется классом
std::tuple<...>
-
Динамические однородные
std::vector<int>
. -
Динамические неоднородные
Вот об этом виде контейнеров и пойдёт речь в данной статье.
Содержание
- Динамические неоднородные контейнеры
- Динамический кортеж
- Хранение данных
- Обработчики
- Доступ к данным
- Время жизни и безопасность исключений
- Прочие проблемы
- Замеры производительности
- Ссылки
Динамические неоднородные контейнеры
Существует несколько техник[1] получения динамического неоднородного контейнера. Вот тройка, пожалуй, наиболее распространённых из них:
-
Массив указателей на полиморфный класс
Выбор
трусабалбесабывалого оопэшника.struct base { virtual ~base() = default; ... }; struct derived: base { ... }; std::vector<std::unique_ptr<base>> v;
-
Массив объединений
Под объединением может пониматься как языковая возможность
union
, так и библиотечный класс типаboost::variant
(в C++17 появитсяstd::variant
). -
Массив произвольных объектов с использованием стирания типа
Например,
boost::any
(В C++17 появитсяstd::any
), в который можно положить что угодно.
У каждой из этих техник есть свои преимущества и недостатки, и мы их обязательно рассмотрим. А сейчас пришло время осветить пункт, который до этого момента оставался в тени, несмотря на то, что он является частью названия (и сути) статьи.
Определение 5. Плотно упакованный контейнер — это такой контейнер, элементы которого лежат в непрерывной области памяти, и между ними нет пропусков (с точностью до выравнивания).
Примерами могут послужить int[n]
, std::vector<int>
и std::tuple<...>
.
К сожалению, не всякий плотно упакованный контейнер является динамическим неоднородным. А нам нужен именно такой.
Но вернёмся к преимуществам и недостаткам вышеперечисленных техник получения динамических неоднородных контейнеров.
Массив указателей на полиморфный класс
Преимущества:
-
Относительная простота реализации
Наследование, полиморфизм, все дела. Эти вещи (к счастью или к сожалению?) знает даже новичок.
-
Лёгко вводить новые сущности
Не требуется никаких дополнительных действий для того, чтобы получить возможность вставлять новый объект в массив. Нужно только создать нового наследника базового класса.
И не нужно перекомпилировать код, зависящий от массива указателей.
Недостатки:
-
Зависимость от иерархии
В массив можно сложить только объекты, унаследованные от некоторого базового класса.
-
Избыточность кода
Для каждого нового элемента требуется создать новый класс в иерархии. То есть если я хочу складывать в контейнер два типа чисел — целые и плавучку, — то мне придётся завести базовый класс “число” и два его соответствующих наследника.
-
Неплотная упаковка
В массиве лежат только указатели, а сами объекты разбросаны по памяти. Это, в общем случае, будет негативно влиять на работу с кэшем.
Массив объединений
Преимущества:
-
Независимость от иерархии
В массив можно положить любой тип, указанный в объединении.
-
Объекты лежат в непрерывной области памяти
В массиве хранится не указатель, а сам объект.
Недостатки:
-
Зависимость от множества объектов, входящих в объединение
При добавлении нового объекта в объединение нужно перекомпилировать весь код, который явно зависит от нашего массива объединений.
-
Неплотная упаковка
Да, объекты лежат в непрерывной области памяти. Но нам хотелось бы получить плотную упаковку, а для этого необходимо, чтобы между объектами не было пустот. А пустоты могут быть.
Дело в том, что размер объединения равен размеру наибольшего типа этого объединения. Например, если в объединение входит два типа —
X
иchar
, причёмsizeof(X) = 32
, то каждыйchar
будет занимать 32 байта, хотя одного было бы вполне достаточно.
Массив произвольных объектов с использованием стирания типа
Преимущества:
-
Полная независимость
В любой момент в такой массив можно положить любой тип, и не придётся перекомпилировать ничего, что зависит от этого массива.
Недостатки:
-
Неплотная упаковка
Как и в случае с массивом указателей, объекты такого массива разбросаны по памяти (в общем случае это не так, потому что может использоваться оптимизация малых объектов[2], но для достаточно больших объектов это всегда верно).
Динамический кортеж
Итак, ни один из вышеперечисленных подходов не предполагает плотной упаковки. Поэтому нужно разработать ещё один подход к созданию динамического неоднородного контейнера, который, ко всему прочему, обеспечит вожделенную плотную упаковку.
Для того, чтобы это сделать, сначала придётся вспомнить такую сущность как any
. Работа с ней происходит примерно так:
Далее последует описание работы
any
и концепции стирания типа. Читатель, знакомый с этими сущностями, может перейти непосредственно к сути.
int main ()
{
// Создание новой переменной из экземпляра произвольного типа.
auto a = any(1);
assert(any_cast<int>(a) == 1);
// Доступ к значению.
any_cast<int>(a) = 42;
assert(any_cast<int>(a) == 42);
// Присвоение значения нового типа в старую переменную.
a = std::string(u8"Привет!");
assert(any_cast<std::string>(a) == std::string(u8"Привет!"));
}
Работает это следующим образом:
К нижеследующему коду не следует относиться слишком серьёзно — это схематичная реализация. За настоящей реализацией следует обратиться к более авторитетному источнику.
#include <cassert>
#include <utility>
enum struct operation_t
{
clone,
destroy
};
using manager_t = void (*) (operation_t, const void *, void *&);
// Для каждого типа в момент его укладки в `any` создаётся специальный обработчик, который затем
// будет использоваться для работы с этим типом.
template <typename T>
void manage (operation_t todo, const void * source, void *& destination)
{
switch (todo)
{
case operation_t::clone:
{
destination = new T(*static_cast<const T *>(source));
break;
}
case operation_t::destroy:
{
assert(source == nullptr);
static_cast<T *>(destination)->~T();
break;
}
}
}
class any
{
public:
any ():
m_data(nullptr),
m_manager(nullptr)
{
}
any (const any & that):
m_manager(that.m_manager)
{
m_manager(operation_t::clone, that.m_data, this->m_data);
}
any & operator = (const any & that)
{
any(that).swap(*this);
return *this;
}
any (any && that):
m_data(that.m_data),
m_manager(that.m_manager)
{
that.m_manager = nullptr;
}
any & operator = (any && that)
{
any(std::move(that)).swap(*this);
return *this;
}
~any ()
{
clear();
}
// Здесь происходит то самое "стирание" типа.
// Тип объекта в этом месте "забывается", и далее на этапе компиляции его узнать уже
// невозможно. Однако, благодаря сохранённому обработчику, на этапе исполнения будет
// известно, как управлять "жизнью" объекта: его копированием и разрушением.
template <typename T>
any (T object):
m_data(new T(std::move(object))),
m_manager(&manage<T>)
{
}
template <typename T>
any & operator = (T && object)
{
any(std::forward<T>(object)).swap(*this);
return *this;
}
template <typename T>
friend const T & any_cast (const any & a);
template <typename T>
friend T & any_cast (any & a);
void clear ()
{
if (not empty())
{
m_manager(operation_t::destroy, nullptr, m_data);
m_manager = nullptr;
}
}
void swap (any & that)
{
std::swap(this->m_data, that.m_data);
std::swap(this->m_manager, that.m_manager);
}
bool empty () const
{
return m_manager == nullptr;
}
private:
void * m_data;
manager_t m_manager;
};
// Для того, чтобы достать значение, нужно явно указать его тип.
// Использование:
//
// `any_cast<int>(a) = 4;`
//
template <typename T>
const T & any_cast (const any & a)
{
return *static_cast<const T *>(a.m_data);
}
template <typename T>
T & any_cast (any & a)
{
return *static_cast<T *>(a.m_data);
}
Как вы уже поняли, динамический кортеж (ДК) будет развитием идеи с any
. А именно:
- Как и в случае с
any
, будет применена техника стирания типа: типы объектов будут “забываться”, и для каждого объекта будет заведён “менеджер”, который будет знать, как с этим объектом нужно работать. - Сами объекты будут уложены друг за другом (с учётом выравнивания) в непрерывную область памяти.
Работать он будет похожим на any
образом:
// Создание первоначального кортежа из произвольного набора элементов.
auto t = dynamic_tuple(42, true, 'q');
// Индикация размера.
assert(t.size() == 3);
// Доступ к элементам по индексу.
assert(t.get<int>(0) == 42);
...
// Добавление новых произвольных элементов.
t.push_back(3.14);
t.push_back(std::string("qwe"));
...
// Модификация имеющихся элементов.
t.get<int>(0) = 17;
Нужно больше кода
Ну что ж, приступим[3].
Хранение данных
Как следует из определения плотной упаковки, все объекты хранятся в едином непрерывном куске памяти. Это значит, что для каждого из них, помимо обработчиков, нужно хранить его отступ от начала этого куска памяти.
Отлично, заведём для этого специальную структурку:
struct object_info_t
{
std::size_t offset;
manager_t manage;
};
Далее, нужно будет хранить сам кусок памяти, его размер, а также отдельно нужно будет помнить суммарный объём, занимаемый объектами.
Получаем:
class dynamic_tuple
{
private:
using object_info_container_type = std::vector<object_info_t>;
...
std::size_t m_capacity = 0;
std::unique_ptr<std::int8_t[]> m_data;
object_info_container_type m_objects;
std::size_t m_volume = 0;
};
Обработчики
Пока что остаётся неопределённым тип обработчика manager_t
.
Есть два основных варианта[4]:
-
Структура с “методами”
Как мы уже знаем по
any
, для управления объектом нужно несколько операций. В случае с ДК это, как минимум, копирование, перенос и разрушение. Для каждой из них нужно завести поле в структуре:struct manager_t { using copier_type = void (*) (const void *, void *); using mover_type = void (*) (void *, void *); using destroyer_type = void (*) (void *); copier_type copy; mover_type move; destroyer_type destroy; };
-
Указатель на обобщённый обработчик
Можно хранить только один указатель на обобщённый обработчик. В этом случае нужно завести перечисление, отвечающее за выбор требуемой операции, а также привести к единому виду сигнатуры всех возмозных действий над объектом:
enum struct operation_t { copy, move, destroy }; using manager_t = void (*) (operation_t, const void *, void *);
Недостаток первого варианта в том, что для каждого действия требуется введение нового поля. Соответственно, память, занимаемая обработчиками будет раздуваться в случае добавления новых обработчиков.
Недостаток второго варианта в том, что сигнатуры разных обработчиков разные, поэтому приходится подгонять все обработчики под единую сигнатуру, при этом, в засисимости от запрошенной операции, некоторые аргументы могут оставаться ненужными, а иногда — о, боги — даже приходится вызывать const_cast
.
Соответственно, недостатки одного из вариантов — это преимущества другого.
По итогам долгих размышлений и взвешиваний преимуществ и недостатков[5], недостатки первого варианта перевешивают, и выбор падает на меньшее зло — обобщённый обработчик.
template <typename T>
void copy (const void *, void *); // См. раздел "Время жизни и безопасность исключений".
template <typename T>
void move (void * source, void * destination)
{
new (destination) T(std::move(*static_cast<T *>(source)));
}
template <typename T>
void destroy (void * object)
{
static_cast<T *>(object)->~T();
}
template <typename T>
void manage (operation_t todo, const void * source, void * destination)
{
switch (todo)
{
case operation_t::copy:
{
copy<T>(source, destination);
break;
}
case operation_t::move:
{
move<T>(const_cast<void *>(source), destination);
break;
}
case operation_t::destroy:
{
assert(source == nullptr);
destroy<T>(destination);
break;
}
}
}
Доступ к данным
Самое простое, что можно сказать про ДК. Тип запрашиваемых данных известен на этапе компиляции, индекс известен на этапе исполнения, поэтому интерфейс доступа к объекту напрашивается сам собой:
template <typename T>
T & get (size_type index)
{
return *static_cast<T *>(static_cast<void *>(data() + offset_of(index)));
}
size_type offset_of (size_type index) const
{
return m_objects[index].offset;
}
Также, для большей эффективности (см. графики производительности доступа в конце статьи) можно определить доступ к объекту по отступу:
template <typename T>
const T & get_by_offset (size_type offset) const
{
return *static_cast<const T *>(static_cast<const void *>(data() + offset));
}
Ну и индикаторы по аналогии со стандартными контейнерами:
size_type size () const
{
return m_objects.size();
}
std::size_t capacity () const
{
return m_capacity;
}
bool empty () const
{
return m_objects.empty();
}
Единственное, про что нужно сказать отдельно — это особый индикатор, сообщающий объём контейнера. Под объёмом понимается суммарное количество памяти, занимаемое объектами, находящимися в ДК.
std::size_t volume () const
{
return m_volume;
}
Время жизни и безопасность исключений
Крайне важные задачи — слежение за временем жизни объектов и обеспечение безопасности исключений.
Поскольку объекты конструируются “вручную” при помощи размещающего new
, то они, естественно, и разрушаются “вручную” — явным вызовом деструктора.
Это создаёт определённые сложности с копированием и переносом объектов при переаллокации. Поэтому приходится реализовывать относительно сложные конструкции для копирования и переноса:
template <typename ForwardIterator>
void move (ForwardIterator first, ForwardIterator last, std::int8_t * source, std::int8_t * destination)
{
for (auto current = first; current != last; ++current)
{
try
{
// Пробуем перенести все объекты на новое место.
current->manage(operation_t::move,
source + current->offset, destination + current->offset);
}
catch (...)
{
// Если не получилось, то уничтожаем то, что уже было перенесено.
destroy(first, current, destination);
throw;
}
}
destroy(first, last, source);
}
template <typename ForwardIterator>
void copy (ForwardIterator first, ForwardIterator last, const std::int8_t * source, std::int8_t * destination)
{
for (auto current = first; current != last; ++current)
{
try
{
// Пробуем скопировать все объекты в новое место.
current->manage(operation_t::copy,
source + current->offset, destination + current->offset);
}
catch (...)
{
// Если что-то пошло не так, то уничтожаем всё, что уже было скопировано.
destroy(first, current, destination);
throw;
}
}
}
Прочие проблемы
Одна из самых больших проблем была с копированием. И, хотя я с ней и разобрался, сомнения всё равно периодически возникают.
Поясню.
Допустим, мы кладём некопируемый объект (скажем, std::unique_ptr
) в std::vector
. Мы это можем сделать при помощи переноса. Но если мы попытаемся скопировать вектор, компилятор будет ругаться, потому что внутренние его элементы некопируемы.
В случае с ДК всё несколько иначе:
- В момент укладки элемента в ДК нужно создать обработчик копирования. Если объект некопируем, то обработчик не может быть создан (ошибка компиляции). При этом ещё неизвестно, собираемся ли мы вообще когда-нибудь копировать наш ДК.
- В момент собственно копирования ДК информация о копируемости типа — из-за стирания типа — уже недоступна.
В настоящее время выбрано следующее решение проблемы:
- Если объект копируем, то для него создаётся обычный копирующий обработчик.
- Если объект некопируем, то для него создаётся особый обработчик, который при попытке копирования бросает исключение.
template <typename T>
auto copy (const void * source, void * destination)
->
std::enable_if_t
<
std::is_copy_constructible<T>::value
>
{
new (destination) T(*static_cast<const T *>(source));
}
template <typename T>
auto copy (const void *, void *)
->
std::enable_if_t
<
not std::is_copy_constructible<T>::value
>
{
auto type_name = boost::typeindex::ctti_type_index::type_id<T>().pretty_name();
throw std::runtime_error(u8"Объект типа " + type_name + u8" некопируем");
}
Ещё более сложная проблема возникает при сравнении двух ДК на равенство. Можно было бы обойтись тем же способом, но, помимо случая с ошибкой компилятора при попытке сравнить несравнимое, есть ещё случаи, когда компилятор генерирует не ошибку, а предупреждение — например, при вызове оператора равенства для чисел с плавающей точкой. Здесь, с одной стороны, нельзя бросать исключение, потому что пользователь мог осознавать свои действия и производить сравнение намеренно. С другой стороны, хотелось бы всё-таки как-то сообщить пользователю о небезопасной операции. Эта проблема пока остаётся открытой.
Замеры производительности
Поскольку основной целью было создать именно плотно упакованный контейнер, чтобы иметь оптимальное время доступа к его элементам, в замерах производительности сравнивалась скорость доступа в ДК со скоростью доступа в массив указателей на “разбросанные” по памяти объекты.
Применялись две схемы “разбросанности”:
-
Разреженность
Пусть
N
— размер замеряемого массива,S
— показатель разброса. Тогда генерируется массив указателей размераN * S
, а затем он прореживается так, что остаются только элементы под номерамиN * i
,i = 0, 1, 2, ...
. -
Перемешанность
Пусть
N
— размер замеряемого массива,S
— показатель разброса. Тогда генерируется массив размераN * S
, перемешивается случайным образом, а затем из него выбираются первыеN
элементов, а остальные выбрасываются.
А в качестве точки отсчёта времени доступа был взят std::vector
.
Графики подтверждают очевидное:
- Скорость доступа к элементам ДК идентична скорости доступа к элементам класса
std::vector
(одно сложение указателей и одно разыменование). - Доступ к элементам массива указателей медленнее. Особенно это видно на больших массивах при показателе разброса
S > 1
, когда данные перестают помещаться в кэш.
Ссылки
Все исходные коды доступны у меня на гитхабе.
- Техниками я их называю потому, что получившийся контейнер формально является динамическим однородным. Тем не менее, они имитируют желаемый эффект.↩︎
- Объект, размер которого не превышает размер указателя, можно хранить в той же области памяти, что и сам указатель.↩︎
- Давно пора!↩︎
- На самом деле, есть и третий вариант: полиморфные обработчики-классы. Но этот вариант нещадно отметается как самый тормознутый.↩︎
- Подсмотрел в стандартную библиотеку↩︎