В основе любого современного языка программирования лежит какая-то ссылочная модель, описывающая структуры данных которыми будут оперировать приложения. Она определяет как объекты ссылаются друг на друга, в какой момент объект можно удалить, когда и как объект можно изменять.
Status Quo
Большинство современных языков программирования строятся на одной из трех ссылочных моделей:
Первая категория это языки с ручным управлением временем жизни объектов. Примеры — C/C++/Zig. В этих языках объекты аллоцируются и освобождаются вручную, а указатель — это просто адрес памяти, никого ни к чему не обязывающий.
Во вторую категорию попадают языки с подсчетом ссылок. Это Оbjective-C, Swift, Частично Rust, C++ при использовании умных указателей и некоторые другие. Эти языки позволяют автоматизировать до некоторой степени удаление ненужных объектов. Но это имеет свою цену. В многопоточный среде такие счетчики ссылок должны быть атомарными, а это дорого. К тому же, подсчет ссылок не может освободить все виды мусора. Когда объект А ссылается на объект Б а объект Б обратно ссылается на объект А такая закольцованная иерархия не может быть удалена подсчетом ссылок. Такие языки как Rust, Swift вводят дополнительные не владеющие ссылки которые решают проблему закольцовок ценой усложнения объектной модели и синтаксиса.
В третью категорию попадают большинство современных языков программирования. Это языки с автоматической сборкой мусора: Java, JavaScript, Kotlin, Python, Lua... В этих языках ненужные объекты удаляются автоматически, но есть нюанс. Сборщик мусора потребляет очень много памяти и процессорного времени. Он включается в случайные моменты времени и ставит основную программу на паузу. Иногда полностью — на все свое время работы, иногда частично. Сборки мусора без пауз не существует. Гарантию сборки всего мусора может дать только алгоритм который просматривает всю память и останавливает приложение на все свое время работы. В реальной жизни такие сборщики давно не используются ввиду своей неэффективности. В современных системах некоторые мусорные объекты не удаляются вообще.
Кроме того, само определение ненужного объекта нуждается в уточнении. Если, например, у нас есть GUI-приложение, и вы убираете с формы какой-то управляющий элемент, подписанный на события таймера, он не может быть удален просто так потому что где-то в объекте таймера хранится ссылка на этот объект, и сборщик мусора не будет считать такой объект мусором.
Как уже говорилось выше, каждая из трех ссылочных моделей имеет свои недостатки. В первом случае имеем дыры в memory safety и утечки памяти, во втором случае мы имеем медленную работу в многопоточной среде и утечки памяти из-за закольцовок, в третьем получаем спорадические остановки программы сильное потребление памяти, процессора и необходимость ручного разрыва ссылок когда объект становится не нужным. К тому же система с подсчетом ссылок и сборкой мусора не позволяют управлять временем жизни других ресурсов — таких как открытые файловые дескрипторы, идентификаторы окон, процессов, шрифтов и так далее. Эти методы рассчитаны только на память. Есть еще одна проблема систем со сборкой мусора — виртуальная память. В условиях, когда программная система накапливает мусор, а затем сканирует память для его освобождения, вытеснение части адресного пространства на внешний носитель может полностью убить производительность приложения. Поэтому сборка мусора не совместима с виртуальной памятью.
То есть проблемы есть и текущие методы их решения имеют изъяны.
Возможное решение
Давайте попробуем построить ссылочную модель свободную от вышеперечисленных недостатков. Во-первых нужно собрать требования, посмотреть на то, как на самом деле объекты используются в программах, что программист ждет от иерархии объектов, как его работу можно облегчить и автоматизировать без потери производительности.
Наша индустрия накопила богатый опыт проектирования моделей данных. Можно сказать, что этот опыт обобщен в универсальном языке моделирования UML. UML вводит три вида отношений между объектами: ассоциация, композиция и агрегация.
Ассоциация — когда один объект знает о другом. Ассоциация не предполагает владения.
Композиция — когда объект монопольно владеет другим объектом. Например, колесо может находиться одновременно только в одной машине.
Агрегация это множественное владение. Когда например много человек шарят имя Андрей.
Разберем это на более конкретных примерах:
База данных владеет своими таблицами, вьюхами, нумераторами и хранимыми процедурами. Таблица владеет своими записями, метаданными колонок и индексами. Запись владеет своими полями. Другой пример: форма пользовательского интерфейса владеет своими контролами, список владеет своими элементами. Документ владеет своими таблицами стилей страницами которые в свою очередь владеют элементами на странице текстовые блоки владеют абзацами которые владеют символами. Все эти отношения — это композиция. Композиция всегда формирует древовидную структуру, в которой у каждого объекта есть ровно один владелец, и объект существует только до тех пор, пока этот владелец на него ссылается. Мы всегда знаем, когда объект нужно удалить, то есть сборщик мусора для таких ссылок не нужен, как не нужен и их подсчет.
Примеры ассоциации: абзацы документов ссылаются на стили, одни записи таблиц ссылаются на другие записи других таблиц (допустим что у нас продвинутая реляционная база данных, в которой такие связи кодируются специальным типом данных, а не с помощью foreign keys), элементы управления на GUI-форме связаны в цепочку перехода по клавише Tab, control на форме ссылается на модель данных, а она в свою очередь ссылается на control в каком-нибудь реактивном приложении. Все эти связи обеспечиваются не владеющими указателями. Они не препятствуют удалению объекта, но обязаны как-то обрабатывать это удаление, чтобы обеспечить memory safety, а язык должен пресекать попытки доступа по таким ссылкам без проверки на потерю объекта.
Агрегация штука опасная. Весь опыт индустрии говорит о том что агрегировать можно только неизменяемые объекты. Например неизменяемыми являются строки в Java, поэтому множество объектов может ссылаться на одну и ту же строку. Если объект имеет множество владельцев из разных иерархий, его изменение приведет к самым печальным последствиям в самых неожиданных местах. Поэтому язык программирования должен пресекать агрегирование изменяемого объекта. Можно ли исключить использование агрегации полностью как то рекомендуют соглашение о стиле кодирования Гугла? Это было бы слишком радикально. Например, популярный паттерн проектирования flyweight построен именно на агрегации. К тому же агрегат здорово поможет в многопоточной среде, где неизменяемые объекты могут безопасно шариться между потоками.
Интересно, что иерархия неизменяемых объектов связанных агрегирующими ссылками не может содержать закольцовок. Каждый неизменяемый объект начинает свою жизнь как изменяемый, так как объект нужно заполнить данными и только потом "заморозить", сделав неизменяемым. А не изменяемые объекты, как мы уже убедились, не могут содержать закольцовок. Следовательно закольцовки в правильно организованных иерархиях объектов могут происходить только по не-владеющим ассоциативным ссылкам. Владеющие ссылки композитов всегда формируют дерево. Агрегирующие ссылки — направленный ациклический граф (DAG). И ни одна из этих структур не нуждается в сборщике мусора.
Получается что основная проблема существующих ссылочных моделей в том, что они позволяют создавать структуры данных противоречащие best practices нашей индустрии и, как побочный эффект, создающие кучу проблем с memory safety и утечкам памяти. Затем использующие эти модели языки героически борются с последствиями своей архитектуры не затрагивая первопричин.
Если спроектировать язык программирования, который будет:
на декларативном уровне поддерживать UML-ссылки
пользуясь этими декларациями автоматически генерировать все операции над объектами (копирование, разрушение, передача между потоками и т.д.),
на этапе компиляции инфорсить правила использования этих ссылок (один владелец, константность, проверка на потерю объекта и т.д.),
...то такой язык обеспечит и memory safety, и отсутствие утечек памяти, и отсутствие оверхедов сборщика мусора, и существенно упростит жизнь программиста. А поскольку объекты будут удаляться в предсказуемые моменты времени, это позволит присоединить управление ресурсами к времени жизни объектов.
Реализация
В экспериментальном языке программирования Argentum идея UML-ссылок реализована так:
Поле класса, помеченное "&" является не владеющей ссылкой (ассоциацией).
Поле помеченное "*" является разделяемой (шареной) ссылкой на неизменяемый объект (аггрегацией)
Все прочие поля-ссылки это композиция (такое поле — единственный владелец изменяемого объекта).
Пример:
class Scene {
// Поле `elements` владеет объектом `Array`, который владеет множеством `SceneItem`s
// Это композиция
elements = Array(SceneItem);
// Поле `focused` ссылается на произвольный `SceneItem`. Без владения
// Это ассоциация
focused = &SceneItem;
}
interface SceneItem { ... }
class Style { ... }
class Label { +SceneItem; // Наследование
text = ""; // Композиция: строка принадлежит лейблу
// Поле `style` ссылается на неизменяемый экземпляр `Style`
// Его неизменяемость позволяет ему шариться между всеми, кто на него ссылается
// Это агрегация
style = *Style;
}
Продолжение примера, создание объектов:
// Создаем объект класса Scene и сохраняем в переменной
// `root` это композитная ссылка (с единственным владельцем)
root = Scene;
// Создаем объект класса Style; заполняем его, вызывая методы инициализации;
// замораживем, превращая в неизменяемый объект с помощью *-оператора
// `normal` это агрегирующая ссылка
// (другие ссылки могут ссылаться на тот же экземпляр Style)
normal = *Style.font(times).size(40).weight(600);
// Создаем объект класса Label, инициализируем поля и вставляем в `scene`
root.elements.add(
Label.at(10, 20).setText("Hello").setStyle(normal));
// Настраиваем ссылку из `scene` в `Label`
root.focused := &root.elements[0];
Сконструированная нами структура данных дает несколько важных гарантий целостности:
root.elements.add(root.elements[0]);
// ошибка компиляции: объект `Label` может иметь только одного владельца
normal.weight := 800;
// ошибка компиляции: `normal` - неизменяемый объект
root.focused.hide();
// ошибка компиляции: нет проверки и реакции на оборванную ссылку `focused`
Но Аргентум не только следит за программистом (и бьет его по рукам). Он еще и помогает, попробуем исправить ошибки компиляции:
root.elements.add(@root.elements[0]);
// @-оператор глубокого копирования.
// В этой строке на сцену добавляется _копия_ Label,
// которая ссылается на _копию_ текста, но шарит тот же самый Style-объект.
normal := *(@normal).weight(800);
// Сделать изменяемую копию Style-объекта,
// изменить в нем weight,
// заморозить эту копию (сделать ее неизменяемой)
// и пусть `normal` ссылается не нее.
root.focused? _.hide();
// Защитить объект по ассоциативной ссылке от удаления,
// и если он не пуст, вызвать его метод.
// после чего снять с объекта защиту.
Все операции по копированию, заморозке, разморозке, удалению, передаче между потоками и т.д. выполняются автоматически. Компилятор строит эти операции используя composition-aggregation-association-деклараций в полях объектов.
Например, если написать:
newScene := @root;
...то будет сделана полная копия сцены с правильно настроенными внутренними ссылками:
Обратите внимание:
все подобъекты, которые должны иметь единственного владельца копируются каскадно.
объекты, помеченные как шаренные (Style) не участвуют в копировании
в копии сцены поле focused правильно ссылается на копию label.
Аавтоматизация ключевых операций над иерархиями объекта в Аргентуме:
обеспечивает memory safety
обеспечивает отсутствие утечек памяти (и делает ненужным сборщик мусора)
гарантирует своевременное удаление объектов, что позволяет автоматически управлять через RAII ресурсами отличными от оперативной памяти: автоматически закрывать файлы, сокеты, хэндлы,
гарантирует отсутствие повреждений в логической структуре объектной модели,
разгружает программиста от рутинной ручной реализации этих операций.
Промежуточные итоги
Язык Аргентум построен на новой, но уже хорошо знакомой по UML, ссылочной модели, котрая свободна от ограничений и недостатков систем со сборкой мусора, подсчетом ссылок и ручным управлением памятью. На сегодняшний день в языке уже есть: параметризованные классы и интерфейсы; многопоточность; управляющие конструкции, основанные на optional-типах данных; быстрые приведения типов и очень быстрые вызовы методов интерфейсов; модульность и FFI. Он обеспечивает безопастность памяти, типов, отсутствие утечек памяти, гонок и блокировок. Он использует LLVM для генерации кода и строит отдельно стоящие исполняемые приложения.
Аргентум — экспериментальный язык. В нем многое предстоит сделать: многочисленные оптимизации кодогенерации, багфиксы, тестовое покрытие, размотку стека, более качественную поддержку отладчика, синтаксический сахар и т.д, но он быстро развивается и перечисленные доработки — дело ближайшего будущего.
Домашняя страница проекта Аргентум с демкой и туториалами: aglang.org.
В следующей статье будет освещена семантика операций над UML-указателями и их реализация в Aгентуме.