Skip to content

Практична рефлексія C++26 для Protobuf

Вступ

Нова редакція стандарту C++ [1], випуск якої очікується у нинішньому, 2026 році, додасть в C++ рефлексію (reflection). Це дозволить розробникам писати код, який досліджуватиме властивості класів, типів, функцій, та інших елементів і робити у них зміни. На момент написання статті, рефлексія хоча і не підтримується основними гілками компіляторів, проте доступна у відгалуженнях як clang [2] так і gcc, які можна зібрати самостійно. Для експериментів нашвидкоруч можна також скористатись Compiler Explorer [A].

У оглядових статтях та відео на цю тему найпоширеніший приклад - друкування довільних структур чи їх серіалізація в JSON. В рамках цієї статті автор пропонує читачу повправлятися разом з автором із дещо складнішим прикладом - десеріалізацією із protobuf.

Огляд

Розділ Цілі та Мета окреслить мету і цілі, розділ статті Інструментарій дасть коротку довідку по фічах рефлексії, що знадобляться для нашої вправи. Розділ Protobuf коротко опише формат сералізації. У розділі Дизайн зформулюємо дизайн рішення. У розділі Імплементація ми напишемо код, а у Верифікація окреслимо методологію тестування. Розділі Підсумки узагальнить результати наших вправ. Розділ Дискусія доповнить деталями щодо намірів чи мотивації деяких аспектів дизайну та імплементації.

Цілі та Мета

Ціль цієї статті - написати рішення для десеріалізації довільних повідомлень у форматі protobuf з можливістю використання в проєктах без динамічної пам’яті (malloc-free). Основна мета - освоїти нові інструменти C++ та ознайомити читачів із прикладами їх практичного використання. Побічна мета - розібратися із форматом серіалізації Protobuf.

Інструментарій

  • ^^expr оператор рефлексії - префікс ^^ - обраховує і повертає значення рефлексії (метаоб’єкта) для заданого операнду expr. Значення рефлексії, або просто рефлексія - це значення непрозорого типу даних std::meta::info,яке можна використати у метафункціях для аналізу чи генерації граматичних конструкцій.
  • [: refl:] Граматична конструкція зрощення (англ. splice) робить зворотну дію - генерує граматичну конструкцію з рефлексії.

    Наприклад [A]

    int main() {
      int x = 1;
      constexpr auto r = ^^x;
      return [: r :];
    }
    
  • nonstatic_data_members_of повертає колекцію метаоб’єктів, що описують не статичні члени даних структури, рефлексія якої передана як аргумент. В деяких випадках, для використання її результату в контекстах часу компіляції його необхідно перетворити в статичний масив функцією define_static_array [3].
  • access_context клас, що репрезентує контекст - неймспейс, клас, чи функцію, з якого виконуються запити, пов’язані і правами доступу до класу, що є предметом запиту.
  • access_context::unchecked функція що повертає контекст без обмежень прав доступу.

    Наприклад [B]

    struct foo {
     int x;
    };
    
    int main() {
     foo bar {12}; 
     constexpr auto refl = define_static_array(nonstatic_data_members_of(^^foo, access_context::unchecked()))[0];
     return bar.[: refl :];
    }
    
  • type_of повертає метаоб’єкт, що описує тип елементу, рефлексію якого переданого в якості аргументу.
  • extract<type> повертає значення, асоційоване із метаоб’єктом, переданим в якості аргументу.
  • template for оператор розширення [4], що розгортає цикл під час компіляції, повторюючи тіло циклу для всіх елементів аргументу циклу. Важливою відмінністю від звичайного for є те що тип змінної циклу, як і всіх змінних в його тілі, визначається для кожної ітерації окремо. Це дозволяє, наприклад, ітерацію по елементам кортежу (tuple).

    Наприклад [C]

    template for (auto i : make_tuple(0, 'a')) {
      println("{}", i);
    }
    
  • [[=v]] анотації - це значення часу компіляції [5], асоційовані із синтаксичною конструкцією, для якої дозволені атрибути. Анотації відрізняються від атрибутів префіксом ‘=’. Вони дозволяють використання користувацьких типів за умови constexpr-есивності їх значень.
  • annotations_of повертає колекцію метаоб’єктів, що описують значення анотацій, асоційованих із аргументом

    Наприклад [D]

    enum [[=11]] foo {};
    
    int main() {
     constexpr auto bar = annotations_of(^^foo)[0];
     return extract<int>(bar);
    }
    
  • data_member_spec повертає метаоб’єкт, що описує новий член даних.
  • define_aggregate додає члени даних до заданого, незавершеного класу/структури/об'єднання і робить його завершеним.
  • consteval { stmt; } позначає блок у якому дозволено виконати мета-функції із стороннім ефектом, як от define_aggregate

    Наприклад [E]

    struct foo;
    
    consteval {
       define_aggregate(^^foo, { data_member_spec(^^int, {"value"}) });
    }
    
    int main() {
     foo bar { 10 }; 
     return bar.value;
    }
    

Protobuf

Protobuf, (Protocol Buffers) - це формат серіалізації даних [6] від Google для кодування структурованих даних в компактний бінарний формат для обміну даними (повідомленнями) між різними системами. Формат повідомлень описується у файлі .proto [7], з якого потім можна згенерувати C++ (чи який інший) код для серіалізації і десеріалізації. Protobuf підтримує базові типи полів (арифметичні, строкові, булеві), користувацькі (енумерації та структури), послідовності (repeated), об’єднання (oneof), та мапи (map).

Цілі типи однозначно вказують їхню розрядність (int32, int64) та підтип fixed чи signed (fixed32, sint64). Fixed типи записуються фіксованою кількістю байт а інші типи - варіативною, мінімально-необхідною кількістю байт.

У бінарному форматі поля позначаються їхніми номерами із proto та типом, і можуть слідувати у довільному порядку чи бути пропущеними [8]. І хоча в редакції proto2 поля можна позначити як required проте це робити не рекомендується (required: Do not use), а в proto3 усі поля опціональні.

Дизайн

Рішення

  1. Тип повідомлень Protobuf для C++ коду створюватиме або транслюватиме користувач, використовуючи структури даних C++, підтримувані типи даних, та анотації
  2. Номери полів призначатиме користувач, додаючи анотацію типу int із номером поля
  3. Для скалярних типів і підтипів даних Protobuf визначимо аналоги - enum із відповідними базовими типами int32, sint32, uint32, fixed32, sfixed32, int64, sint64, uint64, fixed64, sfixed64
  4. Підтримаємо також деякі нативні типи C++: bool, int32_t, int64_t, uint32_t, uint64_t, float та double, що репрезентуватимуть Protobuf типи bool, sint32, sint64, uint32, uint64, float, та double відповідно
  5. Для енумераційних типів Protobuf користувач визначатиме аналогічні в C++, а наше рішення дозволить їх використання в структурах повідомлень.
  6. Рядки символів Protobuf string - підтримаємо з використанням std::string, char[N] чи std::array<char,N> на вибір користувача
  7. Вкладені повідомлення підтримуємо як вкладені структури чи як std::unique_ptr на такі структури
  8. Для полів послідовностей (repeated) підтримаємо статичні масиви та контейнери з методом push_back - vector, list
  9. Для усіх типів, окрім контейнерів, дозволимо використання обгорток std::optional
  10. Protobuf map - std::map або std::unorderd_map на вибір користувача
  11. oneof - анонімне об'єднання (union) або std::variant на вибір користувача
  12. Упаковані послідовності (packed) також позначатимемо анотацією packed
  13. В якості вхідного потоку даних використаємо std::istream
  14. Нерозпізнані та надлишкові елементи вхідного потоку - ігноруємо
  15. Для десеріалізації полів використаємо диспетч-таблицю із функціями для читання полів повідомлення
  16. Таблицю заповнюємо з використанням рефлексії для усіх полів повідомлення
  17. Результат функції десеріалізації повідомлення матиме набір прапорців, як індикацію наявності значень у полях

Визначення

Згідно до прийнятих рішень, можемо зробити наступні визначення:

  1. поле - це не статичний член структури із номером в анотації
  2. повідомлення - це структура (клас) що має хоча б одне поле

Принцип роботи

  1. Користувач створює структуру повідомлення, чи транслює її з proto
  2. Користувацький код викликає функцію deserialize із вхідним потоком і екземпляром структури повідомлення
  3. Наш код під час компіляції заповнює диспетч-таблицю із функціями для читання полів
  4. В процесі десеріалізації наш код
    1. Для кожного елементу у входному стрімі пробує усі функції читання полів поки не досягне успіху
    2. якщо елемент не розпізнали - пропускає його
    3. по завершенню десеріалізації повертає набір флагів, як індикацію наявності полів

Приклад трансляції

Protobuf C++
enum Enum {
 NOTOK = 0;
 OK = 42;
}

message Example {
 sint32 signed_int = 1;
 uint32 unsigned_int = 2;
 fixed32 fixed_32 = 3;
 string text = 4;
 Enum status = 5;
 repeated double array_of_double = 6;
 map<sint32, string> map_int_to_text = 7;
 oneof Union {
   sint64 int_alternative = 8;
   double double_alternative = 9;
 }
}
enum class Enum {
 NOTOK = 0,
 OK = 42
};

struct Example {
 sint32 signed_int [[=1]];
 uint32 unsigned_int [[=2]];
 fixed32 fixed_32 [[=3]];
 string text [[=4]];
 Enum status [[=5]];
 vector<double> array_of_double [[=6]];
 map<sint32, string> map_int_to_text [[=7]];
 union {
   sint64 int_alternative [[=8]];
   double double_alternative [[=9]];
 };
};

Імплементація

Скелет функції десеріалізації повідомлення

Втілюючи рішення, прийняті у попередньому розділі напишемо скелет функції десеріалізації “у першому підході”:

template<message Message>
auto deserialize(std::istream &input, Message &msg) {
 // Під час компіляції заповнюємо диспетч-таблицю
 static constexpr auto field_readers = make_field_readers<Message>();
 deserialize_result_type<Message> result { };
 while (input.good()) { // Поки можна читати
   const auto [num, type] = read_id_type(input); // Читаємо номер поля і тип
   if (! input.good()) break;
   deserialize_result_type<Message> field_success { };
   for (auto reader : field_readers) { // Пробуємо застосувати кожен елемент із таблиці
     if (const auto attempt_success = reader(input, msg, num, type); attempt_success) {
       field_success = attempt_success; // Якщо успішно прочитали, завершуємо спроби
       break;
     }
   }
   if (field_success) {
     result |= field_success;
   } else {
     skip(input, type);
   }
 }
 return result;
}

Онлайн приклад цієї функції можна знайти за посиланням [F]. Поки що це звичний C++ код, ніяких фіч із C++26 не потребує.

Функція генерації диспетч-таблиці make_field_readers

Функція-шаблон make_field_readers для генерації методів читання полів проходиться по всіх полях повідомлення і для кожного генерує функцію читання, підставляючи параметри у шаблонну функцію field_reader (приклад [G]). Тут нам стане у нагоді оператор зрощення ([:field:]). А щоб його можна було використати у циклі, змінна field має бути constexpr, що можливо лише у операторі розширення template for. Відповідно список розширення має бути статичним, тобто функція fields_of має використати define_static_array, так само як і make_field_readers

template<message Message>
consteval auto make_field_readers() {
 std::vector<field_reader_type<Message>> result {};
 template for(constexpr auto field : fields_of<Message>()) {
   result.push_back(field_reader<field, num_of(field)>);
 }
 return std::define_static_array(result);
}

Функція читача поля field_reader

Ця шаблонна функція перевіряє відповідність отриманого типу номера поля відносно очікуваного, і якщо збігаються, викликає функцію deserialize для типу цього поля. Типів десеріалізації очікується декілька, то ж наша deserialize буде перевантажена, і одним із перевантажень буде написана нами раніше функція десеріалізації повідомлення.

template<std::meta::info Field, int Num>
deserialize_result_type<class_of<Field>>
field_reader(std::istream &input, class_of<Field> &obj, int num, data_type type) {
 using Type = data_type_of<Field>;
 if (num == Num && matches<Type>(type)) {
     deserialize(input, obj.[:Field:]);
   return deserialized<Field>();
 }
 return {};
}

Функція num_of

Ця функція знаходить і повертає першу анотацію цілого типу (int) у вхідному параметрі-рефлексії. Для її написання використаємо annotations_of, щоб отримати список анотацій, type_of - щоб дізнатись тип даних анотації, та extract<int> - для отримання значення (приклад [H]).

consteval auto num_of(std::meta::info member) {
 auto annotations = std::meta::annotations_of(member);
 auto found = std::ranges::find_if(annotations, [](auto annotation) {
   return std::meta::type_of(annotation) == ^^int;
 } );
 return found == annotations.end() ? invalid_num : std::meta::extract<int>(*found);
}

Функція fields_of

Ця функція повертає список полів класу, переданого як параметр шаблону (приклад [H]). Тут використовується оператор рефлексії ^^Class, та nonstatic_data_members_of, щоб отримати перелік не статичних членів даних і відфільтрувати їх за наявністю номера в анотації.

template<class Class>
consteval auto fields_of() {
 constexpr auto ctx = std::meta::access_context::unchecked();
 std::vector<std::meta::info> fields{};
 template for(constexpr auto member : define_static_array(nonstatic_data_members_of(^^Class, ctx))) {
   if constexpr (num_of(member) != invalid_num )
     fields.push_back(member);
 }
 return std::define_static_array(fields);
}

Концепція message

Тепер ми маємо достатньо засобів, щоб визначити концепцію message

template<class Message>
concept message = std::is_class_v<Message> && (fields_of<Message>().size() != 0);

Тип результату десеріалізації deserialize_result_type

Цей тип створимо, як набір бітових полів, кожне з яких відповідатиме полю у повідомленні. А щоб було простіше користуватись, імена бітових полів зробимо тотожними іменам полів повідомлення. Для цього скористаємось define_aggregate та data_member_spec щоб створити визначення для нашого незавершеного типу deserialize_result_type (приклад [I]), та identifier_of щоб отримати ідентифікатор поля повідомлення.

template<message Message>
struct deserialize_result_type;

template<typename Message>
consteval auto define_result() {
 return define_aggregate(^^deserialize_result_type<Message>,
           std::views::transform(fields_of<Message>(), [](auto field) {
   return data_member_spec(^^bool, {identifier_of(field), {}, 1U});
 }));
}

Для завершення визначення, цю функцію слід викликати в consteval блоці

struct foo {
 int num1 [[=1]];
 long num2 [[=2]];
};

consteval { define_result<foo>(); }

Таке рішення робоче, проте воно покладає на користувача обов’язок викликати define_result для кожного типу повідомлення. Щоб це відбулося автоматично, за потребою, цей consteval блок має бути у контексті, де Message присутній як параметр, тобто допоміжній шаблонній структурі. А щоб field_reader міг повернути результат з бітом, що відповідає прочитаному полю, потрібна функція, яка б встановлювала цей біт по значенню рефлексії поля. Зробимо це все у допоміжному шаблоні (приклад [J])

template<message Message>
struct deserialize_helper {
 struct result_type;

 static consteval auto define_result() {
   return std::meta::define_aggregate(^^result_type, std::views::transform(fields_of<Message>(), [](auto field) {
     return std::meta::data_member_spec(^^bool, {std::meta::identifier_of(field), {}, 1U});
   }));
 }
 consteval { define_result(); }

 static consteval result_type set_by_name_of(auto Info) {
   struct result_type result {};
   template for(constexpr auto bit : members_of<result_type>()) {
     if (identifier_of(Info) == identifier_of(bit)) {
       result.[:bit:] = true;
     }
   }
   return result;
 }

 template<std::meta::info Member>
 requires (std::same_as<Message, class_of<Member>>)
 static constexpr result_type deserialized() {
   template for(constexpr auto field : fields_of<class_of<Member>>()) {
     if constexpr(field == Member) {
       return set_by_name_of(field);
     }
   }
   return {};
 }
};

template<message Message>
using deserialize_result_type = deserialize_helper<Message>::result_type;

template<std::meta::info Field>
constexpr auto deserialized() {
 return deserialize_helper<class_of<Field>>::template deserialized<Field>();
}

(Не)Упаковані послідовності

Упакованість послідовностей Protobuf означає, що її елементи будуть слідувати один за одним, що дозволяє зекономити на номері поля. Елементи неупакованих послідовностей можуть іти в перемішку із іншими полями, кожному із них передує номер поля. Послідовності примітивних типів упаковані по замовчуванню, проте можуть бути позначені як неупаковані. Для такого позначення визначимо тип даних

enum class packed {
 default_,
 packed,
 unpacked
};

Цей тип даних користувачі використовуватимуть для позначення не упакованих послідовностей скалярних типів:

struct foo {
 int unp[2] [[=1, =packed::unpacked]];
 long pack[2] [[=2, =packed::packed]];
 long norm[2] [[=3]];
};

Щоб використати цей атрибут у нашому коді, визначимо структуру із атрибутами proto, що підтримуються в нашій імплементації (поки що тільки packed)

struct attributes {
 packed packed;
};

Та функцію вичитування атрибутів засобами рефлексії (приклад [K]):

consteval auto attrs_of(std::meta::info member) {
 return std::ranges::fold_left(std::meta::annotations_of(member), attributes { },
     [](attributes attrs, auto annotation) {
       if (type_of(annotation) == ^^packed)
         attrs.packed = std::meta::extract<packed>(annotation);
       return attrs;
     });
}

Отримані атрибути використаємо в функції field_reader

На цьому потреби в використанні рефлексії вичерпані, далі наша імплементація використовуватиме лише старий добрий C++23.

Серіалізація примітивних типів

В Protobuf використовується кодування з варіативною бітністю [7], у якому байт вхідного потоку містить сім інформативних біт і один службовий, нульове значення якого вказує що це термінальний байт, а не нульове, відповідно, на проміжний. Для читання варіативних даних напишемо функцію

inline std::uint64_t read_variant(std::istream &input) {
 static constexpr char high = 0x80;
 std::uint64_t val { };

 char chr { };
 unsigned count = 0;
 for (bool done = false; !done && input.get(chr).good(); done = (chr & high) != high, count += 7) {
   val |= std::uint64_t(chr & 0x7F) << count;
 }
 return val;
}

До цілих типів зі знаком (signed int) застосовується ще й зигзаг кодування, щоб зменшити кількість ненульових старших біт. Декодувати зигзаг можна такою функцією:

template<typename T>
constexpr auto decode_zigzag(std::uint64_t value) noexcept {
 return static_cast<T>((value >> 1) ^ (-(value & 1)));
}

Однією із особливостей protobuf є можливість пропустити елемент даних базуючись лише на даних із вхідного потоку. Необхідність щось пропустити може виникнути через наявність нових полів у вхідному потоці, що не мають відповідників у локальних дефініціях типів повідомлень. Функція для пропуску таких елементів може мати такий вигляд:

inline auto skip(std::istream &input, data_type type) {
 switch (type) {
 case data_type::fixed32:
   input.ignore(sizeof(fixed32));
   break;
 case data_type::fixed64:
   input.ignore(sizeof(fixed64));
   break;
 case data_type::variant:
   read_variant(input);
   break;
 case data_type::lengthy:
   input.ignore(read_variant(input));
 }
}

Типи даних і концепції

Визначимо типи даних відповідники типів у Protobuf

enum int32 : <code>std::int32_t</code> {};
enum sint32 : <code>std::int32_t</code> {};
enum uint32 : <code>std::uint32_t</code> {};
enum fixed32 : <code>std::uint32_t</code> {};
enum sfixed32 : <code>std::int32_t</code> {};
enum int64 : <code>std::int64_t</code> {};
enum sint64 : <code>std::int64_t</code> {};
enum uint64 : <code>std::uint64_t</code> {};
enum fixed64 : <code>std::uint64_t</code> {};
enum sfixed64 : <code>std::int64_t</code> {};

Згрупуємо їх по способам серіалізації за допомогою концепцій:

template<template<typename A, typename B> class Pred, typename A, typename ... B>
consteval auto anyof() {return std::disjunction_v<Pred<A, B>...>;}

template<typename Type>
concept enumeration = std::is_enum_v<Type>
   && !anyof<std::is_same, Type, int32, sint32, uint32, fixed32, sfixed32, int64, sint64, uint64, fixed64, sfixed64>();

template<typename Type>
concept variant_integral = anyof<std::is_same, Type, bool, std::uint32_t, int32, uint32, std::uint64_t, int64, uint64>()
   || enumeration<Type>;

template<typename Type>
concept zigzag_integral = anyof<std::is_same, Type, std::int32_t, sint32, std::int64_t, sint64>();

template<typename Type>
concept fixed_arithmetic = anyof<std::is_same, Type, fixed32, sfixed32, fixed64, sfixed64, float, double>();

Та напишемо функції десеріалізації для кожної із цих концепцій

template<variant_integral Type>
void deserialize(std::istream& input, Type& value) {
 value = static_cast<Type>(read_variant(input));
}

void deserialize(std::istream& input, fixed_arithmetic auto& value) {
 input.read(static_cast<char*>(static_cast<void*>(&value)), sizeof(value));
}

template<zigzag_integral Type>
void deserialize(std::istream& input, Type& value) {
 value = decode_zigzag<Type>(read_variant(input));
}

Заповнення динамічних масивів будемо робити з використанням push_back, resize, та move елементів, то ж визначимо відповідні концепції:

template<typename Container>
concept resizable = requires(Container c) {
   c.resize(1);
};

template<typename Container>
concept back_insertable = requires(Container container) {
 container.push_back(std::declval<typename Container::value_type>());
};

концепцію back-insertable контейнера

template<typename Container>
concept container = back_insertable<Container> && (scalar<typename Container::value_type> ||
 (std::is_class_v<typename Container::value_type> && elementary<typename Container::value_type>));

та концепцію асоціативного контейнера

template<typename Container>
concept associative = requires(Container c, Container::key_type k, Container::mapped_type v) {
 c[k] = v;
};

Оскільки статичні масиви не відповідають концепції back-insertable контейнера, визначимо концепцію обмеженого масиву:

template<typename Array>
concept bounded_array =
   (std::is_bounded_array_v<Array> && elementary<std::remove_extent_t<Array>>) ||
   (std::ranges::output_range<Array, typename Array::value_type> && !resizable<Array>
    && elementary<typename Array::value_type>);

Для кожної із цих концепцій напишемо функцію deserialize

Підтримка oneof

Версія clang з підтримкою рефлексії на момент написання статті не дозволяла звертатись до полів анонімних об’єднань [9], як до звичайних полів. Натомість потребувала ще й рефлексію на саме анонімне об’єднання: obj.[:outer:].[:Field:]. Щоб обійти це, додамо окрему функцію union_reader.

Що до полів типу std::variant, то такому полю потрібен номер на кожну альтернативу, та виклик emplace<Index>() перед доступом до альтернативи. Для цього теж напишемо окрему функцію oneof_reader. Це, відповідно, трохи ускладнить і функцію make_field_readers, що тепер має вибирати одну із трьох функцій читання.

template<message Message>
consteval auto make_field_readers() {
 std::vector<field_reader_type<Message>> result {};
 template for(constexpr auto field : fields_of<Message>()) {
   if constexpr(is_variant_type(type_of(field))) {
     constexpr auto nums = nums_of(field);
     constexpr unsigned n = std::min(nums.size(), template_arguments_of( type_of(field)).size());
     template for(constexpr auto i : std::views::iota(0u, n)) {
       result.push_back(oneof_reader<field, nums[i], i>);
     }
   } else if constexpr(is_union_type(parent_of(field)) && ! has_identifier( parent_of(field))) {
     result.push_back(union_reader<field, num_of(field)>);
   } else {
     result.push_back(field_reader<field, num_of(field)>);
   }
 }
 return std::define_static_array(result);
}

Вкладені повідомлення

Довжина вкладених повідомлень, записується перед даними повідомлення, і функція читання не має вийти за межі цієї довжини. В той час як для кореневого повідомлення довжина не записується і функція читання обмежена лише довжиною потоку даних. Щоб імплементувати ці особливості не повторюючи тіло функції deserialize, додамо параметр - вказівник на функцію що читатиме довжину вкладених повідомлень. Для кореневих повідомлень передамо вказівник на функцію unlimited, яка просто поверне константу max_length.
Остаточна версія нашого рішення десеріалізації подана а прикладі [L].

Верифікація

Компілятор protoc дозволяє серіалізувати дані для довільного повідомлення описаного в форматі proto [10].

protoc --encode=tests.numeric.SInt_32 numeric.proto

Скористаємось цією можливістю для підготовки тестових векторів для функціональних тестів нашого рішення. На жаль, обмеження часу компіляції, встановлені на compiler-explorer, дозволяють скомпілювати онлайн лише незначну кількість тестів. Офлайн рішення містить 400+ тестів і буде доступно на github.

Пiдсумки

Рефлексія в C++26, навіть у її початковому вигляді, доволі потужний інструмент, що дозволить обійтись без зовнішніх кодогенерацій у багатьох із тих застосувань що нині неможливі без таких. Інструменти рефлексії доволі прості для розуміння і освоєння, і їх використання зменшить кількість boilerplate коду, спростить імплементації серіалізації та десеріалізації, та посилить можливості метапрограмування.

Дискусія

Чи потрібна диспетч таблиця?

Всі функції, що оперують над рефлексією, мають бути виконані в consteval контексті. constexpr диспетч таблиця гарантує такий контекст і є доволі простою у створенні і використанні. Автор допускає що можуть бути рішення і без такої таблиці.

Між рефлексією та системою типів

Функціями рефлексії можна вирішити усі завдання над типами, що раніше виконувалися, наприклад, спеціалізацією шаблонів. Однак автору не вдалось знайти простого рішення для заміни спеціалізації

template<typename>
struct member_traits;

template<class Class, typename Type>
struct member_traits<std::optional<Type> Class::*> {
 using value_type = Type;
};

Можливо спеціалізація шаблону є, і залишиться простішим рішенням, ніж написання функцій в просторі рефлексій.

Диференціація підтипів

protobuf визначає кілька підтипів для цілих типів - fixed32, sfixed32, sint32, int32, uint32 і аналогічно для 64 бітних. Підтип впливає на те, як серіалізується значення. Наприклад fixed32/sfixed32 завжди кодуються в 4 байти, інші використовують варіативне кодування, а sint32/sint64 ще й зигзаг кодування.
Можна було б визначити відповідні аліаси для підтипів і скористатись властивістю оператора рефлексії повертати відмінні об’єкти info для різних аліасів, проте у дефініції класів ці типи з’являються як де-аліасед.

Для цих експериментів автор використав прості енуми. Проте слід враховувати що enum “ламає” стандартні запити властивостей типів, як от numeric_limits, is_integral, is_signed, is_unsigned, то що. Тільки деякі із них можна спеціалізувати (numeric_limits), то ж використання структур повідомлень з такими типами полів в інших шаблонах чи бібліотеках може бути ускладнене.

Індикатори наявності значень

Оскільки усі поля повідомлення опціональні, слід вирішити як повідомляти клієнту які полі прочитали а які - ні. Один із варіантів використовувати std::optional. Проте це може призвести до небажаного “роздування” структури. Інший варіант - повертати із операції десеріалізації набір флагів як індикацію наявності/відсутності поля. Однак реалізація такого підходу для вкладених структур може виявитись нетривіальною і не зручною у використанні.

Значення поля за замовчуванням також може бути індикацією відсутності значення, оскільки protobuf при серіалізації пропускає поля із значенням по замочуванню, за винятком упакованих масивів.

В цій статті реалізовано підтримка std::optional та набір флагів для кореневого повідомлення.

oneof

Документація protobuf визначає що поля, об’єднані oneof, десеріалізуються так, ніби вони були розміщені безпосередньо у повідомленні. Це відповідає анонімним об’єднанням в C++. Проте об’єднання мають певні проблеми із безпечністю використання. Тому в цьому рішенні автор також реалізував ще і підтримку std::variant із відповідною кількістю номерів в анотаціях:

std::variant<int, double, std::string> oneof [[=4, =5, =6]];

Збирання clang з рефлексією

Сирці clang з підтримкою рефлексії доступні за посиланням [2]. Автор використовував такі команди, щоб зібрати компілятор

cmake -S llvm -B build -DCMAKE_BUILD_TYPE=Release -DLLVM_ENABLE_PROJECTS="clang" -DLLVM_ENABLE_RUNTIMES="libcxx;libcxxabi;libunwind"
cmake --build build --parallel 5

protoc

Компілятор protobuf доступний як стандартний пакет на Linux збірках. На Ubuntu його можна інсталювати командою

sudo apt-get install protobuf-compiler

Можливий розвиток

  • Валідація типів повідомлень на предмет
    • не унікальності номера поля
    • наявності більше ніж однієї цілої анотації на поле,
    • невідповідність кількості номерів полів кількості альтернатив у варіанті, тощо
  • Серіалізація
  • Абстрагування від iostream

Перелік посилань

  1. Reflection for C++26
    https://www.open-std.org/jtc1/sc22/wg21/docs/papers/2025/p2996r13.html
  2. LLVM fork for P2996
    https://github.com/bloomberg/clang-p2996
  3. define_static_{string,object,array}
    https://www.open-std.org/jtc1/sc22/wg21/docs/papers/2025/p3491r3.html
  4. Expansion Statements
    https://www.open-std.org/jtc1/sc22/wg21/docs/papers/2025/p1306r5.html
  5. Annotations for Reflection
    https://www.open-std.org/jtc1/sc22/wg21/docs/papers/2025/p3394r4.html
  6. Protocol Buffers
    https://uk.wikipedia.org/wiki/Protocol_Buffers
  7. Language Guide (proto 3)
    https://protobuf.dev/programming-guides/proto3/
  8. Encoding
    https://protobuf.dev/programming-guides/encoding/
  9. Reflection of an anonymous union member is not giving expected results
    https://github.com/bloomberg/clang-p2996/issues/251
  10. Protocol buffer compiler
    https://manpages.debian.org/testing/protobuf-compiler/protoc.1.en.html

Перелік онлайн прикладів коду

  1. https://compiler-explorer.com/z/511q4rPPY
  2. https://compiler-explorer.com/z/ccaxa79WY
  3. https://compiler-explorer.com/z/aePE7Wvhh
  4. https://compiler-explorer.com/z/hjG56Wqoj
  5. https://compiler-explorer.com/z/43aaeMK4q
  6. https://compiler-explorer.com/z/dqv4874Ee
  7. https://compiler-explorer.com/z/sEh8T7qcj
  8. https://compiler-explorer.com/z/vqdarbqMe
  9. https://compiler-explorer.com/z/Teesnh4db
  10. https://compiler-explorer.com/z/nrn4srWfs
  11. https://compiler-explorer.com/z/hGv5azh69
  12. https://compiler-explorer.com/z/oPcTEh9vv

Post a Comment

Your email is never published nor shared. Required fields are marked *
*
*

*