понедельник, 2 сентября 2024 г.

[prog.c++] Конструирование объекта в котором физически могут отсутствовать некоторые поля

На прошлой неделе делал интересную штуку: С++ный объект, в котором могут физически отсутствовать некоторые части.

Пришлось заняться этим потому что в текущем проекте было большое количество объектов вида:

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".

Комментариев нет: