(эксперимент десятилетней давности, который до сих пор не даёт мне покоя)

⚠️ Дисклеймер
Это не готовая библиотека и не «лучше protobuf».
Это экспериментальная система сериализации, написанная более 10 лет назад,
для решения задачи, с которой стандартные форматы справляются плохо:
сохранение и восстановление runtime-графов объектов с рефлексией и циклами.

Зачем вообще ещё одна сериализация?

сценарий:

У меня есть:
– редактор
– сцена
– объекты с взаимными ссылками
– UI, который указывает на поля этих объектов

Я хочу:
– сохранить всё
– поменять код
– загрузить обратно
– и не потерять ни одной связи

Большинство форматов сериализации (JSON, XML, protobuf, flatbuffers):

  • хорошо работают с деревьями

  • плохо работают с графами

  • почти не работают с:

    • циклами

    • указателями

    • полиморфизмом

    • ссылками на части объектов

А если работают — то ценой:

  • внешнего кода

  • жёсткой схемы

  • ручного glue-кода

Мне же нужна была система, которая умеет:

  • сериализовать произвольный объектный граф

  • сохранять типы и связи

  • быть самоописывающейся

  • и при этом иметь бинарный и текстовый режим

Так родилась эта система.

Ограничения (сразу и честно)

Чтобы сразу снять лишние вопросы:

  • компилируется только MSVC

  • только 32-bit

  • активно использует макросы

  • проект заморожен

  • время компиляции стало критической проблемой при росте кода

Это не «продакшн-решение», а инженерный эксперимент.

Общая идея формата

Файл сериализации состоит из четырёх логических частей:

  1. Имена описателей метатипов

  2. Описание всех используемых типов используя описатели метатипы

  3. Что-то вроде карты связывающей описание типов с их реализацией

  4. Собственно данные пользовательской структуры (объектный граф)

Причём:

  • файл почти не требует внешнего кода

  • он содержит почти всё необходимое для интерпретации данных

  • текстовый формат изоморфен бинарному

Два слоя описания типов: runtime и save-time
┌──────────────┐
│  C++ code    │
│  (ABI, vtbl) │
└──────┬───────┘
       │
       ▼
┌──────────────┐
│ RTTI │ ← фабрики, offset’ы, указатели
└──────┬───────┘
       │  сопоставление
       ▼
┌──────────────┐
│ STTI │       │ ← имена, типы, поля
└──────┬───────┘
       │  запись
       ▼
┌──────────────┐
│   .qap file  │
└──────────────┘

Два слоя описания типов: runtime и save-time

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

На самом деле в системе две независимые системы описания типов:

1. Run-Time-Type-Info (исполняемая)
То, что используется во время работы программы:

  • фабрики объектов

  • смещения полей

  • реальные указатели

  • виртуальные таблицы

  • всё, что зависит от ABI, компилятора и layout’а памяти

Эта часть нужна, чтобы:

  • создавать объекты

  • ходить по ним

  • связывать указатели

  • вызывать код

2. Save-Time-Type-Info (декларативная)
Отдельный, более «чистый» слой, который используется только для сериализации:

  • описателей метатипов

  • описателей типов

Именно этот слой записывается в файл.

Ключевая идея в том, что:

файл сериализации не знает почти ничего о runtime-реализации,
он знает только декларативное описание структуры данных.

Благодаря такому разделению:

  • формат переживает изменение layout’а памяти

  • порядок полей не критичен

  • добавление/удаление полей возможно

  • миграция работает

По сути:

RTTI — это «как работать»
STTI — это «что именно сохранено»

И эти два мира намеренно не смешиваются.

Это решение:

  • резко усложнило код

  • увеличило объём метаописаний

  • потребовало поддержки двух систем типов

Но без него:

  • самоописание формата было бы невозможно

  • миграция между форматами не работала бы

Простейший пример: граф с циклами

Начнём с минимального примера.

Структуры

// https://github.com/adler3d/QapSerialize/blob/e55922ba0985587fb8cb0b9e89a97551ad8fce20/QapApp/CommonUnit.cpp#L18
class t_node{
public:
  TSelfPtr<t_node> Self;
  string name;
  vector<t_node> arr;
  vector<TWeakPtr<t_node>> links;
};

class t_some_class{
public:
  t_node n;
  int b = 10;
  double c = 20;
  string more = "2025.12.03 18:07";
  double z = 30;
};

Ключевые моменты:

  • TSelfPtr — самоссылка (идентификатор объекта)

  • TWeakPtr — слабая ссылка на другой объект

  • vector<t_node>вложенные объекты, а не указатели

Это уже не дерево, а граф.

Создание связей

t_some_class var;
var.n.links.push_back(&var.n);
var.n.arr.push_back(t_node());
var.n.arr.push_back(t_node());
var.n.arr.push_back(t_node());
var.n.links.push_back(&var.n.arr[2]);
var.n.links.push_back(&var.n.arr[1]);
var.n.links.push_back(&var.n.arr[0]);
var.n.arr[1].links.push_back(&var.n.arr[2]);

Здесь есть:

  • самоссылка

  • ссылки между вложенными объектами

  • циклы

Большинство сериализаторов на этом месте либо падают, либо требуют ручной работы.

Текстовый proto-файл

Помимо бинарного файла, система сохраняет текстовый дамп:

t_some_class
{
  n = t_node
  {
    Self = TSelfPtr<t_node>(userpoint[0])
    arr = vector<t_node>
    {
      t_node { Self = TSelfPtr<t_node>(userpoint[1]) }
      t_node { Self = TSelfPtr<t_node>(userpoint[2]) }
      t_node { Self = TSelfPtr<t_node>(userpoint[3]) }
    }
    links = vector<TWeakPtr<t_node>>
    {
      TWeakPtr<t_node>(userpoint[0])
      TWeakPtr<t_node>(userpoint[3])
      TWeakPtr<t_node>(userpoint[2])
      TWeakPtr<t_node>(userpoint[1])
    }
  }
}

(Файл на самом деле больше — здесь только переработанная ИИ payload-часть показывающая как хранятся умные следящие указатели.)

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

TAutoPtr<DeclareType>
{
  DeclareTypeStruct
  {
    DeclareType{Self = TSelfPtr<DeclareType>(Def$$<t_node>::GetRTTI())}
    Name = string("t_node")
    SubType = TWeakPtr<DeclareType>(nullptr)
    OwnType = TWeakPtr<DeclareType>(nullptr)
    Members = vector<DeclareMember>{
      DeclareMember{
        Name = string("Self")
        Type = TWeakPtr<DeclareType>(Def$$<TSelfPtr<t_node>>::GetRTTI())
        Mode = string("DEF")
        Value = string("$")
      },
      DeclareMember{
        Name = string("name")
        Type = TWeakPtr<DeclareType>(Def$$<string>::GetRTTI())
        Mode = string("DEF")
        Value = string("$")
      },
      DeclareMember{
        Name = string("arr")
        Type = TWeakPtr<DeclareType>(Def$$<vector<t_node>>::GetRTTI())
        Mode = string("DEF")
        Value = string("$")
      },
      DeclareMember{
        Name = string("links")
        Type = TWeakPtr<DeclareType>(Def$$<vector<TWeakPtr<t_node>>>::GetRTTI())
        Mode = string("DEF")
        Value = string("$")
      }
    }
  }
}

Что такое userpoint[n]?

Это идентификатор объекта в графе.

  • каждый объект с TSelfPtr получает уникальный id

  • TWeakPtr ссылается на этот id

  • порядок создания не важен

  • циклы не проблема

Почему текстовый формат не предназн��чен для загрузки?

Это принципиальный момент.

Текстовый файл:

  • изоморфен бинарному

  • полностью повторяет структуру данных

  • предназначен для:

    • отладки

    • анализа

    • diff’ов

    • понимания состояния системы

Но:

  • он не устойчив к правкам руками

  • числа с плавающей точкой — отдельная боль

  • он ближе к IR, чем к DSL

Это осознанное решение.

Самоописание типов

До payload в файле содержится полное описание всех типов:

  • string

  • int

  • real

  • t_node

  • vector<t_node>

  • vector<TWeakPtr<t_node>>

  • и т.д.

Файл можно читать без исходников.

Это сильно увеличивает размер, но даёт:

  • независимость

  • воспроизводимость

  • возможность анализа вне программы

Почему это вообще имеет смысл?

Потому что такой формат:

  • подходит для редакторов

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

  • удобен для live-reload

  • позволяет делать undo/redo как снапшоты

  • честно работает с графами

Это не замена JSON. Это решение другой задачи.

Часть 2. Полиморфизм, владение и ссылки на поля

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

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

Базовый интерфейс и наследники

Вводим наш пользовательский базовый тип:

class i_node{
public:
  TSelfPtr<i_node> Self;
};

И два его наследника:

class t_node2 : public i_node{
public:
  string name;
  vector<TAutoPtr<i_node>> arr;
  vector<TWeakPtr<i_node>> links;
};

class t_leaf : public i_node{
public:
  string name;
  string payload;
  TFieldPtr fieldptr;
};

Ключевые отличия от первого примера:

  • vector<TAutoPtr<i_node>>владение полиморфными объектами

  • vector<TWeakPtr<i_node>> — ссылки на базовый тип

  • t_leaf — отдельный тип узла

  • TFieldPtr — ссылка не на объект, а на его поле

Контейнер с полиморфизмом

vector<TAutoPtr<i_node>> arr;

Это означает:

  • контейнер знает только базовый тип

  • фактический тип элемента определяется в runtime

  • сериализация должна сохранить реальный тип объекта

В текстовом proto это выглядит так:

TAutoPtr<i_node>
{
  t_leaf
  {
    ...
  }
}

Или:

TAutoPtr<i_node>
{
  t_node2
  {
    ...
  }
}

Тип не угадывается и не «восстанавливается магией».
Он явно записан в данных(в бинаре как int равный SaveID из TSelfPtr целевого типа).

Как выглядит наследование в proto

Объект-наследник сериализуется как:

t_node2
{
  i_node
  {
    Self = TSelfPtr<i_node>(userpoint[1])
  }
  name = string("N1")
  arr = ...
  links = ...
}

Важно:

  • базовая часть (i_node) — реальный вложенный объект

  • порядок фиксирован

  • никаких неявных offset’ов или ABI-зависимостей

Это ближе к «снимку структуры», чем к классическому сериализованному объекту.

Граф остаётся графом

Несмотря на полиморфизм и владение:

  • TSelfPtr продолжает идентифицировать объект

  • TWeakPtr продолжает ссылаться по id

  • циклы допустимы

  • типы могут быть разными

Ссылки на поля: TFieldPtr

Теперь самое интересное (и самое спорное).

В t_leaf есть поле:

TFieldPtr fieldptr;

Оно может указывать, например, на конкретное поле конкретного объекта:

var.n.arr[1].build<t_leaf>(Env)->fieldptr.ConnectToField(Env,N1,N1.links);

То есть:

  • есть объект N1

  • у него есть поле links

  • t_leaf хранит ссылку на это поле

Как это выглядит в proto

fieldptr = TFieldPtr
{
  object = TVoidPtr
  {
    type = THardPtr<TType>(Sys$$<t_node2>::GetRTTI())
    ptr = (userpoint[1])
  }
  type = THardPtr<TType>(Sys$$<vector<TWeakPtr<i_node>>>::GetRTTI())
  index = int(5)
}

Фактически здесь зафиксировано:

  • объект (userpoint[1])

  • тип объекта

  • тип поля

  • индекс поля в рефлексивном списке

Это уже рефлексия второго порядка.

Почему это впечатляет…

Потому что формат умеет:

  • сериализовать полиморфные графы

  • сохранять владение и ссылки

  • указывать не только на объект, но и на его часть

Это уровень:

  • визуальных редакторов

  • node-based систем

  • live graph editors

…и почему это оказалось тупиком

TFieldPtr оказался слишком хрупким.

Проблемы:

  • поле идентифицируется индексом

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

  • добавление/удаление поля — риск

  • текстовый формат становится слишком «исполнительным»

По сути, это сериализация ABI рефлекс��и, а не структуры данных.

// если в целевом объекте добавить поле и индексы поедут, то будет примерно такая ошибка заргузки:
cannot convert from
'int t_node2::*'
to
'vector<TWeakPtr<i_node>> t_node2::*'

Часть 3. Что пошло не так, и какие идеи выжили

Эта часть — самая важная. Потому что именно здесь появляется опыт.

Проблема времени компиляции

Система активно использует:

  • шаблоны

  • макросы

  • RTTI

  • регистрацию типов

На маленьких тестах:

  • всё компилируется быстро

  • система ощущается удобной

Но при росте проекта:

  • время компиляции становится критичным

  • любая правка тянет за собой полмира

В какой-то момент это стало неприемлемо.

Проект был заморожен.

Миграция форматов (и это сработало)

При этом один важный эксперимент удался.

Были тесты, где:

  • менялся порядок полей

  • поля добавлялись

  • поля удалялись

И при этом:

  • старые файлы продолжали грузиться

  • данные не «ломались»

Почему?

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

  • а на декларативное описание структуры

Это был сильный аргумент в пользу подхода.

Отказ от TFieldPtr

Со временем стало понятно:

  • ссылка на поле по индексу — плохая идея

  • она слишком низкоуровневая

Вместо этого родилась другая концепция.

оцените мощность TBranch по сравнению с TFieldPtr
оцените мощность TBranch по сравнению с TFieldPtr

TBranch: структурный путь

Идея:

Примерно как:

root.n.arr[1].links

Но:

Плюсы:

  • устойчиво к изменению layout’а

  • идеально для текста

  • можно добраться до любого поля

Фактически — DSL навигации по объектному графу.

Про «умные слабые следящие указатели»

Самый неожиданный вывод:

Я так и не понял, как получать профит от умных слабых следящих указателей в реальном мире. В прочем, как позднее выяснялось, проблема не в указателях.

На практике удобнее оказались:

  • индексы

  • строки

  • структурные пути

  • скрипты-указатели

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

Итоговый вывод

Эта система:

  • не стала библиотекой

  • не дошла до продакшна

  • но дала очень ценный опыт

Главный вывод от ИИ:

Сериализация — это не про байты. Это про сохранение инвариантов связей.

И чем сложнее система, тем важнее:

  • граф, а не дерево

  • семантика, а не layout

  • структура, а не адреса

Вместо заключения

Если бы я делал это сегодня:

  • меньше шаблонов

  • меньше магии

  • больше декларативности

  • больше текстовых, стабильных идентификаторов

Но идеи:

  • самоописания

  • графов

  • структурных путей

— я бы точно взял с собой дальше.


Часть 4. Жёсткие ограничения, которые всплыли на практике

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

Удаление объектов с TSelfPtr — неожиданная проблема

Первый тревожный симптом:

Удаление(при миграции) объекта с TSelfPtr ломает загрузку,
даже если на этот объект больше никто не ссылается.

Интуитивно ожидаешь:

  • нет TWeakPtr

  • нет TAutoPtr

  • объект просто исчезает при миграции

Но нет.

Система не справляется с этим сценарием.

«А если есть слабая ссылка наружу?» — тоже нет

Следующая гипотеза была такой:

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

Результат:

наличие TSelfPtr в удаляемом при миграции типе пока делает миграцию невозможной

TAutoPtr усугубляет ситуацию

Следующее наблюдение оказалось ещё жёстче:

TAutoPtr внутри удаляемого при миграции объекта хранить нельзя вообще

То есть если тип содержит:

  • TSelfPtr

  • TAutoPtr

— система миграции пока не справляется.

Что в итоге реально безопасно мигрирует

После серии экспериментов стало ясно:

Надёжно удаляются при миграции только «тупые» типы

А именно:

  • встроенные типы

  • строки

  • структуры

  • векторы

  • комбинации вышеперечисленного

Без:

  • TSelfPtr

  • TWeakPtr

  • TAutoPtr

  • TFieldPtr

Рабочий миграционный тест

Полный код теста:
class i_node{
public:
#define DEF_PRO_STRUCT_INFO(NAME,PARENT,OWNER)NAME(i_node)
#define DEF_PRO_VARIABLE(ADDBEG,ADDVAR,ADDEND)\
ADDBEG()\
ADDVAR(TSelfPtr<SelfClass>,Self,DEF,$,$)\
ADDEND()
//=====+>>>>>i_node
#include "QapGenStruct.inl"
//<<<<<+=====i_node
public:
  virtual void check(){QapNoWay();}
};

class t_node2:public i_node{
public:
#define DEF_PRO_STRUCT_INFO(NAME,PARENT,OWNER)NAME(t_node2)PARENT(i_node)
#define DEF_PRO_VARIABLE(ADDBEG,ADDVAR,ADDEND)\
ADDBEG()\
ADDVAR(string,name,DEF,$,$)\
ADDVAR(vector<TAutoPtr<i_node>>,arr,DEF,$,$)\
ADDVAR(vector<TWeakPtr<i_node>>,links,DEF,$,$)\
ADDEND()
//=====+>>>>>t_some_class
#include "QapGenStruct.inl"
//<<<<<+=====t_some_class
public:
};
class t_leaf:public i_node{
public:
#define DEF_PRO_STRUCT_INFO(NAME,PARENT,OWNER)NAME(t_leaf)PARENT(i_node)
#define DEF_PRO_VARIABLE(ADDBEG,ADDVAR,ADDEND)\
ADDBEG()\
ADDVAR(string,name,DEF,$,$)\
ADDVAR(string,payload,DEF,$,$)\
ADDVAR(TFieldPtr,fieldptr,DEF,$,$)\
ADDEND()
//=====+>>>>>t_leaf
#include "QapGenStruct.inl"
//<<<<<+=====t_leaf
public:
  void check()override{
    QapAssert(name=="leaf");
    QapAssert(payload=="foo");
  }
};
class t_some_class2{
public:
#define DEF_PRO_STRUCT_INFO(NAME,PARENT,OWNER)NAME(t_some_class2)
#define DEF_PRO_VARIABLE(ADDBEG,ADDVAR,ADDEND)\
ADDBEG()\
ADDVAR(t_node2,n,DEF,$,$)\
ADDVAR(double,z,SET,30,$)\
ADDEND()
//=====+>>>>>t_some_class2
#include "QapGenStruct.inl"
//<<<<<+=====t_some_class2
public:
};

namespace t_before{
  class t_old_stuff{
  public:
  #define DEF_PRO_STRUCT_INFO(NAME,PARENT,OWNER)NAME(t_old_stuff)
  #define DEF_PRO_VARIABLE(ADDBEG,ADDVAR,ADDEND)\
  ADDBEG()\
  ADDVAR(string,name,DEF,$,$)\
  ADDVAR(vector<t_old_stuff>,arr,DEF,$,$)\
  ADDEND()
  //=====+>>>>>t_old_stuff
  #include "QapGenStruct.inl"
  //<<<<<+=====t_old_stuff
  };
  class t_node2:public i_node{
  public:
  #define DEF_PRO_STRUCT_INFO(NAME,PARENT,OWNER)NAME(t_node2)PARENT(i_node)
  #define DEF_PRO_VARIABLE(ADDBEG,ADDVAR,ADDEND)\
  ADDBEG()\
  ADDVAR(string,name,DEF,$,$)\
  ADDVAR(vector<TAutoPtr<i_node>>,arr,DEF,$,$)\
  ADDVAR(vector<TWeakPtr<i_node>>,links,DEF,$,$)\
  ADDEND()
  //=====+>>>>>t_node2
  #include "QapGenStruct.inl"
  //<<<<<+=====t_node2
  public:
    t_node2*init(const string&Name){name=Name;return this;}
  };
  class t_leaf:public i_node{
  public:
  #define DEF_PRO_STRUCT_INFO(NAME,PARENT,OWNER)NAME(t_leaf)PARENT(i_node)
  #define DEF_PRO_VARIABLE(ADDBEG,ADDVAR,ADDEND)\
  ADDBEG()\
  ADDVAR(t_old_stuff,old_stuff,DEF,$,$)\
  ADDVAR(string,payload,DEF,$,$)\
  ADDVAR(string,payload2,DEF,$,$)\
  ADDVAR(TFieldPtr,fieldptr,DEF,$,$)\
  ADDVAR(string,payload3,DEF,$,$)\
  ADDVAR(string,name,DEF,$,$)\
  ADDEND()
  //=====+>>>>>t_leaf
  #include "QapGenStruct.inl"
  //<<<<<+=====t_leaf
  public:
    t_leaf*init(IEnvRTTI&Env){
      payload="foo";
      payload2="bar";
      payload3="baz";
      name="leaf";
      qap_add_back(qap_add_back(old_stuff.arr).arr).name="inner";
      qap_add_back(old_stuff.arr).name="object";
      old_stuff.name="stuff";
      return this;
    }
  };
  class t_some_class2{
  public:
  #define DEF_PRO_STRUCT_INFO(NAME,PARENT,OWNER)NAME(t_some_class2)
  #define DEF_PRO_VARIABLE(ADDBEG,ADDVAR,ADDEND)\
  ADDBEG()\
  ADDVAR(double,z,SET,360,$)\
  ADDVAR(t_node2,n,DEF,$,$)\
  ADDVAR(double,x,SET,30,$)\
  ADDEND()
  //=====+>>>>>t_some_class2
  #include "QapGenStruct.inl"
  //<<<<<+=====t_some_class2
  public:
  };
  void init(IEnvRTTI&Env,t_some_class2&var){
    var.n.links.push_back(&var.n);
    i_node*ptr=nullptr;
    var.n.arr.push_back({});auto&N1=*var.n.arr[0].build<t_node2>(Env);N1.name="N1";
    var.n.arr.push_back({});var.n.arr[1].build<t_leaf>(Env)
      ->init(Env)->fieldptr.ConnectToField(Env,N1,N1.links);
    var.n.arr.push_back({});var.n.arr[2].build<t_node2>(Env)->name="N2";
    var.n.links.push_back(var.n.arr[2].get());
    var.n.links.push_back(var.n.arr[1].get());
    var.n.links.push_back(var.n.arr[0].get());
    N1.links.push_back(var.n.arr[2].get());
  }
};

void main_2025(IEnvRTTI&Env){
  { 
    // кто бы мог подумать что я не зря сделал TEnvRTTI не singleton`ом
    // и поэтому теперь могу делать миграционный тест вот так:
    TStdAllocator MA;
    TEnvRTTI Env2; 
    Env2.Arr.reserve(1024);
    Env2.Alloc=&MA;
    Env2.OwnerEnv=&Env2;

    t_before::t_some_class2 var;
    t_before::init(Env2,var);
    TQapFileStream fsproto("out_before.qap.proto",false);
    QapUberFullSaver(Env2,QapRawUberObject(var),UberSaveDeviceProto(fsproto));
    TQapFileStream fs("out.qap",false);
    QapUberFullSaver(Env2,QapRawUberObject(var),UberSaveDeviceBin(fs));
  }
  Sys$$<t_node2>::GetRTTI(Env);Sys$$<t_leaf>::GetRTTI(Env);
  t_some_class2 var;
  if(!QapPublicUberFullLoaderBinLastHope(Env,QapRawUberObject(var),"out.qap")){
    int gg=1;
  }
  var.n.arr[1]->check();
  QapAssert(var.z==360);
  TQapFileStream fsproto("out.qap.proto",false);
  QapUberFullSaver(Env,QapRawUberObject(var),UberSaveDeviceProto(fsproto));
  TQapFileStream fs("out.qap",false);
  QapUberFullSaver(Env,QapRawUberObject(var),UberSaveDeviceBin(fs));
  int gg=1;
}

Суть теста миграции

Тест делает следующее:

  1. Есть старая версия типов (t_before::)

  2. В ней:

    лишние поля

    изменённый порядок

    вложенные структуры

    TFieldPtr

  3. Данные сохраняются в файл

  4. Затем файл загружается в новую версию типов

  5. Проверяется:

    данные на месте

    логика не сломалась

Что именно меняется между версиями

В старой версии t_leaf содержит:

  • old_stuff (вложенная структура)

  • payload

  • payload2

  • payload3

  • name

  • TFieldPtr

В новой версии t_leaf:

  • нет old_stuff

  • нет payload2, payload3

  • поля в другом порядке

  • остались только:

    • name

    • payload

    • TFieldPtr

При этом:

  • данные успешно загружаются

  • payload == "foo"

  • name == "leaf"

  • check() проходит

Этот тест работает, т.к:

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

  • лишние поля просто игнорируются

  • отсутствующие поля получают дефолты

  • layout памяти не используется

  • данные сопоставляются декларативно

diff proto-файлов
diff proto-файлов

Что показывает diff proto-файлов

Diff между out_before.qap.proto и out.qap.proto наглядно показывает:

  • какие поля исчезли

  • какие типы больше не используются

  • как изменилось описание структуры

Где посмотреть/попробовать?

PS: Мечтаю сделать компилятор который будет сразу эффективно поддерживать такую систему сериализации, но пока на это нет ресурсов, может они есть у вас? Давайте построим крутую систему вместе :)

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


  1. Kotofay
    15.12.2025 08:49

    Поищите библиотеку "POST--" там было что то подобное.

    https://github.com/knizhnik/POST--


    1. Adler3D Автор
      15.12.2025 08:49

      Simple persistent storage

      У меня не простое хранилище, а полноценная система, так что вы направляете меня/читателей скорее всего не туда.

      delete rec;

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


    1. Adler3D Автор
      15.12.2025 08:49

      Там даже в README прямым текстом:

      “Unfortunately POST++ due to its simplicity provides no facilities for automatic object conversion”

      POST++ — это persistent object store на mmap, а не сериализация с миграциями.
      Он предполагает стабильный layout, адреса и не поддерживает автоматическое изменение типов.
      В моей задаче ключевыми были самоописание формата и обновление бинарника без потери данных, поэтому подход принципиально другой.


  1. domix32
    15.12.2025 08:49

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


    1. Adler3D Автор
      15.12.2025 08:49

      Как-то вы на примеры сериализации/десериализации поскупились

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

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

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


      1. domix32
        15.12.2025 08:49

        Есть какие-то пожелания что именно вы хотели бы увидеть в них?

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


        1. Adler3D Автор
          15.12.2025 08:49

          Вы же решали какую-то проблему

          Я на основе этой системы сохранял/загружал/мигрировал всё состояние своих программ, а так же делал довольно крутой(как мне тогда казалось) графический отладчик/интроспектор для них. Последний позволял добраться до почти любой хранимой переменной и поменять её.

          Уменьшите скоуп проблемы и сделайте из неё пример поменьше.

          Проблема было две: "сохранить всё состояние программы" и "менять его в реальном времени не перезапуская программу", как мне уменьшить такой скоуп? Сохранить половину состояния программы? :)

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

          Ссылочные типы очень пригодились для описания типов используемых внутри сохраняемого файла, хотя конечно всё можно было заменить на строки и тогда мы бы поменяли производительность на простоту реализации. У меня получилось нифига не просто, но зато компактно и быстро. Ещё ссылочные типы используются для указания на текстуры/шрифты/спрайты_в_атлассе/клавиатур... но всё это можно переделать на строковые идентификаторы и победить в 80% случаев как мне кажется. Вообще я планировал использовать ссылочные типы для того чтобы боты в игре ссылались друг на друга, но как оказалось - это плохой дизайн и лучше иметь локальные числовые идентификаторы, а не глобальные умные следящие указатели.

          Накидайте какой-нибудь граф, который вы хотели сериализовать.

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


          1. domix32
            15.12.2025 08:49

            Проблема было две: "сохранить всё состояние программы" и "менять его в реальном времени не перезапуская программу", как мне уменьшить такой скоуп? Сохранить половину состояния программы? :)

            Вы всегда можете сделать маленькую программу. Хэлоу ворлд, REPL c финбонначи и мемоизацией, систему управления лифтами, пинг-понг с роботом, симулятор веб или почтового сервера. Заодно будет фикстурой для юнит-тестов.

            "менять его в реальном времени не перезапуская программу"

            для этого придумали скриптование. делаете какой-нибудь ctrl+r и триггерите переинициализацию скрипта. hotreload никогда не требовал сериализации графа.

            текстуры/шрифты/спрайты_в_атлассе

            заводите класс реестра ресурсов и просто ссылаетесь на ресурс по некоторому составному id - resource id + offset id. В качестве resource id можно взять например djb2 хэш от пути к ресурсу. Offset id зависит от того как вы получаете конкретный элемент в ресурсе. Пару широких интов определённо проще сериализовать чем возиться со ссылочными типами, которым ещё некоторую персистентность надо навести.

            Ссылочные типы очень пригодились для описания типов используемых внутри сохраняемого файла.

            Так а используется-то оно как? Выглядит как несколько странный дебаггинг, судя по вашему описанию.

            Мне кажется задача бред и такого графа нет

            Ну, вы же решали какую-то свою боль, изобретая вот это вот всё. Пусть криво-косо и вероятно без знания предметной области, но вполне что-то решили в итоге. Это нормально. Сделайте минимально воспроизводимую боль. Пусть без окон и imgui крутилок, но понятную другим.

            Хотя хаб "ненормальное программирование" я бы всё же добавил.


            1. Adler3D Автор
              15.12.2025 08:49

              для этого придумали скриптование. делаете какой-нибудь ctrl+r и триггерите переинициализацию скрипта. hotreload никогда не требовал сериализации графа.

              Ну чистая правда, даже придраться не к чему. Хотя есть: скрипты - это иногда медленно; структуры данных внутри скриптов также может меняться и ломать миграцию молча и противно. Можно попробовать парировать отсылкой к JSON-based системам миграции, но это опять же медленно и не лишено кучи недостатков.

              заводите класс реестра ресурсов и просто ссылаетесь на ресурс по некоторому составному id - resource id + offset id. В качестве resource id можно взять например djb2 хэш от пути к ресурсу. Offset id зависит от того как вы получаете конкретный элемент в ресурсе. Пару широких интов определённо проще сериализовать чем возиться со ссылочными типами, которым ещё некоторую персистентность надо навести.

              100% правда. я так поступил из-за своего неумения проектировать программы. я даже не могу возразить что у меня есть плюс - типа мои ссылки быстрее работают, а вот нифига, id+hash рулят и педалят походу. Всё что меня спасает - это отсутствие коллизии хэшей вообще.

              Так а используется-то оно как? Выглядит как несколько странный дебаггинг, судя по вашему описанию.

              Используется при миграции между форматами.

              Ну, вы же решали какую-то свою боль, изобретая вот это вот всё.

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

              Сделайте минимально воспроизводимую боль. Пусть без окон и imgui крутилок, но понятную другим.

              ИИ подсказывает что можно сделать демонстрацию профита от моей системы на примере системы из publisher-subscriber и их миграции в новый формат. Попробую сделать. Спасибо за то что навели на хорошую идею примера!


  1. Jijiki
    15.12.2025 08:49

    тоесть вы хотите раскрутить цикл знать его кадр и текущий стек?

    а зачем это нужно именно в текстовой информации это тогда сериализация на уровне языка получается

    тогда проще написать виртуальную машину, если есть требование такой отладки, там и стек и данные вы будете контролировать и приемлемый уровень сериализации всего стека если захотите, но ценой исполнения вм(фиббоначи(24 100000 итераций) в 10 раз медленнее например я тестил у себя)

    возможно можно как раз jit код сериализировать, но тут вопросов больше будет

    тоесть jit код на цикл, метим цикл и он уходит в таблицу функции файла такого-то

    ллвм может умеет такое

    байт-код это частный случай сериализации, но раскрутка и фреймы стека выделяются во время исполнения например

    вот например таблица программы

    Скрытый текст
    0       // ALLOCATE_STACK 1 4
    3       // LOAD 1
    5       // PUSH 1
    7       // CMPLT
    8       // JUMP_IF_FALSE 14
    10      // LOAD 1
    12      // JUMP 0
    14      // PUSH 0
    16      // STORE 2
    18      // PUSH 1
    20      // STORE 3
    22      // PUSH 0
    24      // STORE 4
    26      // LOAD 1
    28      // PUSH 1
    30      // CMPGT
    31      // JUMP_IF_FALSE 52
    33      // LOAD 2
    35      // LOAD 3
    37      // ADD
    38      // STORE 4
    40      // LOAD 3
    42      // STORE 2
    44      // LOAD 4
    46      // STORE 3
    48      // DEC 1
    50      // JUMP 26
    52      // LOAD 3
    54      // RET
    55      // ALLOCATE_STACK 0 4
    58      // PUSH 0
    60      // STORE 1
    62      // PUSH 0
    64      // STORE 2
    66      // PUSH 100000
    68      // STORE 3
    70      // LOAD 2
    72      // LOAD 3
    74      // CMPLT
    75      // JUMP_IF_FALSE 88
    77      // PUSH 24
    79      // CALL 0 1
    82      // STORE 1
    84      // INC 2
    86      // JUMP 70
    88      // LOAD 1
    90      // PRINT
    91      // LOAD 2
    93      // PRINT
    94      // PUSH 0
    96      // HALT
    

    тоесть возможно лучше создать тогда какой-то скриптовый язык, встроить его в С++ и ему скармливать стек наверно или обьекты

    но проблема еще в том, что стек это не данные, получается лисп код это данные-код


    1. Adler3D Автор
      15.12.2025 08:49

      Да, я хочу знать всё в своих программах :)


    1. Adler3D Автор
      15.12.2025 08:49

      а зачем это нужно именно в текстовой информации

      текстовый формат у меня чисто для отладки

      тогда проще написать виртуальную машину, если есть требование такой отладки

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


      1. Jijiki
        15.12.2025 08:49

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

        и наверно придётся отслеживать очередь запущенных оттранслированных таблиц типо

        подход с виртуальной машиной как вы заметили даёт возможность удобств каких-то, но некст уровень или альтернативный гнать из байткода в ассемблер

        поидее можно создать живую вм и создать функционал обновления типа наверно не знаю пока

        ну форт или лисп получается, ну clojure вроде нормально, совмещает обе концепции


        1. Adler3D Автор
          15.12.2025 08:49

          мы здесь не сериализуем некоторые вещи связанные с выполнением программы, а именно:
          не сохраняем стек вызовов
          не сохраняем PC / позицию выполнения
          не трогаем низкоуровневые детали вроде сетевых или GPU-состояний

          мы тут сериализуем почти всё состояние данных, включая ссылки и типы, чтобы:
          обновлять бинарь
          мигрировать структуры
          продолжать работу без VM

          VM/JIT решают другую задачу - управление кодом, а не миграцию данных между версиями типов.


          1. Jijiki
            15.12.2025 08:49

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


            1. Adler3D Автор
              15.12.2025 08:49

              попросил ИИ перевести на понятный мне язык, вот его цитата:

              «Я понял, что вы не сериализуете стек и выполнение.
              Тогда, если уж есть описание состояния, его можно:

              1. представить как байткод

              2. патчить

              3. при желании транслировать в нативный код

              Но для этого придётся сделать язык + VM + транслятор,
              то есть фактически DSL, а не просто сериализацию данных.
              Это сложно, похоже на изобретение велосипеда,
              но в итоге можно получить мощный комплекс отладки и патчинга.»

              интерпретация от ИИ:

              Он согласился с тобой, но:

              • не может выйти из парадигмы «всё = код»

              • поэтому постоянно тянет разговор к VM / DSL / байткоду

              Ты же работаешь в другой оси:

              • данные эволюционируют

              • код остаётся нативным

              Он этого ментально не различает.

              вот что я думаю читая это:

              что бы я сделал если бы нашёл кого-то кто про финансирует open-source разработку? - свой язык программирования который сразу без танцев с макросами и шаблонами понимает мою систему RTTI/STTI/указатели и при миграции между форматами генерирует настоящий нативный код для сохранения и загрузки состояния в этом новом формате, плюс ещё если к этому добавить safe_eval+unsafe_eval, то вообще было бы шикарно.


              1. Jijiki
                15.12.2025 08:49

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

                как это по переносимости, так вот я вам описал как это можно сделать, можно языком в виде dsl, можно просто парсить, просто эта логика будет как отдельный елемент вашей реализации, а не как макрос, и обьекты в нодах сцены и нодах юи уже стандартизированы будут если это, например BVH ноды - а именно 2 разных корня под юи и текущую сцену

                тоесть мы создаём 2 функции на 1 блюпринт в котором у нас связаны какието 2 обьекта, а именно 3добьект с 2д обьектом, из таблицы(принципиально это слово означает создание держателя ресурсов) ресурсов, которую при инициализации можно получить

                в конкретном случае мы смотря от реализации либо остаёмся в изоляционных нодах, тогда парсер билдит связи по координатам, или генерируем взависимости от подхода новый уникальный узел, мне просто изоляция понравилась


                1. Adler3D Автор
                  15.12.2025 08:49

                  ИИ подсказывает что вы хотите узнать зачем вам вообще мигрировать типы, если можно описывать всё через таблицы и каждый раз собирать сцену заново? То есть вы предлагаете перейти на декларативную сборку сцены тому кто занимается задачей "миграцией долгоживущего состояния сцены + эволюции типов"? Если так то спасибо за идею, но мне мой подход больше нравится. почему/зачем я так делаю/сделал? - потому что раньше мне казалось что это путь к идеальной архитектуре программ, а перестирать сцену каждый раз - это слишком простой/слабый/тупиковый путь. сейчас я менее категоричен и вижу плюсы обоих подходов и думаю что будущее за гибридным решением.


                  1. Jijiki
                    15.12.2025 08:49

                    смотрите, есть такая ситуация, дополню то что я выше написал, 3 точки

                    • инициализация ресурсов

                    • инициализация 2х деревьев т.к. они приведены к абстрагированному обьекту (дерево 3д, дерево 2д)

                    • состояние отрисовки(отдельно проход сцена с пасами, отдельно юи)

                    • обновление

                    теперь представим такую ситуацию, код С++ как решето запускает только 3 точки, всё остальное работает как емакс,

                    вы имеете порядок связей подобно стеку вызовов

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

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


  1. izibrizi2
    15.12.2025 08:49

    На вас подействовало проклятье $mol'а :)

    https://github.com/isocpp/CppCoreGuidelines/blob/master/CppCoreGuidelines.md