(эксперимент десятилетней давности, который до сих пор не даёт мне покоя)
⚠️ Дисклеймер
Это не готовая библиотека и не «лучше protobuf».
Это экспериментальная система сериализации, написанная более 10 лет назад,
для решения задачи, с которой стандартные форматы справляются плохо:
сохранение и восстановление runtime-графов объектов с рефлексией и циклами.
Зачем вообще ещё одна сериализация?
сценарий:
У меня есть:
– редактор
– сцена
– объекты с взаимными ссылками
– UI, который указывает на поля этих объектовЯ хочу:
– сохранить всё
– поменять код
– загрузить обратно
– и не поте��ять ни одной связи
Большинство форматов сериализации (JSON, XML, protobuf, flatbuffers):
хорошо работают с деревьями
плохо работают с графами
почти не работают с:
циклами
указателями
полиморфизмом
ссылками на части объектов
А если работают — то ценой:
внешнего кода
жёсткой схемы
ручного glue-кода
Мне же нужна была система, которая умеет:
сериализовать произвольный объектный граф
сохранять типы и связи
быть самоописывающейся
и при этом иметь бинарный и текстовый режим
Так родилась эта система.
Ограничения (сразу и честно)
Чтобы сразу снять лишние вопросы:
компилируется только MSVC
только 32-bit
активно использует макросы
проект заморожен
время компиляции стало критической проблемой при росте кода
Это не «продакшн-решение», а инженерный эксперимент.
Общая идея формата
Файл сериализации состоит из четырёх логических частей:
Имена описателей метатипов
Описание всех используемых типов используя описатели метатипы
Что-то вроде карты связывающей описание типов с их реализацией
Собственно данные пользовательской структуры (объектный граф)
Причём:
файл почти не требует внешнего кода
он содержит почти всё необходимое для интерпретации данных
текстовый формат изоморфен бинарному
Два слоя описания типов: 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получает уникальный idTWeakPtrссылается на этот idпорядок создания не важен
циклы не проблема
Почему текстовый формат не предназначен для загрузки?
Это принципиальный момент.
Текстовый файл:
изоморфен бинарному
полностью повторяет структуру данных
предназначен для:
отладки
анализа
diff’ов
понимания состояния системы
Но:
он не устойчив к правкам руками
числа с плавающей точкой — отдельная боль
он ближе к IR, чем к DSL
Это осознанное решение.
Самоописание типов
До payload в файле содержится полное описание всех типов:
stringintrealt_nodevector<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у него есть поле
linkst_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: структурный путь
Идея:
хранить не «указатель»
Примерно как:
root.n.arr[1].linksНо:
в структурированном виде
с валидацией
с возможностью переинтерпретации
Плюсы:
устойчиво к изменению layout’а
идеально для текста
можно добраться до любого поля
Фактически — DSL навигации по объектному графу.
Про «умные слабые следящие указатели»
Самый неожиданный вывод:
Я так и не понял, как получать профит от умных слабых следящих указателей в реальном мире.
В прочем, как позднее выяснялось, проблема не в указателях.
На практике удобнее оказались:
индексы
строки
структурные пути
скрипты-указатели
Умные указатели хороши внутри кода, но в данных они часто создают больше проблем, чем решают.
Итоговый вывод
Эта система:
не стала библиотекой
не дошла до продакшна
но дала очень ценный опыт
Главный вывод от ИИ:
Сериализация — это не про байты. Это про сохранение инвариантов связей.
И чем сложнее система, тем важнее:
граф, а не дерево
семантика, а не layout
структура, а не адреса
Вместо заключения
Если бы я делал это сегодня:
меньше шаблонов
меньше магии
больше декларативности
больше текстовых, стабильных идентификаторов
Но идеи:
самоописания
графов
структурных путей
— я бы точно взял с собой дальше.
Часть 4. Жёсткие ограничения, которые всплыли на практике
На бумаге система выглядела мощной: графы, ссылки, полиморфизм, самоописание, миграции. Но при реальной попытке удалять поля и типы при миграции вскрылись неприятные ограничения. И это важнее любых красивых примеров.
Удаление объектов с TSelfPtr — неожиданная проблема
Первый тревожный симптом:
Удаление(при миграции) объекта с
TSelfPtrломает загрузку,
даже если на этот объект больше никто не ссылается.
Интуитивно ожидаешь:
нет
TWeakPtrнет
TAutoPtrобъект просто исчезает при миграции
Но нет.
Система не справляется с этим сценарием.
«А если есть слабая ссылка наружу?» — тоже нет
Следующая гипотеза была такой:
Если из удаляемого объекта есть только слабая ссылка наружу, значит его можно безопасно удалить.
Результат:
наличие
TSelfPtrв удаляемом при миграции типе пока делает миграцию невозможной
TAutoPtr усугубляет ситуацию
Следующее наблюдение оказалось ещё жёстче:
TAutoPtrвнутри удаляемого при миграции объекта хранить нельзя вообще
То есть если тип содержит:
TSelfPtrTAutoPtr
— система миграции пока не справляется.
Что в итоге реально безопасно мигрирует
После серии экспериментов стало ясно:
Надёжно удаляются при миграции только «тупые» типы
А именно:
встроенные типы
строки
структуры
векторы
комбинации вышеперечисленного
Без:
TSelfPtrTWeakPtrTAutoPtrTFieldPtr
Рабочий миграционный тест

Полный код теста:
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;
}Суть теста миграции
Тест делает следующее:
Есть старая версия типов (
t_before::)В ней:
лишние поля
изменённый порядок
вложенные структуры
TFieldPtrДанные сохраняются в файл
Затем файл загружается в новую версию типов
Проверяется:
данные на месте
логика не сломалась
Что именно меняется между версиями
В старой версии t_leaf содержит:
old_stuff(вложенная структура)payloadpayload2payload3nameTFieldPtr
В новой версии t_leaf:
нет
old_stuffнет
payload2,payload3поля в другом порядке
остались только:
namepayloadTFieldPtr
При этом:
данные успешно загружаются
payload == "foo"name == "leaf"check()проходит
Этот тест работает, т.к:
сериализация опирается на имена полей
лишние поля просто игнорируются
отсутствующие поля получают дефолты
layout памяти не используется
данные сопоставляются декларативно

Что показывает diff proto-файлов
Diff между out_before.qap.proto и out.qap.proto наглядно показывает:
какие поля исчезли
какие типы больше не используются
как изменилось описание структуры
Где посмотреть/попробовать?
Репозиторий: Adler3d/QapSerialize
Код примера из статьи: CommonUnit.cpp
PS: Мечтаю сделать компилятор который будет сразу эффективно поддерживать такую систему сериализации, но пока на это нет ресурсов, может они есть у вас? Давайте построим крутую систему вместе :)
