
История одной миграции типов: как эволюционирует простейший редактор графов
Главный герой этой статьи — не код
Это файл сохранения
save_node_editor.qap.
Пока мы 5 раз меняли структуры данных, добавляли поля и новые типы — этот файл продолжал работать. Без миграционных скриптов, без ручных конвертеров, без потери данных пользователей.
Это возможно благодаря самоописывающейся сериализации с автоматической миграцией.
В реальном проекте сложность появляется не из-за алгоритмов, а из-за сохранённых данных пользователей.
Если формат данных не умеет расти — проект умирает.
Я покажу эволюцию простого редактора графов — от «чёрных точек» до узлов с портами, цветами и красиво переливающимися цветами каналов. Картинки для удобства пронумерованы от 006 до 011 и соответствуют разным версиям формата данных.
Проект — экспериментальный визуальный редактор графов.
Он развивается итеративно: структура данных меняется чаще, чем логика.
Сохранённые файлы используются постоянно между сборками.
Прежде чем приступить опишем основные структуры редактора.
// слой/документ в котором будет хранится наша геометрия
class t_layer{
public:
#define DEF_PRO_STRUCT_INFO(NAME,PARENT,OWNER)NAME(t_layer)
#define DEF_PRO_VARIABLE(ADDBEG,ADDVAR,ADDEND)\
ADDBEG()\
ADDVAR(string,name,DEF,$,$)\
ADDEND()
//=====+>>>>>t_layer
#include "QapGenStruct.inl"
//<<<<<+=====t_layer
public:
};
// наш мир с котором будет работать редактор
// добавим для удобства в него несколько слоёв/документов помимо текущего слоя
class t_world{
public:
#define DEF_PRO_STRUCT_INFO(NAME,PARENT,OWNER)NAME(t_world)
#define DEF_PRO_VARIABLE(ADDBEG,ADDVAR,ADDEND)\
ADDBEG()\
ADDVAR(int,tick,SET,0,$)\
ADDVAR(vector<t_layer>,layers,DEF,$,$)\
ADDVAR(int,layer_id,SET,-1,$)\
ADDVAR(t_layer,cur,DEF,$,$)\
ADDEND()
//=====+>>>>>t_world
#include "QapGenStruct.inl"
//<<<<<+=====t_world
public:
};В реальном проекте изменения происходили в другом порядке и с откатами, но для статьи путь упрощён и выпрямлен — чтобы наглядно показать сам принцип разработки при использовании last_hope_loader на основе RTTI.
Начнём с минимального возможного формата.
Версия 006 — просто узлы
Модель данных
class t_node{
public:
#define DEF_PRO_STRUCT_INFO(NAME,PARENT,OWNER)NAME(t_node)
#define DEF_PRO_VARIABLE(ADDBEG,ADDVAR,ADDEND)\
ADDBEG()\
ADDVAR(TSelfPtr<SelfClass>,Self,DEF,$,$)\
ADDVAR(bool,enabled,SET,true,$)\
ADDVAR(vec2d,pos,DEF,$,$)\
ADDVAR(double,r,SET,32,$)\
ADDEND()
//=====+>>>>>t_node
#include "QapGenStruct.inl"
//<<<<<+=====t_node
};
// Добавим "vector<t_node> t_layer::nodes;"
class t_layer{
public:
#define DEF_PRO_STRUCT_INFO(NAME,PARENT,OWNER)NAME(t_layer)
#define DEF_PRO_VARIABLE(ADDBEG,ADDVAR,ADDEND)\
ADDBEG()\
ADDVAR(string,name,DEF,$,$)\
ADDVAR(vector<t_node>,nodes,DEF,$,$)\
ADDEND()
//=====+>>>>>t_layer
#include "QapGenStruct.inl"
//<<<<<+=====t_layer
public:
};Никакой логики. Просто геометрия и сериализация.
На экране — только чёрные шары, раскиданные по плоскости.

Версия 007 — появляются провайдеры цвета
Миграция данных
Мы аккуратно расширяем тип, не ломая текущий файл сохранения с состоянием редактора:
// Добавляем "QapColor t_node::color=0xff000000;"
// Добавляем "bool t_node::provider=false;"
class t_node{
public:
#define DEF_PRO_STRUCT_INFO(NAME,PARENT,OWNER)NAME(t_node)
#define DEF_PRO_VARIABLE(ADDBEG,ADDVAR,ADDEND)\
ADDBEG()\
ADDVAR(TSelfPtr<SelfClass>,Self,DEF,$,$)\
ADDVAR(bool,enabled,SET,true,$)\
ADDVAR(vec2d,pos,DEF,$,$)\
ADDVAR(double,r,SET,32,$)\
ADDVAR(QapColor,color,SET,0xff000000,$)\
ADDVAR(bool,provider,SET,false,$)\
ADDEND()
//=====+>>>>>t_node
#include "QapGenStruct.inl"
//<<<<<+=====t_node
};Никаких миграционных скриптов мы писать не будем в этой статье, т.к они не нужны(это не означает, что миграции не нужны вообще — это означает, что в данном классе изменений они не требуются), в этом примере используется сериализатор с поддержкой автоматической миграции типов (проект QapSerialize), поэтому отдельные миграционные скрипты здесь не требуются. Всё сделает автоматика.
Вот пользовательский код вызывающий механизм загрузки с поддержкой автоматической миграции(с помощью которой на протяжении всей статьи пользовательские данные будут выживать):
class TGame:public TQapGameV2{
public:
IEnvRTTI*pEnv=nullptr;
string fn="save_node_editor.qap";
t_world w;
public:
void DoMigrate(){
// пробуем загрузиться из бинарного файла методом "последняя надежда"
// не самый быстрый метод, но зато самый универсальный(с миграцией).
// загрузчик сопоставляет поля по именам и типам,
// инициализируя новые значениями по умолчанию и игнорируя отсутствующие
if(!QapPublicUberFullLoaderBinLastHope(*pEnv,QapRawUberObject(w),fn)){
DoInit(); // на случай если не получилось
}
}
void DoMove()override{
if(kb.OnDown(VK_ESCAPE)){
// сохраняем в универсальный бинарный формат
QapPublicUberFullSaverBin(*pEnv,QapRawUberObject(w),fn);
TerminateProcess(GetCurrentProcess(),0);
}
if(w.tick==0){
DoMigrate();
}
DoUpdate();
w.tick++;
}
...
};Запускаем и смотрим на экран
Некоторые шары окрашены, остальные — чёрные.

Версия 008 — добавляем связи (линки)
Новые типы
class t_endpoint{
public:
#define DEF_PRO_AUTO_COPY
#define DEF_PRO_STRUCT_INFO(NAME,PARENT,OWNER)NAME(t_endpoint)
#define DEF_PRO_VARIABLE(ADDBEG,ADDVAR,ADDEND)\
ADDBEG()\
ADDVAR(TWeakPtr<t_node>,n,DEF,$,$)\
ADDEND()
//=====+>>>>>t_endpoint
#include "QapGenStruct.inl"
//<<<<<+=====t_endpoint
public:
};
class t_link{
public:
#define DEF_PRO_AUTO_COPY
#define DEF_PRO_STRUCT_INFO(NAME,PARENT,OWNER)NAME(t_link)
#define DEF_PRO_VARIABLE(ADDBEG,ADDVAR,ADDEND)\
ADDBEG()\
ADDVAR(bool,enabled,SET,true,$)\
ADDVAR(t_endpoint,a,DEF,$,$)\
ADDVAR(t_endpoint,b,DEF,$,$)\
ADDVAR(double,line_size,SET,4,$)\
ADDEND()
//=====+>>>>>t_link
#include "QapGenStruct.inl"
//<<<<<+=====t_link
public:
};И ещё добавим vector<t_link> links; в t_layer:
class t_layer{
public:
#define DEF_PRO_STRUCT_INFO(NAME,PARENT,OWNER)NAME(t_layer)
#define DEF_PRO_VARIABLE(ADDBEG,ADDVAR,ADDEND)\
ADDBEG()\
ADDVAR(string,name,DEF,$,$)\
ADDVAR(vector<t_node>,nodes,DEF,$,$)\
ADDVAR(vector<t_link>,links,DEF,$,$)\
ADDEND()
//=====+>>>>>t_layer
#include "QapGenStruct.inl"
//<<<<<+=====t_layer
public:
};На этом этапе граф уже есть, но он ещё топологически бедный.
Запускаем и смотрим на экран
Между шарами появляются линии, соединяющие центры.

Версия 009 — распространение цвета (BFS)
Здесь появляется поведенческая логика, но формат данных почти не меняется.
Важно: сам алгоритм здесь не принципиален. Он нужен лишь для того, чтобы показать, что логика может меняться и усложняться, в то время как формат данных продолжает эволюционировать независимо.
void DoPropWithBFS(){
auto&nodes=w.cur.nodes;
auto&links=w.cur.links;
vector<t_node*> cur,next;map<t_node*,int> V;
map<t_node*,t_color_accum> acc;
for(auto&ex:nodes)if(ex.provider){cur.push_back(&ex);acc[&ex].add(ex.color);}
for(int iter=1;cur.size();iter++){
for(auto&f:cur){
for(auto&ex:links){
if(!ex.a.n||!ex.b.n)continue;
if(ex.a.n.get()!=f&&ex.b.n.get()!=f)continue;
auto*oe=ex.a.n.get()==f?ex.b.n.get():ex.a.n.get();
if(oe->provider||oe==f)continue;
acc[oe].add(acc[f].get());
auto&v=V[oe];if(v)continue;v=iter;
next.push_back(oe);
}
}
cur=std::move(next);
}
for(int i=0;i<nodes.size();i++){
auto&n=nodes[i];
if(n.provider)continue;
if(!acc[&n].n)continue;
n.color=acc[&n].get();
n.name=IToS(acc[&n].n);
}
}Результат:
Цвет от провайдеров «растекается» по графу.

Версия 010 — порты
Новые изменения
// добавляем новый тип t_port
class t_port{
public:
#define DEF_PRO_STRUCT_INFO(NAME,PARENT,OWNER)NAME(t_port)
#define DEF_PRO_VARIABLE(ADDBEG,ADDVAR,ADDEND)\
ADDBEG()\
ADDVAR(vec2d,offset,DEF,$,$)\
ADDVAR(double,r,SET,8,$)\
ADDVAR(string,type,DEF,$,$)\
ADDEND()
//=====+>>>>>t_port
#include "QapGenStruct.inl"
//<<<<<+=====t_port
public:
};
// добавляем "vector<t_port> t_node::ports;"
class t_node{
public:
#define DEF_PRO_STRUCT_INFO(NAME,PARENT,OWNER)NAME(t_node)
#define DEF_PRO_VARIABLE(ADDBEG,ADDVAR,ADDEND)\
ADDBEG()\
ADDVAR(TSelfPtr<SelfClass>,Self,DEF,$,$)\
ADDVAR(bool,enabled,SET,true,$)\
ADDVAR(vec2d,pos,DEF,$,$)\
ADDVAR(double,r,SET,32,$)\
ADDVAR(QapColor,color,SET,0xff000000,$)\
ADDVAR(bool,provider,SET,false,$)\
ADDVAR(vector<t_port>,ports,DEF,$,$)\
ADDEND()
//=====+>>>>>t_node
#include "QapGenStruct.inl"
//<<<<<+=====t_node
public:
};
// добавляем "int t_endpoint::port_id;"
class t_endpoint{
public:
#define DEF_PRO_AUTO_COPY
#define DEF_PRO_STRUCT_INFO(NAME,PARENT,OWNER)NAME(t_endpoint)
#define DEF_PRO_VARIABLE(ADDBEG,ADDVAR,ADDEND)\
ADDBEG()\
ADDVAR(TWeakPtr<t_node>,n,DEF,$,$)\
ADDVAR(int,port_id,SET,0,$)\
ADDEND()
//=====+>>>>>t_endpoint
#include "QapGenStruct.inl"
//<<<<<+=====t_endpoint
public:
};Обратите внимание: мы не «переделываем» существующие линки, а лишь расширяем их интерпретацию. Старые данные по-прежнему валидны — просто теперь они могут быть отображены более точно.
Добавляем простой алгоритм выбора лучших портов:
void DoFixLinkPorts(t_link&ref){
if(!ref.a.n||!ref.b.n)return;
struct t_best{bool ok=false;int a;int b;int d;void aib(const t_best&c){if(!ok||c.d<d)*this=c;}};
t_best best;
int aid=0;
for(auto&a:ref.a.n->ports){
int bid=0;
for(auto&b:ref.b.n->ports){
auto pa=ref.a.n->pos+a.offset;
auto pb=ref.b.n->pos+b.offset;
t_best cur={true,aid,bid,pa.sqr_dist_to(pb)};
best.aib(cur);
bid++;
}
aid++;
}
if(!best.ok)return;
ref.a.port_id=best.a;
ref.b.port_id=best.b;
}Результат:
Линки подключаются не к центру, а к портам.

Версия 011 — красивые кривые между портами
Последнее расширение
// Добавляем "vector<vec2d> t_link::line;"
class t_link{
public:
#define DEF_PRO_AUTO_COPY
#define DEF_PRO_STRUCT_INFO(NAME,PARENT,OWNER)NAME(t_link)
#define DEF_PRO_VARIABLE(ADDBEG,ADDVAR,ADDEND)\
ADDBEG()\
ADDVAR(bool,enabled,SET,true,$)\
ADDVAR(t_endpoint,a,DEF,$,$)\
ADDVAR(t_endpoint,b,DEF,$,$)\
ADDVAR(double,line_size,SET,4,$)\
ADDVAR(vector<vec2d>,line,DEF,$,$)\
ADDEND()
//=====+>>>>>t_link
#include "QapGenStruct.inl"
//<<<<<+=====t_link
public:
};На этом этапе формат данных позволяет хранить произвольную геометрию линков — вне зависимости от того, была ли она получена автоматически, интерактивно или внешним инструментом.
Финальный вид: линки — это уже геометрия, а не просто абстрактная связь.

Этот пример не про редактор графов, а про демонстрацию работы алгоритма сериализации. Про то, как живёт формат данных в реальном проекте.
Когда система развивается, мы неизбежно:
добавляем новые поля
меняем интерпретацию старых
усложняем логику
И если формат данных спроектирован так, чтобы расти, а не ломаться, то история проекта перестаёт быть балластом и становится активом.
Алгоритмы можно переписать. Сохранённые данные — нет.
Хорошо спроектированный формат данных — это не следствие архитектуры. Это причина, по которой архитектура вообще может меняться.
Что это даёт на практике?
Быстрые эксперименты — можно пробовать радикальные изменения структур
Параллельная разработка — разные версии редактора могут работать с одними данными
Отладка — всегда можно сохранить состояние и воспроизвести баг
Пользовательские моды — сообщество может расширять форматы без страха сломать всё
Технический итог
// Мы прошли путь от:
struct t_node{vec2d pos;double r;};
// До:
struct t_node{
vec2d pos; double r; QapColor color; bool provider;
vector<t_port> ports;
};
// + связи, геометрия, состояниеИ ни один сохранённый объект не был потерян.
Хочешь так же?
Проект с сериализатором: [QapSerialize] :: Исходники редактора
Эта статья — лишь демо. В реальной системе есть:
Поддержка полиморфизма и наследования
Циклические ссылки без утечек
Текстовый дамп для отладки
И многое другое... Пиши в комментариях, если хочешь подробностей про внутреннее устройство!