На прошлой неделе делал интересную штуку: С++ный объект, в котором могут физически отсутствовать некоторые части.
Пришлось заняться этим потому что в текущем проекте было большое количество объектов вида:
struct data {
mandatory_field_one m_one;
mandatory_field_two m_two;
mandatory_field_three m_three;
std::vector<first_opt_attribute_type> m_first_type_attrs;
std::vector<second_opt_attribute_type> m_second_type_attrs;
std::vector<third_opt_attribute_type> m_third_type_attrs;
};
|
У большинства объектов поля с необязательными атрибутами были пустыми, у некоторых эти поля содержали всего одно-два значения, у отдельных объектов эти атрибуты были довольно-таки большими векторами (местами на несколько десятков тысяч значений).
Объектов типа `data` было много, в некоторых случаях десятки миллионов. И на таком количестве хранение пустых std::vector внутри миллионов объектов типа `data` оказывается очень расточительным (ведь каждый пустой std::vector -- это, как минимум, 24 байта -- size, capacity + указатель на блок с данными).
Объекты же `data` создавались в динамической памяти и ссылки на них хранились как std::unique_ptr.
Причем модель данных не позволяла легко вынести значения опциональных атрибутов из `data` куда-то еще. Например, можно было бы попробовать завести какое-то общее хранилище атрибутов, а в самом `data` тогда хранилась бы ссылка (или индекс) в этом хранилище. Тогда если у объекта атрибутов нет, то в хранилище для объекта ничего нет, а в самом объекте лежит нулевая ссылка. Ну т.е. попробовать бы можно было, но без особых шансов на успех, но зато с большим геморроем 🙁
В общем, хотелось бы, чтобы поля m_first_type_attrs, m_second_type_attrs и m_third_type_attrs таки оставались внутри `data`, но ничего бы не потребляли, если были пустыми.
Здесь бы очень ко двору пришлись бы массивы вроде чего-то такого:
struct data {
mandatory_field_one m_one;
mandatory_field_two m_two;
mandatory_field_three m_three;
std::size_t m_first_type_attrs_count;
first_opt_attribute_type m_first_type_attrs[m_first_type_attrs_count];
std::size_t m_second_type_attrs_count;
second_opt_attribute_type m_second_type_attrs[m_second_type_attrs_count];
std::size_t m_third_type_attrs_count;
third_opt_attribute_type m_third_type_attrs[m_third_type_attrs_count];
};
|
Но в C++ таких массивов нет. Это во-первых. А во-вторых, даже такой способ хранения, будь он возможен, все равно был бы расточительным. Ведь если для объекта нет атрибутов, то поля *_attrs_count с нулевыми значениями в нем все равно есть. Три поля std::size_t -- это 24 байта, умножаем на десяток миллионов объектов `data` и теряем пару десятков мегабайт на ровном месте 🙁
Поэтому было решено прибегнуть к шаблонной магии C++ и сделать класс, который сам рассчитывает, сколько байт ему будет нужно для представления объекта с учетом того, что какие-то поля в нем могут отсутствовать, а какие-то могут иметь переменный размер.
Получилось что-то вроде такого:
// Описание части, которая в объекте присутствует всегда.
struct data_header {
mandatory_field_one m_one;
mandatory_field_two m_two;
mandatory_field_three m_three;
// Вспомогательные типы для идентификации опциональных полей.
struct first_attr_tag {};
struct second_attr_tag {};
struct third_attr_tag {};
// Типы опциональных полей.
using first_attr_vec = compound::vec<first_attr_tag, first_opt_attribute_type>;
using second_attr_vec = compound::vec<second_attr_tag, second_opt_attribute_type>;
using third_attr_vec = compound::vec<third_attr_tag, third_opt_attribute_type>;
};
|
Создается такой объект специальной функцией-фабрикой. Ей на вход подаются начальные значения опциональных полей. Если для какого-то опционального поля значения не заданы, то этого опционального поля и нет.
std::vector<second_opt_attribute_type> attrs{...};
auto my_data = data::build(
// Данные для инициализации фиксированной части должны быть всегда.
data_header{...},
// А вот опциональные данные нужны только те, которые в объекте присутствуют.
data::second_attr_vec::move_from(attrs));
// Доступ к полям фиксированной части возможен напрямую.
std::cout << my_data->m_one << std::endl;
std::cout << my_data->m_two << std::endl;
// Доступ к опциональным полям нужно проверять.
if(my_data->has_field<data::second_attr_tag>()) {
// Поле есть, можно с ним работать.
for(const auto & a : m_data->get_field_ref<data::second_attr_tag>()) {
...
}
}
|
Не буду вдаваться в подробности, т.к. все это делалось в закрытом проекте. Но скажу, что весь фокус тут в функции-фабрике build, которая вычисляет сколько же места потребуется (с учетом необходимых выравниваний) и создает обычный динамический массив std::byte. А уже дальше внутри этого массива посредством placement new размещается все то, что в объекте должно присутствовать.
Для меня реализация этой кухни оказалась на грани моих знаний C++ и возможностей как программиста, где-то, наверное, даже за гранью. Но как-то все это заработало 🤓
Порадовало то, что C++ позволил это сделать. Что еще раз убедило меня в том, что современный C++ в некоторых нишах все еще остается адекватным выбором (даже более чем адекватным, если у вас есть толковые C++ разработчики). C++ позволяет программисту при необходимости перемещаться между разными уровнями абстракции и, если нужно, выполнять всякие хитрые оптимизации. Правда, для этого желательно знать побольше, чем я, и руки иметь попрямее, чем у меня, тогда будет еще проще и дешевле.
Что напрягало, как это функциональный стиль C++ного метапрограммирования (работа велась в рамках C++20). Вся эта рекурсия по спискам типов... 😓 Если сталкиваешься с этим самым метапрограммированием раз в пару лет, то непросто на эти рекурсии перестроится.
Еще, конечно же, доставляла тема с std::launder. Но к ней мне придется вернуться еще раз, как минимум.
В целом же каких-то непреодолимых препятствий не встретилось, со всем удалось справиться. Так что, приходится признать, что C++ развивается в правильном направлении. Не смотря на то, что какие-то вещи в современном C++ мне сильно не нравятся.
ЗЫ. Кстати говоря, пригодился трюк вот из этой статьи: "Pulling a single item from a C++ parameter pack by its index".