C++ поистине противоречивый язык. Старый добрый С существует аж с 1972 года, С++ появился в 1985 и сохранил с ним обратную совместимость. За это время его не раз хоронили, сперва Java, теперь его потихоньку продолжают хоронить Go и Rust. Все его недостатки пережеваны множество раз. Если вы пришли в мир С++ из других ООП языков, то здесь вы не найдете:

  • Внятного стектрейса если где-то стрельнет исключение или SEGFAULT

  • Внятных сообщений об ошибках в некоторых случаях(в большинстве)

  • Естественно здесь нет сборки мусора, все придется делать руками

  • Чего-то стандартного, будь то система сборки, пакетный менеджер, решение для тестирования или даже компилятор.

  • И конечно же рефлексии

Им действительно тяжело пользоваться, особенно в крупных проектах, но он предоставляет большие возможности и пока не собирается на покой. На нем пишут игровые движки, софт для embedded систем, его используют Яндекс, VK, Сбер, множество финтех, крипто и блокчейн стартапов. Все потому что у С++ вместе с тем хватает и достоинств:

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

  • Умопомрачительные шаблоны и сопутствующая магия

  • Код, выполняемый во время компиляции

  • Богатая стандартная библиотека и Boost

  • Малый размер скомпилированного файла

  • Поддержка всех возможных архитектур и операционных систем

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

Проблема

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

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

struct TempHumData {
  string sensor_name;
  uint sensor_id;
  string location;
  uint update_interval_ms;
 
  struct Value {
    int temperature;
    uint humidity;
  };
 
  Value value;
}

Обычно, языки программирования позволяют работать с JSON как с DOM т.е. древовидной структурой данных описывающей некий объект. Свойства объекта могут быть числом, строкой или другим объектом. В С++ других вариантов нет:

#include "nlohmann/json.hpp"
 
nlohmann::json json;
 
json["sensor_name"] = "living_temp_hum";
json["sensor_id"] = 47589431;
json["location"] = "living_room";
json["update_interval_ms"] = 1000;
 
nlohmann::json nested_val;
 
nested_val["temperature"] = 24.3;
nested_val["humidity"] = 48;
 
json["value"] = nested_val;

К счастью есть возможность создать объект через парсинг JSON строки.

auto json = nlohmann::json::parse(json_str);

И где-то в другом месте проекта можно получить из него данные:

auto sensor = json["sensor_name"].get<std::string>();

Чем больше полей в объекте и чем шире он используется, тем хуже будут последствия. Любые более-менее серьезные изменения становятся болезненными и рутинными:

  • Название полей("sensor_name") это просто текст, поэтому и искать его придется как текст и редактировать тоже как текст. Никакого умного переименования в IDE.

  • Ошибки в имени никак не скажутся на компиляции, вместо этого в рантайме получим дефолтное значение, что не всегда очевидно.

  • Легко неправильно преобразовать тип - float в int или int в uint

И конечно же, приложение будет работать некорректно, а узнаете вы об этом не сразу, возможно в продакшене.

Есть вариант вручную присвоить полям структуры значения из DOM в отдельном файле:

TempHumData deserialize(const nlohmann::json& json) {
  TempHumData result;
 
  result.sensor_name        = json["sensor_name"].get<std::string>();
  result.sensor_id          = json["sensor_id"].get<uint>();
  result.location           = json["location"].get<std::string>();
  result.update_interval_ms = json["update_interval_ms"].get<uint>();
  result.value.temperature  = json["value.temperature"].get<int>();
  result.value.humidity     = json["value.humidity"].get<uint>();
 
  return result;
}

Тогда мы сможем пользоваться структурой. Ошибки будут в одном месте, но поможет это не сильно. Представьте, что будет, если количество полей устремится за 100+ или понадобится парсить множество разных JSON, полученных через REST API или из базы данных. Придется писать сотни строк кода, часто нажимать Ctrl+C, Ctrl+V, и человеческий фактор обязательно даст о себе знать. Кроме того, это придется проделывать каждый раз когда что-то меняется в объекте. В таком случае, ручной маппинг в структуру приносит больше боли чем пользы. 

Если мы используем другой язык программирования, можно сериализовать сам объект непосредственно и, соответственно, десериализовать JSON в объект.

Код на Go, имеющий такое поведение, выглядит следующим образом:

import “encoding/json"
 
type TempHumValue struct {
 Temperature float32 `json:"temperature"`
 Humidity    uint    `json:"humidity"`
}
 
type TempHumData struct {
 SensorName       string       `json:"sensor_name"`
 SensorId         uint         `json:"sensor_if"`
 Location         string       `json:"location"`
 UpdateIntervalMs uint         `json:"update_interval_ms"`
 Value            TempHumValue `json:"value"`
}
 
 
// somewhere
 
data := TempHumData{/* some data */}
 
bytes, _ := json.Marshal(data)
json_str := string(bytes)

В C# подобным функционалом обладает Newtonsoft Json, а в Java Jackson2 ObjectMapper

В таком случае код парсинга и преобразования в структуру уже написан за нас и скрыт за интерфейсом, тип значения определяется автоматически, а любые изменения объекта остаются только в одном месте - в файле с определением структуры. Сам исходный код становится для нас своего рода контрактом. Кроме того, JSON либо корректно распарсится весь целиком либо не распарсится вовсе.

Все это становится возможным благодаря рефлексии(reflection, отражение) - способности программы понимать как именно она была написана. Как называются объекты, какого они типа, какие поля у них есть и сколько их, приватные они или публичные и т.д. Все это хранится в каком-то месте собранной программы и имеется логика, позволяющая эту информацию запрашивать.

кстати

Рефлексия полезна не только для сериализации/десериализации, но и для вызова методов по их имени, например, по наступлению событий в игровых движках или для реализации RPC. Но мы не будем реализовывать это в данной статье т.к. на самом деле решаем конкретную проблему, а рефлексия это только способ ее решения.

Одной из основных идей С++ является: “Мы не платим за то, что не используем”. И отсутствие такого механизма как рефлексия хорошо укладывается в рамки этой идеи. Примерный код на ассемблере, получаемый после компиляции Hello World:

section .data
  msg  db   'Hello world!'
  len  equ  $-msg
section .text
  mov rax, 1   ; set write as syscall
  mov rdi, 1   ; stdout file descriptor
  mov rsi, msg ; source buffer
  mov rdx, len ; number of bytes
  syscall      ; call write

Мы не храним информацию об исходном коде в привычном для программиста виде. Статические данные(секция .data) и набор инструкций(секция .text) просто упаковываются в бинарный файл. Тем самым, минимизируется размер файла и не тратится время на лишнюю инициализацию объектов в динамической памяти. В конце концов классы, функции, переменные - все это высокоуровневые абстракции нужные человеку, а не процессору.

Настало время рассказать немного о Rust. У него очень много общего с С++. Он построен на llvm(инструментарий компилятора С++), у него нет сборщика мусора, и он так же не поддерживает рефлексию. Но тем не менее у него есть очень классный serde, который не уступает решениям из других языков.

use serde::{Deserialize, Serialize};
 
#[derive(Serialize, Deserialize)]
struct TempHumValue {
   temperature: f32,
   humidity: u32,
}
 
#[derive(Serialize, Deserialize)]
struct TempHumData {
   sensor_name: String,
   sensor_id: u32,
   location: String,
   update_interval_ms: u32,
   value: TempHumValue,
}
 
// somewhere
 
let data = TempHumData {/* some data */};
 
let json_str = serde_json::to_string(&data).unwrap());

Секрет в данном случае прост, но не совсем очевиден. Rust имеет мощный механизм макросов, благодаря которому перед компиляцией генерируется код, содержащий логику сериализации всей структуры поле за полем. Почти как в случае с ручным маппингом, только код пишет за нас компилятор.

Мы многое сделаем похожим на Rust и serde, но при этом немного отделим мух от котлет и разделим сериализацию и рефлексию. При всем при этом ни разу не заплатим за то, что не будем использовать.

Решение

Прежде всего надо определиться с принципами работы нашего решения. Если коротко, пошло и без интриги, то нам придется:

  • Написать библиотеку рефлексии которая позволит анализировать объекты, копировать их, создавать новые и т.д.

  • Добавить в нее поддержку стандартных типов: 

    • int, float и другие примитивы

    • строки

    • массивы

    • стандартные контейнеры, такие как std::vector и т.д.

  • Так же как в serde придется анализировать исходный код и генерировать новый, чтобы добавить поддержку новых типов - пользовательских enum(class), struct и class.

  • В конце концов написать сериализацию/десериализацию для нужных форматов.

Библиотека

Первая цель которой нам надо добиться - абстрагироваться от конкретного типа. Это довольно важный для понимания момент и на нем следует остановиться. Интуитивно, я бы хотел написать примерно такой код:

template <typename T>
void serialize_recursive(const T* obj) {
  std::vector<???*> fields = reflection::get_fields_of<T>(obj);
 
  for (auto&& one_field : fields) {
    serialize_recursive(one_field);      
  }
}

template <>
void serialize_recursive<int>(const int* obj) {
	// serealize int
}

template <>
void serialize_recursive<bool>(const bool* obj) {
  // serealize bool
}

Мне бы хотелось чтобы в fields хранились указатели разных типов на поля объекта, но это невозможно из-за особенностей языка. Компилятор просто не знает как физически хранить такие данные. Он так же не может знать какие именно типы могут там храниться, чтобы корректно вывести тип one_field, сгенерировать код для всех <T> и рекурсивно вызывать функцию. Сейчас мы работаем с одним объектом, через секунду с другим и у всех разное количество полей и их тип.

Поэтому, как вариант, можно разруливать типы в рантайме. Иными словами динамическая типизация, ну почти.

Первая сущность которая нам понадобится - Var . Как ясно из названия это нечто, что представляет из себя переменную. Var хранит в себе:

  • указатель с типом void* на данные нашей переменной

  • id типа переменной

  • признак константная переменная или нет

У Var есть шаблонный конструктор, который принимает указатель произвольного типа, вычисляет id и стирает тип указателя преобразуя его к void*.

Получение id типа один из ключевых моментов. Монотонно возрастающий id дает возможность построить таблицу с указателями на функции, где id выполняет роль индекса и позволяет быстро вызывать нужную функцию. Это основная идея работы всей библиотеки рефлексии. Имея id типа и void* на данные мы можем вызвать либо:

static void copy(void* to, const void* from) {
  *static_cast<int*>(to) = *static_cast<const int*>(from);
}

либо:

static void copy(void* to, const void* from) {
  *static_cast<float*>(to) = *static_cast<const float*>(from);
}

Так, мы можем копировать переменные, создавать новые экземпляры и т.д. Нужно только добавить в таблицу указатель на функцию для конкретного действия.

кстати

В случае, если необходимо создать новый объект и вернуть его из функции, к сожалению, не обойтись без динамического выделения памяти. Компилятор должен знать тип(размер) объекта, если память аллоцируется на стеке. Следовательно, память придется выделять в куче, а возвращаемый тип сделать универсальным т.е. void* или Var.

Стандартный для C++ механизм получения id типа  typeid(T).hash_code() не даст монотонно возрастающей последовательности поэтому нам не подойдет.

Придется изобрести свой TypeId который будет содержать единственный int в качестве данных и дополнительную логику. По умолчанию он инициализируется значением 0 - неизвестный тип, остальные значения задаются через специализации. Например:

TypeId TypeId::get(int* /*unused*/) {
 static TypeId id(TheGreatTable::record(Actions(
                &IntActions::reflect,
                &IntActions::call_new,
                &IntActions::call_delete,
                &IntActions::copy)));
 return id;
}

Я оставил только необходимое для понимания, оригинал в репозитории.

Здесь есть довольно хитрый момент. Специализация TypeId::get(T* ptr) использует приватный конструктор TypeId, который принимает число - собственно id. Это число мы получаем вызовом TheGreatTable::record(). Оно остается в статической переменной, следовательно будет инициализировано только один раз, дальше просто возвращается.

Правильно написанный шаблонный код уменьшит количество boiler plate, а статическая инициализация позволит нам не задумываться у какого типа какой id, все будет происходить автоматически без нашего участия.

TheGreatTable это еще одна ключевая сущность библиотеки. Та самая таблица с указателями на функции. Запись в нее возможна только через метод record(), который регистрирует указатели и возвращает индекс в таблице т.е. id типа. В примере выше, в нее записываются указатели на четыре функции.

Таким образом, мы можем быстро и безболезненно определить тип в рантайме и вызывать соответствующий код, различные проверки, которые обычно делает компилятор, тоже придется делать в рантайме, например:

Expected<None> reflection::copy(Var to, Var from) {
  if (to.is_const()) {
    return Error("Cannot assign to const value");
  }
  if (to.type() != from.type()) {
    return Error(format("Cannot copy {} to {}", type_name(from.type()), type_name(to.type())));
  }
  TheGreatTable::data()[to.type().number()].copy(to.raw_mut(), from.raw());
  return None();
}

Для того чтобы хранить о типе всю необходимую информацию и иметь универсальную логику работы с ним, нам понадобится еще одна сущность. 

TypeInfo представляет собой sum type на основе std::variant с чуть более объектно ориентированным интерфейсом. Вызовом метода match() можно определить, что именно представляет из себя тип:

info.match([](Bool& b) { std::cout << “bool\n”; },
           [](Integer& i) { std::cout << “integer\n”; },
           [](Floating& f) { std::cout << “floating\n”; },
           [](String& s) { std::cout << “string\n”; },
           [](Enum& e) { std::cout << “enum\n”; },
           [](Object& o) { std::cout << “object\n”; },
           [](Array& a) { std::cout << “array\n”; },
           [](Sequence& s) { std::cout << “sequence\n”; },
           [](Map& m) { std::cout << “map\n”; },
           [](auto&&) { std::cout << “something else\n”; });

Любой тип может представлять собой один из следующих вариантов:

  • Bool - один единственный тип bool

  • Integer - все целые типы, включая char

  • Floating - числа с плавающей запятой: float и double

  • String - строковые типы включая std::string_view

  • Enum - разные enum и enum class

  • Object - структуры и классы, позволяет искать поле по имени и получить список всех полей.

  • Array - классические массивы в стиле С

  • Sequence - стандартные контейнеры с одним шаблонным параметром 

  • Map - ассоциативные контейнеры с двумя шаблонными параметрами

Для того чтобы абстрагироваться от конкретных типов применяется type erasure. Шаблонный код для разных типов(int32_t, uint64_t, char) скрыт за общим интерфейсом(Iinteger) и работает с Var и другими универсальными сущностями.

Вся работа начинается с вызова основной функции рефлексии er::reflection::reflect(), которая возвращает TypeInfo. Дальше мы имеем возможность рекурсивно разобрать наш тип и понять как он устроен и какие данные хранит.

Мне не хочется превращать статью в документацию. Поэтому код для поддержки стандартных типов оставлю по ссылке. Если какой-то из них не будет использован в приложении, то статическая инициализация не сгенерирует TypeId, не добавит указатели на функции в TheGreatTable, а компилятор вырежет ненужный код, и мы не заплатим за то, что не будем использовать.

Теперь мы разобрались с основными принципами работы библиотеки и нам надо как-то добавить поддержку пользовательских структур и классов.

Генератор

Как мы знаем, только компилятор и сам программист знают, что именно написано в файлах с исходным кодом. После компиляции в бинарном файле об этом нет никакой информации - только константные данные и набор машинных инструкций.

кстати

Существующие решения для рефлексии в C++ мне не нравятся как раз тем, что заставляют писать кучу кода используя уродливые макросы. Это приходится делать потому что информацию нужно как-то добавить в бинарный файл с программой и добавлять ее приходится руками.

Мы пойдем иным путем. Мы воспользуемся API компилятора чтобы автоматизировать сборку необходимой информации. К счастью в 2007 году вышла первая версия Clang и LLVM, с тех пор появилось множество полезных утилит анализирующих исходный код, например, clang-format, clang-tidy и объединяющий их clangd. Используя те же принципы мы напишем свою утилиту для анализа исходного кода. Сами исходники при этом можно будет компилировать чем угодно, хоть gcc, хоть MSVC(но, как всегда, с нюансами).

Clang предоставляет libTooling - набор библиотек для анализа исходного кода. С его помощью мы можем анализировать исходный код точно так же как это делает компилятор т.е. через Abstract Syntax Tree. Это даст нам много бонусов, по сравнению с ручным анализом исходного кода. AST содержит информацию из множества файлов, следовательно, предоставляет больше информации, позволяет понять в каком пространстве имен находится тот или иной объект, легко отличить объявление(declaration) от определения(definition) и т.д.

Кроме доступа к AST, у нас будет доступ к препроцессору, он позволит в качестве атрибутов применить пустые макросы:

#define ER_REFLECT(...) // expands to nothing
 
ER_REFLECT()
struct TempHumData {
  // struct fields
}

Взаимодействие с libTooling, в основном, происходит посредством обратных вызовов. Например, когда препроцессор разворачивает макрос или во время обхода AST встречается определение класса. Внутри них мы можем анализировать поддеревья AST и получать имена полей, типов, модификаторы доступа и т.д. Собранную информацию следует сохранить в какой-нибудь промежуточной структуре данных. Как это происходит на самом деле можно посмотреть в файле parser_cpp.h.

Так же нам надо как-то генерировать код, основываясь на собранной информации. Для этого отлично подходят движки шаблонов, такие как go template, mustache, jinja и др. Мы напишем руками всего несколько шаблонов, по которым будем генерировать сотни новых файлов с исходным кодом. В этом проекте я решил использовать inja, своего рода C++ порт jinja для Python. 

Упрощенный файл шаблона для объектов выглядит следующим образом:

template <>
struct TypeActions<{{name}}> {
  static TypeInfo reflect(void* value) {
    auto* p = static_cast<{{name}}*>(value);
 
    static std::map<std::string_view, FieldDesc> map {
      {% for item in fields_static -%}
      {"{{item.alias}}", FieldDesc::create_static(Var(&{{name}}::{{item.name}}), {{item.access}})},
      {% endfor %}
      {% for item in fields -%}
      {"{{item.alias}}", FieldDesc::create_member(value, Var(&p->{{item.name}}), {{item.access}})},
      {% endfor %}
    };
 
    return Object(Var(p), &map);
 
  }
};
 
template <>
TypeId TypeId::get({{name}}* /*unused*/) {
  static TypeId id(TheGreatTable::record(Actions(&TypeActions<{{name}}>::reflect,
    &CommonActions<{{name}}>::call_new,
    &CommonActions<{{name}}>::call_delete,
    &CommonActions<{{name}}>::copy)));
 
  return id;
}

Оригинал находится по ссылке

TypeActions<T> это просто обертка, чтобы не засорять код и не насиловать автодополнение в IDE сгенерированными именами классов и функций.

Вместо {{name}} будет вставлено имя класса или структуры. 

При первом вызове reflect() в два этапа заполняется статическая std::map где ключом является имя поля, а значением его дескриптор. Позже, благодаря ему, можно будет получить FieldInfo, который хранит в себе Var и модификатор доступа - public, private и т.д. На первом этапе регистрируются только статические поля. Это позволит обеспечить к ним доступ даже без экземпляра класса.

ClassWithStaticFields* ptr = nullptr;
auto info = reflection::reflect(ptr);

На втором этапе регистрируются указатели на все остальные поля, в том числе и приватные. Благодаря этому можно гибко контролировать доступ к ним. Десериализовать данные только в публичные поля, а приватные только читать и печатать в консоль. 

Далее указатель на std::map помещается в Оbject, который упаковывается в TypeInfo и возвращается из функции.

В специализации TypeId::get указатели на функции регистрируются в TheGreatTable

Сгенерированный код для всех пользовательских типов будет находиться в reflection.h и reflection.cpp, следовательно, скомпилируется в отдельный объектный файл. Такая организация упростит сборку проекта, но об этом чуть позже. Для удобства все настройки для генератора, в том числе путь к анализируемым и генерируемым файлам описываются в YAML файле.

Сериализация

Код сериализаторов для JSON, YAML и массива байт можно найти в репозитории. Бинарная сериализация как и protobuf оптимизирует размер данных на лету.

Производительность сериализации примерно такая же как у rapid_json . Для десериализации я написал парсеры JSON и YAML с использованием лексера. К сожалению я обычное быдло, а не гуру алгоритмов и оптимизаций, поэтому нативный парсер чуть быстрее nlohmann::json, но медленнее rapid_json . Тем не менее использование simdjson в качестве парсера позволяет даже немного обойти rapid_json .

Бенчмарки позволяют самостоятельно оценить производительность на разном железе.

Собираем все вместе

На данный момент у нас есть:

  • Библиотека рефликсии и сериализации

  • Шаблоны, с помощью которых будет генерироваться код

  • Анализатор и генератор исходного кода в отдельном приложении

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

set(SOURCES
   main.cpp
   ${CMAKE_CURRENT_SOURCE_DIR}/generated/reflection.cpp)
 
add_custom_command(
   OUTPUT
       ${CMAKE_CURRENT_SOURCE_DIR}/generated/reflection.cpp
   COMMAND er_gen -p -c ${CMAKE_CURRENT_SOURCE_DIR}/config.yaml
   DEPENDS
       data/temp_hum.h
   COMMENT "Generating reflection headers")
 
add_executable(${PROJECT_NAME} ${SOURCES})

К счастью весь сгенерированный исходный код находится в одном .h и одном .cpp файле поэтому достаточно включать reflection.h для доступа к API, a reflection.cpp добавить в список файлов с исходным кодом.

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

auto json_str = serialization::json::to_string(&obj).unwrap()

И в обратную сторону:

auto sensor_data = serialization::simd_json::from_string<TempHumData>(json_str).unwrap();

Более развернутый пример можно найти в репозитории с проектом:

Итог

Такое решение позволяет получить максимально близкий к другим языкам опыт. Отличие заключается только в небольшом колдунстве над процессом сборки. Кроме того, его функционал легко расширить.

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

Статья получилась объемной, но некоторые темы не были раскрыты. Например, как устроен парсинг JSON или YAML, как устроена бинарная сериализация. Если вы хотите узнать что-то в следующей статье, пожалуйста, дайте знать, что именно.

Комментарии (42)


  1. semenyakinVS
    15.03.2022 02:17
    +1

    Спасибо, было действительно интересно почитать! Активно интересуюсь темой рефлексии в C++, всё жду когда в языке появятся встроенные на уровне стандарта механизмы для получения, например, указателей на члены классов (поля и методы) на этапе компиляции. Вижу конструкцию в духе "get_members<ClassType, Options>()", возвращающую tuple с указателями на поля и методы, определяемые настройками Options. Уже одна эта возможность дала бы весьма нехилые встроенные в язык механизмы для организации рефлексии


    1. maaGames
      15.03.2022 06:53
      +1

      Указатели на этапе компиляции? Это невозможно даже в теории, как минимум по двум причинам: 1) произвольный адрес загрузки (Randomized Base Address); 2) динамически выделяемая память, там адреса зависят не только от объёма (свободной) памяти, но и от её фрагментации и всех остальных работающих программ в системе. Т.е. никогда этого не будет.

      А вот смещения в пределах одного объекта уже сейчас есть, именно на этапе компиляции.


      1. mayorovp
        15.03.2022 10:05

        Указатели на члены класса (member pointers) — это как бы и есть смещения в пределах одного объекта.


        А получить хотелось бы не один указатель, а все указатели, на все члены.


        1. qw1
          15.03.2022 11:32
          +1

          Автор выше написал «поля и методы». Видимо, замечание относилось к функциям класса.


          1. mayorovp
            15.03.2022 11:35
            +2

            Теперь я понял что имелось в виду.


            Однако, невыразимость каждого указателя на метод как константы времени компиляции не является препятствием для получения на этапе компиляции списка этих самых методов.


    1. chocolacula Автор
      15.03.2022 08:09

      Соглашусь с тем, что адрес и время компиляции несовместимы. В моем проекте можно перенести много логики из статической инициализации во время компиляции. Нужно только использовать C++20 constexpr std::vector. Но 17 стандарт не во всех проектах используется, что уж говорить о 20.


  1. Chuvi
    15.03.2022 09:20

    Я дико извиняюсь, а зачем там vcpkg в сабмодулях? он же отдельно качается, если не ошибаюсь, и в %$PATH% прописывается, а не подмодулем в каждый проект.


    1. chocolacula Автор
      15.03.2022 10:01
      +1

      Использование vcpkg как сабмодуль это рекомендованный способ установки. Пруф. Это снимает проблему кривой версионности. Когда из vcpkg одному проекту нужен curl одной версии, а второму другой.


  1. megat72
    15.03.2022 15:22
    +1

    А мне вот непонятно, зачем автор списал какие-то недостатки по работе с json конкретной либы на недостатки всего C++. Такой "жёлтый" аргумент сразу кидает тень на всю статью. Советую пересмотреть эту часть.


    1. chocolacula Автор
      15.03.2022 15:27
      +5

      В С++ нет либ без таких недостатков. Это ограничение языка. Нет способа получить информацию о классе и присвоить значение его полям. Вся статья о том как это ограничение языка обойти.


      1. megat72
        15.03.2022 17:11

        Ещё один жёлтый аргумент)


        1. chocolacula Автор
          15.03.2022 17:26
          +3

          Хотелось бы увидеть опровержение доказывающее желтизну моих аргументов. Например ссылки на либы без таких недостатков.


          1. 0xd34df00d
            15.03.2022 21:20
            +1

            Ух, раз тут начали обсуждать желтизну, я таки не смог пройти мимо (хотя при первом прочтении статьи получилось).


            Например, у вас написано:


            его используют [..] множество финтех, крипто и блокчейн стартапов.

            Можно примеры множества финтех, крипто и блокчейн-стартапов? Ладно, каких-нибудь HFT'шников, которые просто всю жизнь писали на плюсах и поэтому свой следующий маленький воннаби-хедж-фонд начинают тоже на плюсах, я могу найти, но с криптой и блокчейнерами будет особенно интересно.


            Производительность, из-за отсутствия сборки мусора

            Сборка мусора — не приговор. Я регулярно без существенных усилий получаю производительность плюсового уровня на каком-нибудь хаскеле.


            возможности низкоуровневых оптимизаций.

            Их мало. Да, для плюсов легко написать оптимизирующий компилятор среднего качества, но писать на плюсах (или на C) оптимальный код без обращения к интринсикам, инлайн-ассемблеру и тому подобному практически нереально. У компилятора недостаточно информации о семантике программы ни чтобы разрулить возможный алиасинг char* со всем подряд (поэтому я на одной работе писал обёртку над char*-строками, чтобы вместо этого там хранился указатель на struct MyChar { char ch; };, который уже алиаситься не может), ни чтобы векторизовать школьный код, ни чтобы скомпилировать циклы из std::string::reserve/std::string::push_back и std::string::resize/std::string::operator[] в одно и то же (и первое в конкретной задаче было процентов на 30 медленнее второго).


            Умопомрачительные шаблоны и сопутствующая магия

            Умопомрачительные в прямом смысле, от них можно поехать (поверьте как человеку, написавшему на шаблонах не один DSL, хоть для ORM, хоть для описания графов обработки данных). Это инструмент, получившийся случайно, а не созданный специально, и записывать его в плюсы я бы не стал. В более других языках с метапрограммированием всё куда проще и прямее.


            Код, выполняемый во время компиляции

            …который при этом отдельно должен быть написан под эту самую компиляцию. Я не могу просто взять условный boost.graph и подёргать его в компилтайме.


            Богатая стандартная библиотека и Boost

            Ну смотря с чем сравнивать.


            Поддержка всех возможных архитектур и операционных систем

            Зависит от бекенда. clang + llvm поддерживает столько же архитектур, сколько rustc + llvm или ghc + llvm. А если брать какую-нибудь микроконтроллерную экзотику, то у вас там если не llvm-бекенд, то настолько кривой компилятор, что половина указанных вами плюсов вылетают в трубу, если не становятся минусами.


            А кроме этого всего — статья хорошая, спасибо.


            1. chocolacula Автор
              15.03.2022 22:00
              +2

              У меня тоже периодически пригорает) Смысл этой части в том что есть и плюсы и минусы одновременно, в этом и заключается противоречивость. Я их не оценивал количественно или качественно. Если очень хочется примеров из блокчейна Bitcoin подойдет?


              1. 0xd34df00d
                16.03.2022 00:40

                У меня тоже периодически пригорает

                Ну, я лет 15 любил плюсы, а потом как-то в итоге так пригорело, что я их выкинул из своей профессиональной жизни, и в итоге всё стало очень хорошо.


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

                Не оценивать качество плюсов как-то странно, ИМХО — а то можно случайно записать в список несуществующие плюсы.


                Если очень хочется примеров из блокчейна Bitcoin подойдет?

                Как стартап — нет, иначе в сфере крипты нет вообще никаких стартапов.


                Моё субъективное мнение — во всяких криптоблокчейнах куда больше нелюбимого мной раста или даже любимой мной агды, чем плюсов.


                1. Starche
                  16.03.2022 01:27

                  Кроме 100500 форков биткоина вот небольшой список из топовых

                  Ripple https://github.com/ripple/rippled
                  EOS https://github.com/EOSIO/eos
                  Stellar https://github.com/stellar/stellar-core
                  Monero https://github.com/monero-project/monero

                  А вообще в крипте доминирует даже не rust, а go. А вот про агду не слышал, видимо совсем в стартапах.

                  Кстати, даже хаскель есть - в Cardano https://github.com/input-output-hk/cardano-node


                  1. 0xd34df00d
                    16.03.2022 02:01

                    А вот про агду не слышал, видимо совсем в стартапах.

                    Агду как раз в IOHK используют для верификации всяких вещей в cardano-экосистеме. Я даже в их папирах по кодированию system f в агде вдохновление для кое-какого своего ресёрча брал.


                    Или, например, другие чуваки использовали идрис для своего криптоблокчейна для распределённых вычислений (правда, они немного сдохли в 2020-м на волне кризиса).


      1. Kelbon
        16.03.2022 20:45

        Есть

        https://github.com/kelbon/MoreThanTuple

        Библиотека позволяющая в одну строку сериализовать любой тип.

        Пример оттуда:

          using check_type = std::tuple<std::vector<int>, double, float, std::pair<int, char>,
                                        mtt::tuple<int, double, std::set<double>>>;
        //... fill this mega type
          check_type value(/*something*/);
          // its all! Ready!
          std::string buffer = serialize<mode::single_machine>(value);
          // and deserialize
          auto result = deserialize<check_type, mode::single_machine>(buffer);

        Там бинарная сериализация, но впринципе есть план добавить json сериализацию(там нужно будет передавать имена полей в каком либо виде, поэтому пока этого нет)


        1. chocolacula Автор
          16.03.2022 21:10

          там нужно будет передавать имена полей в каком либо виде

          Как раз потому что из коробки их негде взять. Все же должен заметить, что наши с вами проекты очень разные и не совсем корректно их сравнивать.


  1. svr_91
    15.03.2022 15:35

    Все это хорошо, пока серилизуемые данные подчиняются какому-то определенному паттерну. А завтра окажется, что в одном запросе поле temperature приходит сразу в корне запроса, а в другом в элементе data/temperature, или поле update_interval_ms - не число, а строка, представляющая собой число в hex формате. И что в этом случае делать?


    1. qw1
      15.03.2022 15:43
      +2

      Сериализация это запись объектов в выходной поток, строковый или бинарный, по определённым правилам, и чтение из него обратно в объекты. Ваш случай — это не сериализация, а парсинг. Что делать? — Решать другую задачу другим инструментом.


      1. svr_91
        15.03.2022 16:06

        Даже в случае серилизации, был объект одной версии, стал другой (в смысле сериализированного представления). Такая ситуация встречается постоянно


        1. qw1
          15.03.2022 16:15
          +1

          А вы как решаете эту проблему?
          UPD. Увидел ниже — «набором if-ов». Такое не устраивает, код-лапша.


          1. svr_91
            15.03.2022 16:19

            Мне кажется, лучше выписывать весь код серилизации/десерилизации руками, без всяких генераторов, да, некрасиво, но надежно.


    1. chocolacula Автор
      15.03.2022 16:03

      На самом деле это уже немного другого толка задача. Решать ее придется в любом случае. Не важно как работать с данными, важно что это уже другие данные. В случае использования DOM поменяется имя/тип/путь к полю и код так же придется переписывать. Чтобы из-за этого не сесть в лужу нужны тесты. Ну и за версией API хотя бы минимально следить.


      1. svr_91
        15.03.2022 16:12

        В случае DOM можно решить набором if-ов. Да, коряво, но просто и понятно и работает. В данном случае, боюсь придется дописывать/переписывать фреймворк серилизации


        1. chocolacula Автор
          15.03.2022 16:26
          +2

          Да ну бросьте) Все же гораздо проще. Если нужна поддержка двух разных версий: запиливаем TempHumDataV1 и TempHumDataV2. И обрабатываем их отдельно, достаем из разных таблиц, получаем через разные REST API. Если хочется извращений и стрельбы по ногам, то бросаем в конец данных признак версии - один байт с числом. Если он есть - v2(v3, etc.) Если там '}' - v1. Но повторю свою мысль. Это не очевидно, а значит признак плохого проектирования. Разные данные должны обрабатываться отдельно. Проектируете новый датчик, посылающий другие данные? Пусть он делает PUT https://api/v2/temp_hum.


          1. svr_91
            15.03.2022 16:37

            Ну ок, видимо у вас задача действительно другого плана. Я просто немного побит жизнью, и сталкивался с запросами, которые на самом деле об одном и том же, но между которыми какое-нибудь мелкое но противное отличие все равно есть. И тут только руками разбирать остается, несмотря на то, что различных полей там было под 50


            1. chocolacula Автор
              15.03.2022 16:57

              Если это легаси и нет возможности исправить формат данных, например, embeded без возможности обновления прошивки, то да... Выбора нет, придется разбирать руками. Но тем не менее это просчет в проектировании. С этим придется жить, но это неправильно.


              1. svr_91
                15.03.2022 17:03

                Это могут быть просто разные клиенты, которые шлют/получают запросы. К ним не придешь и не исправишь естественно


          1. ivfilin
            16.03.2022 11:58

            "Мир розовых пони" его нет

            Исходя из текущих своих проектов

            Версионность для формата представления данных/объекта - необходимость. Пример датчик на этапе проектирования - заложили поля

            • name //название

            • check //поверка

            прошло время, добавилось:

            • check_by_superspeсial_lab_srvice // специфичная проверка

            т.е. датчик(объект) остался тот же , но его описательная часть изменилась

            т.е. в

            struct SensorMetaInfo - добавили поля, возможно поменяли местами, а некоторые удалили. А заказчику ПО поставлено и оно шлет инфу в централизованное хранилище и endpoint -там один, надо по инфе разобраться какой десериализатор используется.

            или в более простом случае - вы пишете сохранение настроек работы системы (датчиков) в файл. Накатываете обновление ПО, которое старый файл настроек должен прочитать и применить.

            А потом приходит понимание, новое ПО глючное - накатываем старую версию, но файлы настроек уже обновлены - как реагировать? Тут хотя бы по более новой (не поддерживаемой) версии можно принять решение - сообщить об ошибке или попробовать дефолтную инициализацию.

            Ну и вопрос общего плана. Представление float/double

            как сериализуется ?

            double d_inf = std::numeric_limits<double>::infinity();

            double d_nan = std::numeric_limits<double>::nan();

            json это

            "dfdsf" - string

            1 - int

            1.0 - real

            True/False - bool

            {} - obj

            [] - array

            ничего другого нет.

            ---

            nlohman::json - имеет возможность кастомной сериализации, читать adl_serializer

            по полям классов структур можно ходить через boost_hana_adapt_struct (но там через макросы)

            пример (из проекта) получения json из объекта (поля структуры предварительно обозначены через boost_hana макрос)

            template <typename T>
            static void makeJsonFromStruct(nlohmann::json &j, const T &t) {
              boost::hana::for_each(
                  boost::hana::accessors<T>(), [&j, &t](const auto &meta) {
                    try {
                      j[boost::hana::to<const char *>(boost::hana::first(meta))] =
                          boost::hana::second(meta)(t);
                    }
                    catch (std::exception &e) {
                      LOG_ERROR << "Can`t convert obj with type "
                                << boost::core::demangle(typeid(T).name())
                                << " exception: " << e.what();
                    }
                  });
            }


            1. chocolacula Автор
              16.03.2022 12:20

              Вы описываете проблему:

              обновили ПО и конфиг -> забаговано, надо откатывать -> откатываете только ПО оставив конфиг.

              Верно? Это звучит не как проблема сериализации/десериализации, а как проблема с развертыванием.


              1. ivfilin
                16.03.2022 13:03

                да это не проблема сериализатора, это реакция на комент

                Если нужна поддержка двух разных версий: запиливаем TempHumDataV1 и TempHumDataV2. И обрабатываем их отдельно, достаем из разных таблиц, получаем через разные REST API

                Объект (датчик, класс) остается один и тот же формат данных - меняется - и условное требование поля int version - вполне разумное.

                Реализацию в чем-то подобную вашей писали в 2019 в своем проекте.

                Но потом перешли на использование boost_hana + nlohman::json

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

                double d_inf = std::numeric_limits<double>::infinity();

                double d_nan = std::::nan();


                1. chocolacula Автор
                  16.03.2022 14:30
                  +1

                  Ваше мнение - Объект один и тот же, а данные отличаются. Мое мнение другое - разным данным разные объекты и наоборот, иначе это не сериализация. Это натягивание данных одного объекта, на другой. Тут естественно только руками.

                  А как вы хотите сериализовать? Как поток байт? Не вижу проблем, все будет скопировано бит в бит и обратно. Как строка? Поддержки таких значений у меня нет, но не забывайте это опенсорс проект одного человека. Если сделаете пулл реквест с их поддержкой я обещаю, сто вмерджу его первым.

                  Кроме того. Вы уверены, что сериализовать числа с плавающей точкой в json т.е. как строку это хорошая идея?


                  1. ivfilin
                    16.03.2022 17:25

                    то что выложили и поделились наработками - прекрасно.

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

                    Если ваш подход закрывает потребности вашего проекта - супер.

                    Но в моем случае (по требованиям)

                    smthObj smth {smthObj::fromJson(json)}

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

                    Обмен посредством json/xml api - это необходимость и тогда double -> string - необходимость. std::nan() infinity() - валидные значения для вещественных чисел - и это надо передать через json.

                    ---

                    тут был комментарий

                    Ну, я лет 15 любил плюсы, а потом как-то в итоге так пригорело, что я их выкинул из своей профессиональной жизни, и в итоге всё стало очень хорошо.

                    вот к этому приходят многие, у меня это python - совсем другая крайность, но и задачи поменялись и делать их на плюсах - себе дороже.

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

                    ---

                    За статью - спасибо


  1. x2v0
    16.03.2022 10:14

    Вообще-то, полноценная рефлексия будет включена в будущий стандарт C++(25??)

    У меня где-то валялся драфт.

    ++

    Рефлексия была в ROOT (https://root.cern.ch/), с момента его появления в 1995 году.

    Она используется для сериализации ROOT I/O (включая Sheema Evolution)

    и в C++ интерпретаторе.


    1. chocolacula Автор
      16.03.2022 11:09

      Не вижу ничего похожего в будущем стандарте.


      1. qw1
        16.03.2022 11:36

        Вы ссылаетесь на 23-й стандарт, а она появится не раньше 25-го.
        Краткий обзор был недавно habr.com/en/post/598981


        1. chocolacula Автор
          16.03.2022 12:08

          У меня на этот счет несколько важных мыслей:

          • Сейчас 2022, рефлексия появится в 26, не уж то мне страдать еще 4 года?

          • Можно примерно предполагать только о том, что войдет в следующий стандарт(С++23) и то не факт, это все еще черновик.

          • На С++26 даже черновика нет, то о чем говорите вы, к сожалению, всего лишь PROPOSAL и нет никаких гарантий, что оно войдет в С++26. Есть гарантия, что оно НЕ войдет в С++23.

          • Не факт что новый стандарт будет в 26, увы.

          • Даже после утверждения стандарта, в компиляторах это появится не сразу.

          • Даже после появления в компиляторах как скоро production код будет портирован на С++26?

          Я же вам предлагаю готовое, быстрое решение уже сейчас. Нужен С++17, который +/- довольно широко распространен. Если воспользоваться С++20 и растыкать по проекту constexpr, то часть логики уйдет в compile time.

          Повторюсь. Вот прямо сейчас.


    1. myxo
      16.03.2022 12:37

      Да, а networking ts должен был войти в с++20… Наличие драфта мало о чем говорит


  1. myxo
    16.03.2022 12:40
    +2

    Странно, что в комментах не написали о github.com/boostorg/pfr. Сам не пробовал, но вроде с его помощью можно также писать такие сериализаторы для простых типов. Причем в compile time. И это работает с текущим стандартом, не нужны никакие внешние генераторы, которые никто себе ставить не будет.


    1. chocolacula Автор
      16.03.2022 14:38
      +1

      Большое спасибо что вспомнили про pfr! @antoshkka сделал огромную и очень крутую работу, но у такого подхода довольно много ограничений, которые я хотел обойти. Например у меня есть поддержка алиасов, можно анализировать все что угодно, в том числе печатать в консоль для дебага const и static. Вначале я хотел воспользоваться pfr, но в последствии отказался от него.