Дальнейшее упрощение самоссылающихся типов для Rust
— 2024-07-08
Yoshua WuytsИсточник
- не все самоссылающиеся типы являются !Move
- время жизни 'self недостаточно
- автоматическая стабильность ссылок
- операции с сырыми указателями и автоматическая стабильность ссылок
- Relocate, вероятно, должен принимать &own self
- переработанный мотивирующий пример
- когда неперемещаемые типы всё ещё нужны
- заключение
В моём предыдущем посте я обсуждал, как мы могли бы ввести эргономичные самоссылающиеся типы (SRT) в Rust, в основном за счёт внедрения функций, которые, как мы знаем, всё равно хотим в той или иной форме. Перечисленные функции были:
- Какая-либо форма времён жизни
'unsafeи'self. - Безопасная нотация указателей на выход (out-pointer) для Rust (
super let/-> super Type). - Способ введения указателей на выход без нарушения обратной совместимости.
- Новый автотрейт
Move, который можно использовать для пометки типов как неперемещаемых (!Move). - Типы-представления (view types), которые делают возможной безопасную инициализацию самоссылающихся типов.
Этот пост был воспринят довольно хорошо, и я подумал, что последовавшее обсуждение было весьма интересным. Я узнал о нескольких вещах, которые, как мне кажется, помогли бы дополнительно улучшить дизайн, и подумал, что было бы хорошо записать это.
Не все самоссылающиеся типы являются !Move
Нико Мацакис (Niko Matsakis) отметил, что не все самоссылающиеся типы обязательно являются !Move. Например: если данные, на которые ссылаются, размещены в куче, то тип на самом деле не должен быть !Move. При написании парсеров протоколов на самом деле довольно часто сначала считывают данные в тип, размещённый в куче. Вероятно, что значительное количество самоссылающихся типов на самом деле не нуждаются в !Move или вообще в каком-либо понятии Move для функционирования. Что также означает, что нам не нужна какая-либо форма super let / -> super Type для конструирования типов на месте.
Если мы просто хотим включить самоссылки для типов, размещённых в куче, то всё, что нам для этого нужно, — это способ их инициализировать (типы-представления) и возможность описывать самовремена жизни (минимум 'unsafe). Это должно дать нам хорошее представление о том, что мы можем расставить по приоритетам, чтобы начать включать ограниченную форму самоссылок.
Время жизни 'self недостаточно
Говоря о временах жизни, Mattieum указал, что 'self, вероятно, будет недостаточно. 'self указывает на всю структуру, что в итоге оказывается слишком грубым для практического использования. Вместо этого нам нужно иметь возможность указывать на отдельные поля для описания времён жизни.
Оказывается, Нико также придумал функцию для этого в виде времён жизни, основанных на местах (places). Вместо того чтобы иметь абстрактные времена жизни, такие как 'a, которые мы используем для связи со значениями, было бы лучше, если бы ссылки всегда имели неявные уникальные имена времён жизни. Имея доступ к этому, мы должны переписать мотивирующий пример из нашего предыдущего поста, основанный на 'self:
#![allow(unused)] fn main() { struct GivePatsFuture { resume_from: GivePatsState, data: Option<String>, name: Option<&'self str>, // ← Обратите внимание на время жизни `'self` } }
В пример, основанный на путях:
#![allow(unused)] fn main() { struct GiveManyPatsFuture { resume_from: GivePatsState, first: Option<String>, data: Option<&'self.first str>, // ← Обратите внимание на время жизни `'self.first` } }
В этом примере это может показаться не столь важным; но как только мы введём изменяемость, всё быстро усложнится. И не введение магического 'self в пользу постоянного требования 'self.field, кажется, в целом было бы лучше. А для этого требуются времена жизни, которые могут быть основаны на местах, что, кажется, отличной идеей независимо ни от чего.
Автоматическая стабильность ссылок
Ранее в этом посте мы установили, что нам на самом деле не нужно кодировать !Move для самоссылающихся типов, которые хранят свои значения в куче. Это не все самоссылающиеся типы — но описывает значительное их количество. А что, если бы нам не нужно было кодировать !Move почти для всего оставшегося множества самоссылающихся типов?
Если это звучит как конструкторы перемещения, вы были бы правы — но с оговоркой! В отличие от трейта Relocate, который я описал в своём предыдущем посте, DoveOfHope отметил, что нам, возможно, даже не нужно, чтобы это работало. В конце концов: если компилятор уже знает, что мы указываем на поле, содержащееся в структуре, — разве компилятор не может гарантировать обновление указателей, когда мы пытаемся переместить структуру?
Я скептически относился к возможности этого, пока не прочитал о временах жизни, основанных на местах. С этим, кажется, у нас действительно было бы достаточно детализации, чтобы знать, как обновлять какие поля при их перемещении. С точки зрения стоимости: это просто обновление значения указателя при перемещении — что практически бесплатно. И это почти полностью избавило бы нас от необходимости кодировать !Move.
Единственные случаи, не охваченные этим, — это ссылки 'unsafe или фактические указатели *const T / *mut T на данные в стеке. Компилятор на самом деле не знает, на что они указывают, и поэтому не может обновить их при перемещении. Для этого какая-либо форма трейта Relocate действительно кажется полезной. Но это то, что тоже не нужно было бы добавлять сразу.
Операции с сырыми указателями и автоматическая стабильность ссылок
Этот раздел был добавлен после публикации, 2024-07-08.
Хотя компилятор должен иметь возможность гарантировать, что генерация кода корректна для, например, mem::swap, мы не можем дать те же гарантии для операций с сырыми указателями, таких как ptr::swap. И поскольку существующие структуры могут свободно использовать эти операции внутри, это означает, что самоссылающиеся типы в стеке не могут просто работать без каких-либо оговорок, как мы можем для SRT в куче. Это действительно проблема, и я хочу поблагодарить The_8472 за то, что указал на это.
Мне очень хотелось избежать дополнительных ограничений, чтобы SRT в стеке могли соответствовать опыту SRT в куче. Но это, кажется, невозможно, поэтому, возможно, минимальное количество ограничений, которые мы можем сделать включёнными по умолчанию (как Sized) в определённой редакции, может быть достаточно, чтобы справиться с этим. В настоящее время я думаю о чём-то вроде:
- Ввести новый автотрейт-маркер
Transferв дополнение кRelocate, как двойник системеDestruct/Dropв Rust.Transfer— это название ограничения, которое люди будут использовать,Relocateпредоставляет хуки для расширения системыTransfer. - Все типы с временем жизни
'selfавтоматически реализуютTransfer. - Только ограничения, включающие
+ Transfer, могут принимать типыimpl Transfer. - Все соответствующие операции перемещения сырых указателей должны соблюдать дополнительные инварианты безопасности того, что делать с типами
impl Transfer. - Мы постепенно обновляем стандартную библиотеку для поддержки
+ Transferво всех ограничениях. - В какой-то редакции мы делаем отказ, а не согласие (
T: Transfer→T: ?Transfer).
#![allow(unused)] fn main() { auto trait Transfer {} trait Relocate { ... } }
Мне очень хотелось избежать чего-то подобного. И это ставит под вопрос, действительно ли это проще, чем неперемещаемые типы. Но the_8472 совершенно прав, что это проблема, и поэтому нам нужно её решать. К счастью, мы уже делали что-то подобное с const. И я не думаю, что мы можем это обобщить. Я напишу об этом подробнее в какой-нибудь другой раз.
Relocate, вероятно, должен принимать &own self
Теперь, даже если мы не ожидаем, что людям понадобится писать свою собственную логику обновления указателей, практически когда-либо, это всё равно должно быть предоставлено. И когда мы это сделаем, мы должны правильно это закодировать. Надриериль (Nadrieril) очень любезно указал, что ограничение &mut self в трейте Relocate может быть не совсем тем, что мы хотим, — потому что мы не просто заимствуем значение — мы фактически хотим его уничтожить. Вместо этого они сообщили мне о работе, проделанной в отношении &own, которая дала бы доступ к так называемым «владеемым ссылкам».
Даниэль Генри-Мантилья (Daniel Henry-Mantilla) — автор крейта stackbox, а также основной ответственный за систему расширения времени жизни, стоящую за макросом pin! в стандартной библиотеке. Некоторое время назад он поделился очень полезным описанием &own. Суть идеи в том, что мы должны разделить понятия: «Где конкретно хранятся данные?» и «Кто логически владеет данными?» В результате возникает идея иметь ссылку, которая не просто предоставляет временный уникальный доступ, — но может получить постоянный уникальный доступ. В своём посте Даниэль любезно предоставляет следующую таблицу:
Семантика для T | Для базового выделения памяти | |
|---|---|---|
&T | Общий доступ | Заимствовано |
&mut T | Эксклюзивный доступ | Заимствовано |
&own T | Владеемый доступ (ответственность за уничтожение) | Заимствовано |
Применяя это к нашему посту, мы бы использовали это, чтобы изменить трейт Relocate с приёма &mut self, который временно получает эксклюзивный доступ к типу, — но не может фактически уничтожить тип:
#![allow(unused)] fn main() { trait Relocate { fn relocate(&mut self) -> super Self; } }
На приём &own, который получает постоянный эксклюзивный доступ к типу и может фактически уничтожить тип:
#![allow(unused)] fn main() { trait Relocate { fn relocate(&own self) -> super Self; } }
редакция 2024-07-08: Этот пример был добавлен позже. Чтобы объяснить, что решает &own, давайте взглянем на пример реализации Relocate из нашего предыдущего поста. В нём мы говорим следующее:
Мы делаем одно сомнительное предположение здесь: нам нужно иметь возможность взять владеемые данные из
self, не сталкиваясь с проблемой, когда данные не могут быть перемещены, потому что они уже заимствованы изself.
#![allow(unused)] fn main() { struct Cat { data: String, name: &'self str, } impl Cat { fn new(data: String) -> super Self { ... } } impl Relocate for Cat { fn relocate(&mut self) -> super Self { let mut data = String::new(); // ← фиктивный тип, не выделяет память mem::swap(&mut self.data, &mut data); // ← взять владеемые данные super let cat = Cat { data }; // ← создать новый экземпляр cat.name = cat.data.split(' ').next().unwrap(); // ← создать самоссылку cat // ← вернуть новый экземпляр } } }
Что даёт нам &own — это способ правильно закодировать семантику здесь. Поскольку тип не перемещается, мы не можем фактически переместить его по значению. Но логически мы всё ещё хотим получить уникальное владение значением, чтобы мы могли уничтожить тип и переместить отдельные поля. Это своего рода способ, которым перемещение Box по значению тоже работает, но вместо выделения памяти в куче выделение может быть где угодно. С этим мы могли бы переписать довольно сомнительный код mem::swap выше в более нормально выглядящий код деструктуризации + инициализации:
#![allow(unused)] fn main() { struct Cat { data: String, name: &'self str, } impl Cat { fn new(data: String) -> super Self { ... } } impl Relocate for Cat { fn relocate(&mut self) -> super Self { let Self { data, .. } = self; // ← деструктурировать `self` super let cat = Cat { data }; // ← создать новый экземпляр cat.name = cat.data.split(' ').next().unwrap(); // ← создать самоссылку cat // ← вернуть новый экземпляр } } }
Теперь, поскольку это действительно необходимо для создания типов в фиксированных местах памяти, этому трейту потребуется какая-либо форма синтаксиса -> super Self. В конце концов: это было бы единственное место, где это всё ещё было бы нужно. Для всех, кто интересуется последними новостями об &own, вот issue Rust для него (который, кстати, также был открыт Нико).
Переработанный мотивирующий пример
Имея это в виду, мы можем снова переработать мотивирующий пример из предыдущего поста. Чтобы освежить память всем, вот высокоуровневый код на основе async/.await в Rust, который мы хотели бы иметь:
async fn give_pats() { let data = "chashu tuna".to_string(); let name = data.split(' ').take().unwrap(); pat_cat(&name).await; println!("patted {name}"); } async fn main() { give_pats().await; }
Используя обновления в этом посте, мы можем приступить к его десугарингу. На этот раз без необходимости в каких-либо ссылках на Move или конструировании на месте, благодаря временам жизни на основе путей и автоматическому сохранению референциальной стабильности компилятором:
#![allow(unused)] fn main() { enum GivePatsState { Created, Suspend1, Complete, } struct GivePatsFuture { resume_from: GivePatsState, data: Option<String>, name: Option<&'self.data str>, // ← Обратите внимание на время жизни `'self.data` } impl GivePatsFuture { // ← Конструирование на месте не требуется fn new() -> Self { Self { resume_from: GivePatsState::Created, data: None, name: None } } } impl Future for GivePatsFuture { type Output = (); fn poll(&mut self, cx: &mut Context<'_>) // ← `Pin` не требуется -> Poll<Self::Output> { ... } } }
Это значительно проще, чем то, что у нас было раньше, в своём определении. И даже сам десугаринг вызова оказывается проще: больше не нужен промежуточный конструктор IntoFuture для гарантии конструирования на месте.
#![allow(unused)] fn main() { let into_future = GivePatsFuture::new(); let mut future = into_future.into_future(); // ← `pin!` не требуется loop { match future.poll(&mut current_context) { Poll::Ready(ready) => break ready, Poll::Pending => yield Poll::Pending, } } }
Всё, что нужно для этого, — чтобы компилятор обновлял адреса самоссылок при перемещении. Это небольшое дополнительное количество кодагена всякий раз, когда значение перемещается, — помимо просто битового копирования, ему также нужно обновлять значения указателей. Но это кажется вполне реализуемым, должно работать действительно хорошо и, что наиболее важно: пользователям редко или никогда не нужно будет об этом думать. Написание &'self.field всегда просто будет работать.
Когда неперемещаемые типы всё ещё нужны
Я не хочу полностью отвергать идею неперемещаемых типов. Определённо есть преимущества в наличии типов, которые нельзя перемещать. Особенно при работе со структурами FFI, требующими неперемещаемости. Или некоторые высокопроизводительные структуры данных, которые используют множество самоссылок на данные в стеке, обновление которых было бы слишком дорогостоящим. Случаи использования определённо существуют, но они будут довольно нишевыми. Например: Rust for Linux использует неперемещаемые типы для своих интрузивных связных списков — и я думаю, что им, вероятно, нужна какая-либо форма неперемещаемости для фактической работы.
Однако, если компилятор не требует неперемещаемых типов для предоставления самоссылок, то неперемещаемые типы внезапно переходят от основополагающих к чему-то более близкому к оптимизации. Вероятно, всё ещё стоит добавить их, поскольку они, безусловно, более эффективны. Но если мы сделаем это правильно, добавление неперемещаемых типов будет обратно совместимым и будет тем, что мы можем ввести позже в качестве оптимизации.
Что касается того, должен ли async {} возвращать impl Future или impl IntoFuture: я думаю, ответ действительно должен быть impl IntoFuture. В редакции 2024 мы меняем синтаксис диапазонов (0..12) с возврата Iterator на возврат IntoIterator. Это соответствует поведению Swift, где 0..12 возвращает Sequence, а не IteratorProtocol. Я думаю, это хороший показатель того, что async {} и gen {}, вероятно, также должны возвращать impl Into* трейты, а не свои соответствующие трейты.
Заключение
Мне нравится, когда то, что я пишу, обсуждается, и в итоге я узнаю о другой соответствующей работе. Я думаю, чтобы включить самоссылающиеся типы, я теперь определённо склоняюсь к форме встроенного обновления указателей как части языка, а не к неперемещаемым типам (редакция: возможно, я поторопился). Однако, если мы действительно хотим неперемещаемые типы — я думаю, мой предыдущий пост предоставляет последовательный и удобный для пользователя дизайн, чтобы достичь этого.
Существует довольно большое количество зависимостей, если мы хотим реализовать полную историю для самоссылающихся типов. К счастью, мы можем внедрять функции по одной, включая всё более выразительные формы самоссылающихся типов. С точки зрения важности: какая-либо форма 'unsafe кажется хорошей отправной точкой. За ней следуют времена жизни на основе мест. Типы-представления кажутся полезными, но не находятся на критическом пути, поскольку мы можем обойти поэтапную инициализацию, используя танец с Option. Вот график всех функций и их зависимостей.
Граф, показывающий различные зависимости между языковыми элементами
Разбивка функций на самом деле ещё больше укрепила моё восприятие того, что всё это кажется вполне выполнимым. 'unsafe не кажется таким уж далёким. И Нико высказывался несколько серьёзно о временах жизни на основе путей и типах-представлениях. Посмотрим, как быстро они на самом деле будут разработаны на практике, — но, изложив всё это таким образом, я чувствую некоторый оптимизм!