Практический пример, сравнение с "конкурентами", формальное определение операций, особенности многопоточности, внутреннее устройство
Язык находится в состоянии глубоко экспериментальной разработки, поэтому многие частные случаи еще не учтены и не сделаны почти никакие оптимизации. Языку не хватает синтаксического сахара и хорошего отладчика. Работа продолжается, конструктивная критика приветствуется.
Тестовый пример
Чтобы не впадать в излишнее теоретизирование, разберем ссылочную модель языка на примере модели данных практически реального десктоп-приложения. Это будет редактор набора карточек, на которых будут присутствовать блоки текста, картинки, линии-коннекторы, связывающие эти блоки между собой и кнопки, по нажатию которых можно будет переходить между карточками.
Как мы видим, здесь присутствуют композитные, ассоциативные и агрегатные связи:
карточки принадлежат документу,
элементы принадлежат карточкам,
битмап принадлежит графическому блоку,
коннекторы связываются с произвольными блоками через анкерные точки,
карточки связываются с другими карточками через объекты Button,
текстовые блоки совместно используют стили.
Иерархия объектов построена из трех видов связей:
композиция
ассоциация
агрегация
Разберем их по порядку.
Композиция: "A владеет B"
Все современные приложения построены на структурах данных с древовидным владением:
HTML/XML/Json DOM,
структуры реляционных и документно-ориентированных баз данных,
промежуточное представление кода в компиляторах,
сцены в 3D приложениях,
формы пользовательского интерфейса.
Все это то, что в UML называется композицией - владением с единственным владельцем.
Не является исключением и наш пример. Документ владеет карточками, карточки владеют элементами, которые владеют анкерными точками. Это все - композиция.
Инварианты композиции:
У объекта может быть ровно один владелец.
Объект не может быть прямым или косвенным владельцем самого себя.
Объект жив пока жив его владелец и владелец ссылается на него.
Было бы очень полезно если бы современные языки программирования позволяли явно декларировать такое отношение владения и проверяли инварианты композиции на этапе компиляции.
Композиция в С++/Rust/Swift
Почему не в Java/JS/Kotlin/Scala? В системах со сборщиком мусора все ссылки являются ссылками со множественным владением. То есть агрегатными, а не композитами. Поэтому поищем встроенную поддержку композиции в языках без GC.
Например, попробуем спроектировать структуру данных выше описанного приложения на C++.
Мы не можем включить объекты прямо в структуру по месту использования по трем причинам:
Во-первых, полиморфизм. На карточке могут присутствовать блоки разных типов.
Во-вторых ассоциативные не владеющие ссылки. Они присутствуют в модели нашего приложения. Они связывают коннекторы с анкерами, и кнопки с карточками. Если представлять их в программе в виде weak_ptr, а это естественное представление ассоциаций в C++, то объекты на которые они ссылаются должны быть самостоятельными объектами а не полями в других объектах. Умные указатели в C++ вообще не любят указывать внутрь других объектов.
Во третьих, есть неиллюзорная опасность нарваться на русскую рулетку с временем жизни объектов: В любой момент, когда мы удаляем любой CardItem-объект, в стеке текущего потока в кадре какой-нибудь функции (в локале параметре или временном значении) вполне может оказаться ссылка на этот объект, и тогда после возврата в эту функцию мы рискуем обратиться по указателю, ссылающемуся куда-то в середину уже удалённой структуры данных.
На следующей диаграмме показана эта ситуация в упрощенном виде:
У нас есть Card1, в котором Лежит TextBox1.
Где-то в нашей программе есть ссылка на текущий объект-в-фокусе.
Юзер нажимает кнопку del. И она отправляется сфокуссированному объекту.
Хендлер в текущем сфокусированном объекте просит сцену удалить все select-нутые объекты.
Среди них оказывается наш TextBox1.
Имеем русскую рулетку в виде нескольких мусорных указателей в стеке.
Три вышеописанных соображения - полиморфизм, несовместимость с weak_ptr, русская рулетка - не позволяют нам реализовать композицию включением объектов непосредственно в другие объекты. Нам нужен композитный указатель.
C++ предоставляет unique_ptr
для случая уникального монопольного владения. Этот указатель решает проблему полиморфизма но не решает ни проблему русской рулетки, ни проблему weak_ptr
, о которых говорилось выше. Объект, лежащий в unique_ptr
не может быть таргетом weak_ptr
. И этому есть логическое объяснение: weak_ptr
может потерять ссылку в любой момент. Поэтому при его разыменовании нужно защитить объект от удаления. В C++ операция разыменования weak_ptr::lock()
порождает shared_ptr
. А он не совместим с unique_ptr
по понятным причинам: первый удаляет объект по обнулению счетчика ссылок, тогда как второй - немедленно. Таким образом, ни weak_ptr
ни shared_ptr
не может использоваться на одном объекте совместно с unique_ptr
.
Значит нам остается только хранить наши уникально-владеющие композитные ссылки в виде shared_ptr, который вообще-то реализует агрегацию, а не композицию, что заставляет нас признать, что композиция как понятие в стандартном C++ не поддержана.
Интересно что при переходе к Rust ситуация не меняется. Замените unique_ptr
на Box
, а shared_ptr
на Rc
. И мы получите точно такое же поведение.
Aналогично обстоят дела в Swift, ARC-strong полный аналог shared_ptr
, встроенный в язык weak
- это weak_ptr
.
Перечисленые языки используют shared-ссылки, вообще-то предназначенные для агрегации с множественным владением, не только для нее, но и для композиции, только потому, что им нужны счетчики ссылок для учета временных ссылок из стека, которые спасают нас от русской рулетки. Архитектурные решения были сделаны, основываясь на схожести технической реализации защиты от преждевременного удаления объекта и шареного владения. И в результате мы потеряли встроенную в язык защиту от множественного владения и закольцовок.
Композиция в Аргентуме
Вначале пара слов о синтаксисе:
a = expression; // это определение новой переменной.
a := expression; // это модификация существующей переменной - присваивание.
ClassName // это создание экземпляра.
// Декларации типов требуются только в параметрах и результатах
// функций, делегатов и методов (но не лямбд).
// Во всех остальных выражениях типы выводятся автоматически.
fn myCoolFunction(param int) int { param * 5 }
// Тип переменной и тип поля класса задается инициализатором.
x = 42; // Int64
Для предотвращения "русской рулетки" Аргентум вводит два отдельных типа ссылок:
Композитная ссылка. Живет в полях объектов и результатах функций. Тип декларируется как
@T
.Временная стековая ссылка. Живет только в стеке. Декларируется как
T
.
На объект может ссылаться сколько угодно временных стековых ссылок, плюс не более одной композитной.
// Примеры:
// Локальная переменная `a` ссылается на новый объект класса Image
a = Image;
// Локальная переменная `b` ссылается на тот же объект, что и `a`.
b = a;
// Локальная переменная `с` ссылается на копию объекта `a`
c = @a;
// То, ради чего все затевалось: нельзя присваивать композитной ссылке
// объект, хранящийся в стековой ссылке. Т.к. это может нарушить правила
// единственного владельца.
// Откуда тут стековая ссылка? Любое чтение композитной ссылки возвращает
// стековую ссылку.
a.resource := c.resource; // Ошибка компиляции
// Присваивание любой ссылке отвязывает ее от старого объекта и привязывает
// к новому. При отвязывании старый объект может быть удален, если это была
// последняя ссылка.
a.resource := Bitmap;
Описанные в начале статьи инварианты композиции обеспечиваются в аргентуме автоматически:
У объекта может быть ровно один владелец. Владелец ссылается на объект композитной ссылкой. А ей можно присваивать только копии других объектов и новые созданные объекты. Поэтому не бывает двух композитных ссылок на один и тот же объект.
Объект жив, пока жив его владелец, и владелец ссылается на него. Плюс защита от "русской рулетки": объект жив, пока на него ссылается композитная ссылка или хотя бы одна стековая ссылка. Это обеспечивается счетчиком ссылок в объекте. Поскольку ссылки встроены в язык, компилятор может провести escape-анализ и убирать ненужные retain|release-операции со счетчиками. И еще, поскольку композитные ссылки работают с mutable-объектами, а mutable-объекты в Аргентуме всегда принадлежат какому-то одному потоку, этот счетчик не атомарен. Это также гарантирует, что разрушение объекта и освобождение его ресурсов произойдет в предсказуемый момент времени на правильном потоке. Часто это бывает важно.
Объект не может быть прямым или косвенным владельцем самого себя. При создании объекта компилятор уже видит в какое композитное поле какого объекта будет присвоен создаваемый объект. Это даёт гарантию того что владелец объекта всегда создаётся раньше объекта, и значит, объект никогда не будет владеть сам собой.
Краткий итог раздела: Аргентум имеет встроенную поддержку композиции. Он проверяет безопасность всех операций над композитами на этапе компиляции и оптимизирует обращения к счетчикам, которые в случае композиции никогда не являются атомарными. Синтаксис операций с композитами краток и интуитивен.
Ассоциации (weak pointers): "A знает о B"
Ассоциация aka связь без владения. В нашем примере в каждой кнопке есть поле target, которое ссылается на какую-то карточку. В коннекторе есть поля start
и end
, которые ссылаются на какие-то анкерные точки. Это всё примеры ассоциаций. Ассоциации очень распространены в моделях приложений: ссылки между UI-формами, foreign keys в базах данных, перекрестные ссылки между контроллерами, моделью данных и представлениями в MVC, делегаты и поддписчики на события, везде где в файловых форматах появляются атрибуты id
и ref
- это все ассоциации.
Ассоциации могут формировать произвольные графы, в том числе содержащие циклы.
Объекты, находящиеся по обе стороны ассоциации должны иметь владельцев в своих иерархиях.
Как и в случае композиции, ассоциативная связь имеет инварианты:
И ссылающийся объект и таргет-объект могут иметь независимые друг от друга времена жизни.
Связь разрывается при удалении любого из двух объектов.
Ассоциациативные ссылки в С++/Rust/Swift
Все перечисленные языки поддерживают ассоциации с помощью разных форм weak_ptr. Как уже обсуждалось в главе про композицию, все они требуют, чтобы таргет был шареным объектом, что ограничивает компилятор в возможностях проверки целостности структур данных.
Кстати, все перечисленные языки позволяют обратиться по ассоциативной ссылке без проверки на потерянность объекта.
Как ассоциация представлена в Аргентуме
Ассоциативные ссылки играют большую роль во встроенных операциях копирования иерархий объектов (ей посвящен отдельный раздел) и в многопоточных операциях (о которых будет отдельный пост), в прочих сценариях ассоциативные ссылки Аргентума почти идентичны weak-ссылкам C++/Rust/Swift двумя отличиями:
таргетом может быть любой объект, даже хранящийся в композитной ссылке,
обращение по ссылке невозможно без проверки на наличие таргета.
// В Аргентуме ассоциативная ссылка на класс T декларируется как &T.
// Функция принимающая ассоциативную ссылку на CardItem
// и возвращающая такую же ассоциативную ссылку на CardItem:
fn myCoolFunction(parameter &CardItem) &CardItem { parameter }
// В Аргентуме есть &-оператор, который создает &-ссылку на объект
a = TextBlock; // a - Владеющая ссылка
w = &a; // `w` - это &-ссылка на `a`
// `x` не привязанная ни к чему ссылка:
x = &TextBlock;
// Теперь `x` и `w` ссылаются на один и тот же объект:
x := w;
// &-ссылка может присутствовать в полях, переменных,
// параметрах, результатах и временных значениях.
class C {
// `field` поле-ссылка на `C`, изначально не привязанная ни к какому объекту
field = &C;
}
&-ссылка может потерять свой таргет в любой момент в результате любой операции которая удаляет объекты. Поэтому перед использованием &-ссылку надо заблокировать и проверить на наличие таргета. Эта операция порождает стековую ссылку. Но поскольку всегда существует вероятность, что таргет был потерян или ссылка изначально не указывала ни на какой объект, а стековые ссылки не-nullable, то результат такой конверсии - это всегда optional<стековая_ссылка>.
Краткое отступление про optional тип данных в Аргентуме:
Для любого типа (не только ссылок) может существовать optional-обертка
Для типа
T
, optional тип будет?T
. Например?int
,?@Card
,?Connector
, и т.д.Переменная типа
?T
может содержать или "ничего" или значение типаT
.-
Бинарная операция
A ? B
работает с optionals. Она требует, чтобы операнд A имел тип?T
, а операндB
был преобразованиемT->X
. Результат операции -?X
. Бинарная операцияA ? B
работает как операторif
:Вычисляется операнд
A
,Если в нем "ничего", то результатом всей операции будет "ничего" типа
?X
Если в нем значение
T
, то оно вытаскивается из optional, связывается с именем_
и выполняется операндB
, чей результат упаковывается в?X
Приведенная информация минимально-достаточна для иллюстрации работы &-ссылок. Остальное описание типа optional будет в следующем посте.
В Аргентуме &T
-ссылка автоматически конвертируется в ?T
(в optional стековую ссылку) всякий раз когда ожидается значение типа optional. Поэтому операция "?
" примененная к &-ссылке автоматически выполняет блокировку таргета ссылки от удаления, проверку результата на "не потерянность" и исполнение кода при успехе:
// Если переменная `weak` (которая является &-ссылкой) не пустая,
// присвоить в поле объекта, на который она ссылается строку "Hello".
weak ? _.text := "Hello";
Поскольку переменная "_
" существует все время исполнения правого операнда, результат разыменования &-ссылки будет защищен от удаления в течение всего времени его работы:
weak ? {
_.myMethod();
handleMyData(_);
log(_.text)
};
Поскольку результат проверки виден только внутри правого операнда операции "?
", в Аргентуме отсутствует синтаксическая возможность обратиться без проверки:
к внутреннему содержимому optional,
к null-ссылке,
к потенциально потерянной &-ссылке
а также к результату приведения типов, индексу массива, ключу мапа.
Все это делает Аргентум немного занудным, но экстремально безопасным языком.
Внутри &-ссылка реализована как указатель на динамически аллоцированную структуру из четырех полей, которая хранит указатель на таргет, идентификатор потока таргета, счетчики и флаги. Один из флагов сигнализирует о том что ссылка ещё ни разу не передавалась через границы потоков. Такие внутрипоточные ссылки обрабатываются более простым кодом, и не нуждаются в межпоточной синхронизации.
Краткий итог раздела:
Аргентум имеет встроенную поддержку ассоциативных ссылок.
В отличие от C++/Rust/Swift, таргеты &-ссылок могут оставаться композитами, т.к. защита объекта после разыменования выполняется стековой ссылкой, а не shared_ptr/arc/Rc.
Разыменование &-ссылки совмещено с проверкой на наличие таргета, поэтому разыменование без проверки невозможно чисто синтаксически, что делает эту операцию безопасной.
Если ссылка на объект не покидает границы потока, она не использует примитивы синхронизации.
Операции над &-ссылками имеют легковесный и очевидный синтаксис.
Агрегация: один объект, много владельцев
В нашем примере множество TextBox-объектов может ссылаться на один и тот же Style-объект. И каждый Style-объект жив, пока на него кто-то ссылается. В реальных приложениях на удивление мало сценариев, когда объекты могут безопасно шариться подобным образом. Коллективная мудрость сообщества программистов давно объявила шаринг изменяемых объектов анти-паттерном. Все примеры безопасного совместного владения сводятся к максиме "shared XOR mutable".
Например, можно безопасно ссылаться на один и тот же String-объект в Java. Или использовать одни и те же текстурные ресурсы в разных объектах 3D-сцены. Поскольку эти объекты не изменяемые.
Инварианты агрегации:
Все ссылки на шареный объект одинаковые и равноправные.
Шареный объект жив, пока на него ссылается хотя бы одна агрегатная ссылка.
Если дополнить этот список инвариантов правилом неизменяемости таргет-объекта, то появится одна важная гарантия: объект не может прямо или косвенно ссылаться на себя. Т.к. получить агрегатную ссылку можно только на неизменяемый объект, а присвоить ссылку не изменяемому объекту невозможно.
Как существующие мейнстрим-языки поддерживают агрегацию
Все современные языки полностью поддерживают агрегацию в неограниченном виде, когда шареный объект может быть изменяемым.
Это вызывает и трудно отлавливаемые проблемы в бизнес-логике приложения, и утечки памяти и состояние гонок в многопоточных средах. Исключением является Rust, который заставляет шареный объект быть неизменяемым, хоть и позволяет взламывать эту неизменяемость использованием Cell-обертки, что, учитывая его позицирование как языка системного уровня, полностью оправдано.
Как агрегация реализована в Aргентуме
В Аргентуме агрегатная ссылка может ссылаться только на неизменяемый объект. Более того, неизменяемость и шареность - это одно и то же понятие.
Агрегатная ссылка на класс T
декларируется как *T
(Читается frozen T).
В аргентуме есть оператор заморозки, который принимает стековую ссылку T
на изменяемый объект и возвращает агрегатную шареную ссылку *T
на неизменяемый объект. Оператор заморозки имеет синтаксис: *expression
. Если есть возможность, объект замораживается на месте, но если на объект есть какие-то ссылки снаружи, то делается и замораживается копия объекта.
У замороженного объекта нельзя изменять значения полей и нельзя вызывать изменяющие методы. Методы в Аргентуме делятся на:
Изменяющие, которые можно вызвать только у изменяемых объектов,
Не изменяющие, которые можно вызывать у любых объектов,
Шареные - которые можно вызвать только у замороженных объектов, в которых
this
гарантированно является агрегатной ссылкой.
Пример неизменяемости:
a = Style;
a.size := 14;
f = *a; // `f` имеет тип *Style и ссылается на замороженную копию `a`.
x = f.size; // Можно обращаться к полям замороженных объектов.
f.size := 16; // Ошибка компиляции. Попытка изменить замороженный объект.
Пример шаринга:
s = *Style.setSize(18); // Тут мы создаем Style-объект, заполняем его и замораживаем
t = TextBox.setStyle(s); // Создаем TextBox и добавляем в него ссылку на `s`.
// Это можно было сделать проще:
t = TextBox
.setStyle(*Style.setSize(18));
// Второй textBox будет ссылаться на тот же Style.
t1 = TextBox.setStyle(t.style);
Замороженный объект не может быть разморожен, но его можно скопировать уже знакомым оператором "@
" и эта копия будет изменяемой.
s = @t1.style; // `s` - изменяемая копия существующего стиля
s.size := 24; // изменяем size
t1.style := *s; // замораживаем и сохраняем в t1.
Внутренняя реализация *T
-ссылок в Аргентуме использует счетчик с дополнительным флагом многопоточности. Этот флаг позволяет шареным объектам живущим в одном потоке обходиться без атомарных операций и примитивов синхронизации.
Полный запрет на изменение шареных объектов в Аргентуме имеет три важных следствия:
Отсутствие закольцовок гарантировано, поэтому для управления временем жизни агрегатов достаточно простого ARC. GC не нужен.
Любой поток, имеющий агрегатную ссылку имеет 100% гарантию, что все объекты, доступные по этой ссылке и всем исходящим ссылкам продолжают быть доступными этому потоку независимо от действий других потоков. Это позволяет существенно сократить необходимость в retain/release операциях над счетчиками.
Все операции над счетчиком ссылок из любого потока не обязаны синхронизироваться с поведением других потоков. Эти операции могут задерживаться и даже переупорядочиваться, до тех пор, пока любой retain выполняется раньше любого release. Это позволяет группировать операции со счетчиками существенно удешевляя их.
Все перечисленное значительно удешевляет многопоточный учет ссылок на шареные объекты.
Неизменяемость шареных объектов - это требование best practices, направленное на улучшение надежности бизнес логики приложений, исключение гонок и ликвидации неопределенного поведения. А получившееся в результате кардинальное ускорение работы со счетчиками и исключение GC - это приятный бонус, а не самоцель.
Краткий итог раздела: Аргентум следует максиме "shared XOR mutable". И хотя каждый программист может сказать: "А я нарушал это неписанное правило несколько раз и до сих пор жив",- на больших масштабах приложений и длинных дистанциях эксплуатации и поддержки приложений следование этой максиме является выгодной стратегией.
Автоматическое копирование иерархий объектов
Оператор глубокого копирования объектов строго говоря не имеет отношения к ссылочной модели Аргентума. Можно было бы вместо этого:
запретить присваивание композитной ссылке чего либо кроме нового инстанса объекта,
или заставить программистов реализовывать трейт
Clone
вручную,или скопировать поддерево, но ассоциации и агрегации скопировать по-простому - в виде значений указателей, как в Rust-e.
Без полноценной автоматической операций копирования объектная система Аргентума все равно бы была полной, безопасной и гарантированно защищенной от утечек памяти. Но не удобной. Некоторые операции или стал бы невозможными или были бы сопряжены с большим количеством рукописного кода. Список операции, зависящих от глубокого копирования небольшой но очень важный:
преобразование стековой ссылки
T
в композитную@T
,заморозка объекта, если на объект еще кто-то ссылается, и он не может быть заморожен на месте,
разморозка объекта.
Операция копирования строится на следующих принципах (в порядке убывания важности):
Не должны нарушаться инварианты ссылочночной модели.
Должны поддерживаться non-null ссылки (то есть если в оригинальном объекте поле
не-null
, в копии оно не может становитьсяnull
).Результат копирования должен быть осмысленным.
Данные не должны теряться.
Копирование должно работать в многопоточном окружении.
Оверхед операции копирования по времени и памяти должен быть минимальным.
Первый и второй принципы требуют, чтобы при копировании корня дерева объектов было скопировано все поддерево по композитным ссылкам.
Копирование агрегатной ссылки может выполняться простым копированием значения ссылки, с тем, чтобы оригинал и копия совместно шарили общий неизменяемый под-объект. В конце концов это и является смыслом агрегации.
С ассоциативными ссылками есть два случая:
Случай 1. Если копируемая ссылка ссылается за пределы копируемой иерархии объектов, единственное значение, которое она может иметь - это значение оригинала. Поэтому копия такой ссылки будет ссылаться на оригинальный объект.
Рассмотрим пример: скопируем карточку, которая ссылается на другую карточку:
doc.cards[0] ? doc.cards.append(@_);
// Если документ содержит card[0], скопировать _его_ в конец списка cards
Легко заметить, что это ожидаемое поведение. Именно так бы работал код копирования, написанный программистом.
Случай 2. Если копируемая ссылка ссылается на объект, который участвует в той же операции копирования, значит эта ссылка относится к внутренней топологии объекта у нас есть выбор, ссылаться ей на оригинальный объект или на копию. Если она ссылается на оригинал, то мы теряем информацию о внутренней топологии, а мы договорились не терять информацию. Поэтому внутренние ссылки копируются так, чтобы сохранялась топология оригинала. Рассмотрим пример копирования объектов имеющих внутренние перекрестные ссылки:
doc.cards[1] ? doc.cards.append(@_);
Такое копирование ссылок это также ожидаемое поведение. Именно так бы работал написанный вручную код копирования.
Такая операция копирования универсальна. Точно такие же принципы применяются:
При обработке моделей документов, как в приведенном выше примере.
При преобразованиях AST в компиляторах, например если нужно сделать inline функции или инстанцирование шаблона, мы копируем поддерево этой функции с сохранением всех внутренних связей между узлами (например ссылок на локальные переменные и параметры).
В 3D движках этот паттерн используется для изготовления префабов, и операция копирования там должна выполняется точно по таким же правилам.
В паттерне проектирование "прототип". Например при разработке графического пользовательского пользовательского интерфейса мы можем создать элемент списка состоящий из иконок, текста, checkbox-а, хитрым образом связанных друг с другом ссылками и с прикрепленными хендлерами обеспечивающими нужное поведение, и потом копировать и вставлять этот элемент в контролы списков для каждого элемента модели данных. Именно такой алгоритм копирования обеспечит ожидаемые внутренние связи и ожидаемое поведение.
Сложно найти сценарий, в котором такая операция копирования окажется не применимой.
Есть еще один довод за автоматизацию операции копирования. Если язык требует ручной реализации этой операции, например через реализацию трейта Clone, то это приведет к тому, что пользовательский код будет выполняться в середине большой операции копирования, затрагивающей несколько объектов. И этот код будет видеть объекты в недостроенном, не валидном состоянии. В Аргентуме эта операция строится автоматически, она всегда правильна, эффективна и безопасна.
Если объекты управляют какими-то системными ресурсами, для них можно определить специальные функции afterCopy
и dispose
, которые будут вызываться при копировании и удалении объектов. Они вызываются в тот момент, когда иерархии объектов находятся уже/еще в валидном состоянии. Они могут использоваться для закрытия файлов, освобождения ресурсов, копирования хендлов, сброса кешей и прочих действий по управлению системными ресурсами.
Краткий итог раздела: Автоматизированная операция копирования - как автоматическая трансмиссия в машине. Ее можно ругать, но жизнь она упрощает изрядно.
Влияние многопоточности на ссылочную модель
Потоковая модель Аргентума - большая тема требующая отдельной статьи. Здесь описывается только часть имеющая отношение к ссылочной модели.
Потоковая модель Аргентума напоминает модель web-workers, в которой потоки - это такие легковесные процессы, которые живут в общем адресном пространстве и имеют общий доступ ко всем неизменяемым объектам приложения по агрегатным ссылкам (если у них есть ссылка). Кроме того, у каждого потока есть собственная иерархия изменяемых объектов. Примеры потоков с собственным состоянием: поток графической сцены, поток http клиента, поток документной модели. Конечно могут существовать и простые потоки-воркеры без состояния, которые просто молотят задачи, оперируя объектами лежащими в парметрах задач.
Каждый поток имеет входящую очередь асинхронных задач. Задача - состоит из:
ассоциативной ссылки &T на объект-получатель задачи, это один из объектов, живущих в этом протоке,
функции, которую надо исполнить,
и списка параметров, которые надо передать этой функции.
Таким образом, задачи исполняются в потоках, но посылаются объектам, хранящимся в этих потоках. Чтобы послать задачу, коду не нужно знать ничего про потоки и очереди, ему нужно иметь ассоциативную ссылку на объект-получатель.
Роль и поведение ссылок в многопоточном окружении
Стековые ссылки
T
и композитные ссылки@T
- это локальные внутрипоточные ссылки, не видимые за пределами своего потока. Они всегда показывают на объект своего потока. Они могут быть переданы в качестве параметров задач в другой поток. При этом они передают и переподчиняют свой таргет-объект другому потоку. Реальная посылка задачи в очередь происходит, когда в текущем потоке не остается ссылок на объекты этой задачи. Это гарантия того, что изменяемый объект не будет одновременно доступен двум потокам.Агрегатные ссылки
*T
могут свободно шарится между потоками, объект по ссылке всегда доступен любому количеству любых потоков.Ассоциативные ссылки
&T
позволяют хранить связи между объектами разных потоков. При попытке синхронного обращения к объекту другого потока, &-ссылка возвращает null. Но зато &-ссылки позволяют асинхронно посылать задачи своим таргет-объектам независимо от того, в каком потоке эти объекты находятся. Таким образом ассоциативная ссылка в многопоточном окружении играет роль универсального локатора объекта.
Краткий итог: Все правила поведения ссылок одинаковы в однопоточном и многопоточном случае. Все инварианты продолжают выполняться. Операции копирования, заморозки, разморозки не нуждаются в межпоточной синхронизации.
Заключение
Ссылочная модель Аргентума позволяет ему избегать data races, утечек памяти, обеспечивает memory safety, null safety на уровне синтаксиса. Встроенные типы ссылок позволяют строить модели данных и иерархии объектов, которые сами поддерживают свою структуру, проверяя инварианты владения на этапе компиляции. Синтаксис операций над ссылками краток и мнемоничен.
В следующей части будет описан control flow, построенный на базе optional-типа данных. Он немного необычен но очень практичен.
Комментарии (42)
ultrinfaern
01.08.2023 15:11+2У вас все строится на остовании того, что в доменной модели есть абсолютный корень. Но это же не так. Если у системы есть несколько бизнес процессов, то для каждого можно попытаться выделить свой локальный корень и то не факт что один.
kotan-11 Автор
01.08.2023 15:11-1Корень модели данных приложения - это объект какого-то класса. Все локальные корни подсистем и бизнес-процессов могут быть полями в это классе. Ну не в глобальные же переменные их складывать в XXI веке.
ultrinfaern
01.08.2023 15:11+1Обычная бухгалтерская программа:
кадровик - корень сотрудник
склад - корни товар и оветственное лицо
зарплата - корень табельный номер
и все они ссылаются друг на друга да еще и есть и организация и департаменты и счета
никакого общего класса тут не может быть
PS: Ваше предложение создать один класс для корня называется "god object" и является антипаттерном.
kotan-11 Автор
01.08.2023 15:11Кажется мы друг друга не поняли. Один класс корня называется "Application" и в нем есть под-объекты: система логгирования, модель данных, UI, стейт-менеджер, messaging. Где-то в модели данных есть подобъекты: локальный кеш, persistent storage, политики репликации и вытеснения. Где-то в локальном кеше будут когда надо появлятся и пропадать сотрудники, товары и прочие мелкие части модели данных. А "god object" - это совсем про другое.
kotan-11 Автор
01.08.2023 15:11Если предположить, что вы имеете в виду не корневой объект в модели данных приложения, о которой идет речь в статье, а корень иерархии наследования классов, то правило единого корня дейтствует во всех managed-системах - в C#, Java, JS, Python и многих других. Все объекты независимо от их полезной нагрузки имеют общее поведение, используемое в управляемой среде исполнения, и это поведение заключено в корневом классе.
Panzerschrek
01.08.2023 15:11+1Я правильно понимаю, что при ассоциации ассоциативная ссылка - это внутри ссылка на поле класса + ссылка на счётчик ссылок класса (для отслеживания времени жизни)?
Можно поподробнее рассказать про счётчики внутри объектов? Компилятор для всех композитов их генерирует? Или дело обстоит несколько хитрее?
Отсутствие изменяемости при разделяемости - это минус языка. Иногда такая возможность необходима. А чтобы не было гонок, нужно использовать какие-либо примитивы синхронизации. Как Mutex в Rust, например.
В целом у меня вызывает некий скепсис подход с реализацией различных моделей владения/ссылок на уровне языка. По моему опыту, различных подходов может быть весьма много и всё в язык тащить будет невозможно. Посему лучше реализовывать те же подходы на уровне библиотеки.
В Ü, например, я реализовал несколько видов библиотечных контейнеров:
shared_ptr_final - разделяемый указатель на неизменяемые данные. Внутри есть счётчик ссылок.
shared_ptr - разделяемый указатель на изменяемые данные. Внутри есть счётчик ссылок и счётчик доступа. Доступ осуществляется через lock объект. Программа убивается, если обнаруживается, что на объект создано более одной ссылки на изменение, или ссылка на изменение при наличии ссылок на чтение.
shared_ptr_mt_final - аналог shared_ptr_final для многопоточного использования. Счётчик атомарен.
shared_ptr_mt - аналог shared_ptr для многопоточного использования. Внутри вместо lock объекта примитив синхронизации rwlock. Вместо аварийного завершения программы при нарушении правил доступа тут происходит блокировка, возможно даже мёртвая.С многопоточностью Ü обходится достаточно хитро. Типы, которые под капотом что-то там потоконебезопасно меняют (тт же shared_ptr), помечаются специальным тегом. При агрегации этот тег наследуются. И при попытке передать объект типа с наличием этого тега в другой поток компилятор породит ошибку.
rsashka
01.08.2023 15:11Я тоже пришел к очень похожим выводам, что модель распределения памяти получается не полная и как минимум не учитывает необходимость синхронизации доступа к объектам из разных потоков + привязка к деталям реализации аргентума.
Хотя и с вами не совсем согласен, в частности насчет реализации модели владения на уровне библиотеки.
Поддержка данной функциональности на уровне языка позволяет диагностировать ошибки уже во время компиляции приложения, что затруднительно сделать в случае использования библиотечных функций.Поэтому не смотря на некоторый скепсис к конкретной реализации, сама по себе идея реализовать принципы владения объектами на уровне синтаксиса языка для исключения накладных расходов из-за GC очень заманчивая.
Panzerschrek
01.08.2023 15:11Не понял - а почему реализация на уровне библиотеки не позволяет обнаруживать ошибки во время компиляции?
rsashka
01.08.2023 15:11не "не позволяет", а "затрудняет".
И вы сами писали:
Программа убивается, если обнаруживается, что на объект создано более одной ссылки на изменение, или ссылка на изменение при наличии ссылок на чтение.
Вместо аварийного завершения программы при нарушении правил доступа тут происходит блокировка, возможно даже мёртвая.Что означает, что часть проверок происходит во время выполнения, а не при компиляции.
Panzerschrek
01.08.2023 15:11Теперь понял. Да, shared_ptr-типы имеют внутри себя проверки времени выполнения. Примерно так же, как и какой-нибудь RecCell в Rust. И просто взять и сделать эти проверки статическими невозможно по своей сути.
Но shared_ptr - штука весьма нишевая. В моём языке Ü кроме этого есть и статические проверки. Почитать о том, как это работает, можно в документации.rsashka
01.08.2023 15:11И просто взять и сделать эти проверки статическими невозможно по своей сути.
Как раз это и имелось в виду, что реализация модели управления памятью и доступом на уровне синтаксиса языка позволит (в теории) выполнять данные проверки не во время выполнения, а во время компиляции или по крайней мере, большую часть из них.
Panzerschrek
01.08.2023 15:11Сейчас уже и так большая часть проверок (что по ссылке выше) происходят во время компиляции. А что касается shared_ptr-подобных классов, они по самой своей сути используются там, где статически проверить что-то не возможно.
Взгляните хоть на пример Rust. Уж там то было бы всё статично, если это было бы теоретически возможно. Но нет, возможно это не всегда, потому то и существует RefCell.rsashka
01.08.2023 15:11они по самой своей сути используются там, где статически проверить что-то не возможно
Это из-за модели управления памятью. В С++ она отсутствует (точнее используется ручное управление), а в Rust реализовано в виде библиотечных вызовов. Поэтому ни тот, ни другой язык не показатель.
А вот Аргентум попробовал реализовать некую модель управления памятью на уровне синтаксиса. И хоть у него тоже не все гладко, но видно, что в этой идее есть потенциал.Panzerschrek
01.08.2023 15:11-1ручное управление
Заблуждение, которое может исходить только от того, кто на C++ толком не писал. Нету там ничего ручного - всё разруливается конструкторами/деструкторами библиотечных контейнеров.
А вот Аргентум попробовал реализовать некую модель управления памятью на уровне синтаксиса
Ну пока что рано говорить, реализовал ли, ибо язык находится только в начали пути развития. И пока что видны существенные проблемы, вроде негибкости и чрезмерных затрат памяти и лишних инструкций во времени исполнения.
rsashka
01.08.2023 15:11Что-то вы скатываетесь в какой-то деструктив. На С/С++ я пишу уже третий десяток лет, так что ваше предположение мимо.
Конструкторы/деструкторы, в какой то мере автоматизирует управление памятью, но библиотечные контейнеры и шаблоны, это и есть ручное управление,только в красивой обертке.
Что же касается Аргентума, пусть он и на начальном пути развития, да с корявой, и не полной моделью управления память, которая имеет существенные проблемы, но но её уже реализовал.
kotan-11 Автор
01.08.2023 15:11Внутреннее устройство объектов и счетчиков будет постоянно изменяться.
Сейчас в каждом объекте есть три служебных поля - ссылка на класс, счетчик стековых ссылок +1, если есть композитная ссылка или для неизменяемых объектов - счетчик агрератных ссылок и признак многопоточности, указатель на партент-объект (он поддерживается автоматически и доступен приложениям) или указатель на weak-block.
Для объектов, на которые есть ассоциативные weak-ссылки, дополнительно аллоцирован weak-block, в нем есть указатель на таргет, счетчик &-ссылок и флаг многопоточности, идентификатор треда таргета, эвакуированный из таргета указатель на партент-объект.
Panzerschrek
01.08.2023 15:11Это что выходит, у каждого агрегата ещё в нагрузку идут служебные поля? А не жирновато ли?
kotan-11 Автор
01.08.2023 15:11Смотря с чем сравнивать. Например в C++ у каждого объекта есть vmt_ptrs на каждую независимую базу + offsets на каждую виртуальную базу. А std::shared_ptr - это два машинных слова + каждый объект, на который он указывает должен иметь отдельную динамически аллоцированную структуру со счетчиками. Так что все относительно.
Panzerschrek
01.08.2023 15:11Указатели на таблицу виртуальных функций существуют только у полиморфных классов (как минимум с одним виртуальным методом). Большинство классов не такие.
shared_ptr же тоже используется не часто, посему затраты на счётчики ссылок внутри него терпимы. В Argentum же, насколько я понимаю, у всех объектов есть подобные накладные расходы, как в какой-нибудь (прости Господи) Java.
alextretyak
01.08.2023 15:11Вы что-нибудь слышали про язык программирования Vale?
Разработчик этого языка также обещает «memory safety without a borrow checker, generations, reference counting, or tracing GC», и утверждает, что его подход возможно реализовать в C++.
Какие преимущества у вашего языка по сравнению с подходом Vale?kotan-11 Автор
01.08.2023 15:11Язык val.
Исходники тут: https://github.com/val-lang/val
Сайт проекта тут: https://www.val-lang.dev/Ни так ти там я не нашел ничего, что позволило бы понять, как будут организованы структуры данных в хипе. Все про стек. Вся работа основывается на Swift standard library, значит будет в хипе будет все как в Swift.
alextretyak
01.08.2023 15:11Язык val.
Это другой язык. :)(:Сайт проекта тут: www.val-lang.dev
Вот цитата непосредственно оттуда:Our goals overlap substantially with that of Rust and other commendable efforts, such as Zig or Vale.
Собственно в этой цитате есть ссылка на сайт языка Vale.
[А ещё есть Vala. Это три различных языка программирования! (Val, Vala и Vale)]kotan-11 Автор
01.08.2023 15:11Действительно, другой язык, спасибо. Давайте сравним. Для ассоциации vale использует generational references, которые просто убивают приложение, если встречается ссылка на удаленный объект. Плюс нет возможности защитить объект от удаления на время использования. Для композиции vale использует особый указатель с move-семантикой. Нет защиты от закольцованной композиции - владения самим собой. Агрегация не поддержана вовсе.
boldape
01.08.2023 15:11Как эксперт по плюсам хочу обозначить несколько неточностей с вашей стороны
С++ был спроектирован в эпоху однопоточности и с учётом этого важного контекста он обсалютно прекрасно поддерживает композицию. Дефолтный выбор для реализации композиции это родительский объект владеет дочерним по значению. Ассоциативная ссылка как и у вас это вик поинтер, агрегатная ссылка это забыл точное название из буста, смарт поинтер на объект со внутренним счётчиком ссылок. Как так, спросите вы, композит же это значение как создать вик поинтер на значение? Да очень просто рутовый объект должен реализовывать шаред фром зис, а все дочерние объекты используют его счётчик при создании ассоциативной ссылки. Это та редко используемая специализация конструктора стд шаред поинтер о которой мало кто знает и совсем мало кто понимает как это работает и зачем это нужно. Вот именно для этого оно и нужно.
У вас есть единственное разумное возражение - полиморфизм. Да в таком случае композит это шаред поинтер, а ассоциативная ссылка на него вик поинтер, но есть альтернатива известная ещё с античных(си) времён - полиморфизм на вариантах или полиморфизм в функциональном стиле. Это когда рут объект имеет по значению правильно выравненный достаточного (для хранения любого возможного полиморфного типа) размера массив байт + дескриминатор. Полиморфные объекты размещаются внутри этого массива через инплэйс нью.
Глубокое копирование при использовании значений для агрегатов идёт почти из коробки, да ассоциативные ссылки внутри одного дерева объектов нужно поправить руками в конструкторе/присваивании после копирования.
Что я хочу сказать, с++ позволяет сделать все по уму почти из коробки, да как это делать правильно и не ошибаться понимают мало людей, но ваше заявление что в плюсах это всегда русская рулетка основано на недостаточных знаниях о с++.
Да, многопоточность в плюсах это отдельная боль и все становится намного сложнее когда объекты используются в разных потоках, тут я соглашусь что без специальных смарт поинтеров могут возникать не иллюзорные проблемы удаления объектов не в тех потоках где надо.
rsashka
После прочтения первой статьи у меня появились некоторые вопросы к ссылочной модели языка.
Я уже задавал в предыдущей статье, но к сожалению так и не понял причину (необходимость) выделения константных ссылок в отдельный класс, так как в реализации они ни чем не отличаются "обычных не владеющих ссылок".
Может быть сейчас поясните, зачем нужно выделять ссылки на константные объекты в отдельный тип?
kotan-11 Автор
В Аргентуме нет константных объектов. В аргентуме есть неизменяемые объекты. У неизменяемого объекта есть ограниченный временем жизненный цикл - создали, заполнили данными, сделали неизменяемым, попользовались в разных иерархиях, удалили за ненадобностью. Для того, чтобы удалять ненужные неизменяемые объекты (точнее учитывать, что они все еще кому-то нужны) и существуют агрегатные
*T
ссылки.На уровне синтаксиса в Аргентуме есть константы, но это понятие ортогонально неизменяемым объектам.
rsashka
У меня наверно не получается правильно сформулировать вопрос, т.к. вы опять рассказываете, как у вас сделаны ссылки на неизменяемые объекты.
Я же спрашиваю, зачем выделять ссылки на неизменяемые объекты в отдельную категорию с отдельным синтаксисом, если они практически идентичны обычным не владеющим ссылкам?
kotan-11 Автор
Ответ на вопрос "зачем": "Для того, чтобы удалять ненужные неизменяемые объекты (точнее учитывать, что они все еще кому-то нужны) и существуют агрегатные
*T
ссылки. "*T
-ссылка означает: Вот этот объект я пока держу, пока не удайте его, он мне нужен. можете шарить этот объект с другими местами в программе, где он тоже нужен, его можно шарить, т.к. я обязуюсь его не изменять.&T
-ссылка означает: Вот этим объектом я буду пользоваться иногда, пока его не удалят те кто его держит.Приведите пример, в котором по вашему мнению вместо шарено-владеющей ссылки
*T
можно применить невладеющую ссылку&T
.rsashka
Если я вас правильно понял, то &Т ссылка НЕ увеличивает счетчик ссылок (является слабой), но при захвате может изменять объект, тогда как *Т ссылка увеличивает счетчик ссылок (т.е является сильной, но не может изменять объект).
И если это так, то для *Т ссылок вы постулируете отсутствие зацикленности вложенных ссылок только тем, что объект не изменяемый?
Я все правильно понял?
kotan-11 Автор
Все так.
rsashka
Спасибо, теперь со ссылками разобрался.
Но появился другой вопрос, Каким образом неизменяемость объекта обеспечивает отсутствие зацикленных ссылок?
Разве объект не может содержать ссылку на точно такой-же, но не изменяемый объект?
И если может, то что мешает в этом поле указать ссылку на самого себя? Ведь при определении объекта это же можно сделать?
kotan-11 Автор
При определении объекта этого объекта еще нет. В инициализаторе поля this не доступен. После создания объекта он изменяемый. Все ссылки на него - временные стековые
T
, которые нельзя присваиваеть его*T
полям. А если его заморозить оператором*
, который вернет на него*T
ссылку, ты получишь объект, который нельзя изменять. Поэтому объект не может ссылаться сам на себя ничем, кроме не-владеющей&T
ссылки.rsashka
Спасибо за разъяснение. Теперь я понял, что данная особенность ссылок опирается только на внутреннюю реализацию Аргентума.
nin-jin
А что происходит в таком сценарии?
один поток создаёт объект
передаёт слабую ссылку другому потоку
другой поток разыменова её и пошёл работать
в этот момент первый поток очистил память
Там где-то неявно запирания вставляются?
kotan-11 Автор
Ссылка на объект из другого потока (все равно откуда ее получили) разыменуется как null. Чтобы получить объект по этой ссылке нужно или послать ей асинхронный таск, или передать ее в тот поток, где находится ее таргет, и там разыменовать.
nin-jin
У меня есть ссылка на объект, как получить из него данные? Данные мне нужны в моём потоке, а не в том, который владеет объектом. И перехватывать владение мне не надо.
kotan-11 Автор
Если ты хочешь передать из потока в поток какие-то данные, просто передай этот объект, вместо ссылки на него. Это дешево, это не копирование.
Если у тебя есть какой-то объект, данные которого нужны одновременно множеству потоков, сделай этот объект незименяемым, и передай *-ссылку на него всем желающим.
Если этот объект изменяемый, каждый читатель состояния должен видеть непротиворечивое состяние этого объекта и всех его подобъектов. Нужна версионность. Пусть на каждое изменение порождает неизменяемый снапшот, ссылку на который этот объект выдает другим потокам по требованию.
Если важно, чтобы объект жил в одном потоке, а использовался из другого, ак микросервис, нужно продумать API запросов-тасков к этому объекту.
Если нужно "прям тут" наколхозить вытаскивание текущего значения поля из объекта другого потока, это можно сделать посылкой асинхронного таска, вытаскиващего одно поле, с асинхронным коллбэком.
nin-jin
Кто и когда освободит память этого неизменяемого объекта?
Ну и, кстати, делать снепшот большого словаря слишком дорого как и гонять туда-сюда таски для каждого поля.
kotan-11 Автор
Этим занимаются агрегатные *-ссылки.
Поэтому я предложил множество решений, какие-то подойдут для одних случаев, какие-то для других.
И вообще, многопоточность - для CPU-bounded задач.
Она используется для разбиения задачи на части и передачи их воркерам. В этом случае нужно однократно передать задачу, принять задачу и иногда обеспечить совместный доступ воркеров к общему неизменяемому состоянию.
Второе применение многопоточности - микросервисная модель. Когда логгеру, графической сцене, сетевой подсистеме и другим сервисам, у которых есть свое внутреннее состояние, передаются очереди запросов.
Оба сценария использования многопоточности поддержаны ссылочной моделью Аргентума нативно. Если в вашем приложении есть какой-то другой стиль использования потоков, буду признателен, если вы опишете его, и тогда мы сможем устроить мозговой штурм, чтобы выработать хорошее решение.
boldape
Конечно есть, модель пол. Сейчас уточню. У вас описана модель пуш, поток посылает асинхронный таск в mpsc очередь другого потока, тот таск исполняет и таким же образом посылает результат первому потоку в его очередь. Пол работает наоборот. У меня есть поток который генерирует таски и кладет их в СВОЮ spmc очередь, а другие потоки забирают таски на обработку из этой очереди, а результат кладут в свою очередь, первый(хотя чаще ещё какой то) поток периодически опрашивает очереди рабочих потоков на предмет результата.
Если кажется что разницы, нет то это только кажется. В модели пуш сбалансировать нагрузку потоков нетривиальная задача, а в модели пол это происходит автоматически.
kotan-11 Автор
Спасибо, очень верное замечание. У воркеров (потоков без состояния) должна быть общая очередь для балансирования нагрузки.
boldape
Общая очередь это деталь реализации тред пула, проблема у вас в языке, чтобы что то кому то послать нужно знать адресата, а в модели пол у нас нет адресата, поэтому она (согласно изложенному в статье) на данный момент не реализуема. Это архитектурное ограничение вашего языка.
Мне кажется что ваша модель памяти в общем и целом разумна, но многопоточность не вписывается сюда. Я бы вам предложил скрыть многопоточность также как и в модели памяти указатели и следовать бест практисам. Т.е. я говорю о поддержке корутин вместо потоков.
Конкретно в вашей модели памяти операция лок для ассоциативной ссылки должна возвращать не опшионал стэковой ссылки, а корутину которая возвращает стэковою ссылку.
авэйт на такой корутине спрячет пересылку таска в поток владелец, заморозку объекта и отправку его назад в поток который лочит ссылку, это то как вы это видите сейчас. С точки зрения плюсовика это конечно очень много накладных расходов, но в целом рабочий вариант. Но есть альтернатива которая прекрасно работает под той же абстракцией корутин, юзер мод средства синхронизации. На самом деле нет вселенской необходимости слать асинхронный таск обрабатывать его и так же слать ответ просто что бы получить доступ к данным, можно заложить объект сделать глубокую копию и разложить обьект, копию отдать тому кто лочит ассоциативную ссылку. Это намного эффективнее чем асинхронные таски, но требует корутин, т.к. лок ассоциативной ссылки может привести к юзер мод блокировке.
Мой поинт в том что ваша модель неполна без корутин и поэтому накладывает очень серьезные архитектурные ограничения.