История одной миграции типов: как эволюционирует простейший редактор графов

Главный герой этой статьи — не код

Это файл сохранения 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:
};

Никакой логики. Просто геометрия и сериализация.

На экране — только чёрные шары, раскиданные по плоскости.

Картинка 006      | Базовые узлы                 | Чёрные шары
Картинка 006 | Базовые узлы | Чёрные шары

Версия 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++;
  }
  ...
};

Запускаем и смотрим на экран

Некоторые шары окрашены, остальные — чёрные.

Картинка 007      | Цвет + флаг provider         | Появились цветные источники
Картинка 007 | Цвет + флаг provider | Появились цветные источники

Версия 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:
};

На этом этапе граф уже есть, но он ещё топологически бедный.

Запускаем и смотрим на экран

Между шарами появляются линии, соединяющие центры.

Картинка 008      | Связи (линки)                | Линии между центрами
Картинка 008 | Связи (линки) | Линии между центрами

Версия 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);
  }
}

Результат:

Цвет от провайдеров «растекается» по графу.

Картинка 009      | (логика BFS)                 | Цвет "растекается"
Картинка 009 | (логика BFS) | Цвет "растекается"

Версия 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;
}

Результат:

Линки подключаются не к центру, а к портам.

Картинка 010      | Порты + привязка к ним       | Линии цепляются к портам
Картинка 010 | Порты + привязка к ним | Линии цепляются к портам

Версия 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:
};

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

Финальный вид: линки — это уже геометрия, а не просто абстрактная связь.

Картинка 011      | Геометрия линий              | Плавные кривые
Картинка 011 | Геометрия линий | Плавные кривые

Этот пример не про редактор графов, а про демонстрацию работы алгоритма сериализации. Про то, как живёт формат данных в реальном проекте.

Когда система развивается, мы неизбежно:

  • добавляем новые поля

  • меняем интерпретацию старых

  • усложняем логику

И если формат данных спроектирован так, чтобы расти, а не ломаться, то история проекта перестаёт быть балластом и становится активом.

Алгоритмы можно переписать. Сохранённые данные — нет.

Хорошо спроектированный формат данных — это не следствие архитектуры. Это причина, по которой архитектура вообще может меняться.

Что это даёт на практике?

  1. Быстрые эксперименты — можно пробовать радикальные изменения структур

  2. Параллельная разработка — разные версии редактора могут работать с одними данными

  3. Отладка — всегда можно сохранить состояние и воспроизвести баг

  4. Пользовательские моды — сообщество может расширять форматы без страха сломать всё

Технический итог

// Мы прошли путь от:
struct t_node{vec2d pos;double r;};
// До:
struct t_node{
  vec2d pos; double r; QapColor color; bool provider;
  vector<t_port> ports;
};
// + связи, геометрия, состояние

И ни один сохранённый объект не был потерян.

Хочешь так же?

Проект с сериализатором: [QapSerialize] :: Исходники редактора
Эта статья — лишь демо. В реальной системе есть:

  • Поддержка полиморфизма и наследования

  • Циклические ссылки без утечек

  • Текстовый дамп для отладки

  • И многое другое... Пиши в комментариях, если хочешь подробностей про внутреннее устройство!

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


  1. sergey2212
    23.12.2025 08:14

    Классный пример. Практичный. Если формат выживает на графах — он переживёт почти что угодно.


  1. nin-jin
    23.12.2025 08:14

    А почему вы обфусцировали не весь код? Не боитесь, что его украдут?


    1. Adler3D Автор
      23.12.2025 08:14

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


      1. nin-jin
        23.12.2025 08:14

        А как вы решили что обфусцировать, а что - нет?


        1. Adler3D Автор
          23.12.2025 08:14

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


  1. HiItsYuri
    23.12.2025 08:14

    Кодстайл интересный, конечно. Сводит на нет полезность сниппетов.


    1. alexdmy
      23.12.2025 08:14

      gcc -E file.cpp в помощь


  1. domix32
    23.12.2025 08:14

    Теперь идея понятна. Хотя подход я бы назвал сомнительным. В кооперативе подобными расширениями обычно не занимаются - из-за скорости итерирования, а в соло проще простенькие конвертер написать и также расширять немного кода по мере разрастания структур данных - месиво из макросов поддерживать сложнее, чем пару функций чтения/записи, а мигрировать данные с v0 до v200 кажется нереалистичным, чтобы иметь какое-то применение.


  1. xakepp35
    23.12.2025 08:14

    Пример неудачный. Сплошные маркосы слабочитаемые дефайны, либо код вида q.w.e.r.t.y*=z.x.c.v.b; рекомендую в следующий раз перед постом просить llm отрефакторить ваш код в целях повышения наглядности и читаемости. Это улучшит качество оьучающего материала. Также можно выбрать более современный актуальный язык, хотя бы с поддержкой подсветки синтаксиса.. тот же раст вон позволяет писать все те же игры и там есть тоже своя философия, но и включена подсветка синтаксиса и проверка типов компилятором.