Эргономичные самоссылающиеся типы для Rust
— 2024-07-01
Yoshua Wuyts Источник
- мотивирующий пример
- самоссылающиеся времена жизни
- конструирование типов на месте
- преобразование в неперемещаемые типы
- неперемещаемые типы
- мотивирующий пример, переработанный
- поэтапная инициализация
- миграция с Pin на Move
- превращение неперемещаемых типов в перемещаемые
- дальнейшее чтение
- заключение
Я недавно немного размышлял о самоссылающихся типах, и, хотя технически возможно писать их сегодня с помощью Pin (с ограничениями), они совсем не удобны. Так что же потребуется, чтобы сделать их удобными? Ну, насколько я могу судить, есть четыре компонента, необходимых для их реализации:
- Возможность писать время жизни
'self. - Возможность конструировать типы из функций в фиксированных местах памяти.
- Способ пометить типы как «неперемещаемые» в системе типов.
- Возможность безопасно инициализировать самоссылки в структурах без танцев с
Option.
Только когда у нас есть все четыре этих компонента, написание самоссылающихся типов может стать доступным большинству обычных программистов на Rust. И это кажется важным, потому что, как мы видели с async {} и Future: как только вы начинаете писать достаточно сложные стейт-машины, возможность отслеживать ссылки на данные становится невероятно полезной.
Говоря об async и Future: в этом посте мы будем использовать их в качестве мотивирующего примера того, как эти функции могут работать вместе. Потому что если кажется реалистичным, что мы сможем заставить работать такой сложный случай, как этот, то другие, более простые случаи, вероятно, тоже будут работать.
О, и прежде чем мы углубимся, я хочу выразить огромную благодарность Эрику Холку (Eric Holk). Мы потратили несколько часов, работая вместе над последствиями !Move для системы типов и разбирая множество крайних случаев и проблем. Я не могу быть единственным, кому принадлежат идеи в этом посте. Однако любые ошибки в этом посте — мои, и я не претендую на то, чтобы говорить от нашего имени.
Отказ от ответственности: Этот пост не является полностью сформированным дизайном. Это раннее исследование того, как несколько функций могут работать вместе для решения более широкой проблемы. Моя цель — в первую очередь сузить пространство дизайна до конкретного списка функций, которые можно постепенно реализовывать, и поделиться им с более широким сообществом Rust для обратной связи. Я не вхожу в языковую группу и не говорю от её имени.
Мотивирующий пример
Давайте возьмём async {} и Future в качестве примеров здесь. Когда мы заимствуем локальные переменные в блоке async {} через точки .await, результирующая стейт-машина будет хранить как конкретное значение, так и ссылку на это значение в одной и той же структуре стейт-машины. Эту стейт-машину мы называем самоссылающейся, потому что у неё есть ссылка, которая указывает на что-то внутри self. И поскольку ссылки — это указатели на конкретные адреса памяти, возникают сложности с гарантией того, что они никогда не станут недействительными, так как это привело бы к неопределённому поведению. Давайте посмотрим на пример асинхронной функции:
async fn give_pats() { let data = "chashu tuna".to_string(); // ← Объявлено владеемое значение let name = data.split(' ').next().unwrap(); // ← Получена ссылка pat_cat(&name).await; // ← Точка `.await` здесь println!("patted {name}"); // ← Ссылка используется здесь } async fn main() { give_pats().await; // Вызывает функцию `give_pats`. }
Это довольно простая программа, но идея должна быть достаточно понятна: мы объявляем владеемое значение внутри, вызываем функцию с .await, а позже снова ссылаемся на владеемое значение. Это сохраняет ссылку живой через точку .await, и для этого требуются самоссылающиеся типы. Мы можем десугарировать это в стейт-машину фьючерса примерно так:
#![allow(unused)] fn main() { enum GivePatsState { Created, // ← Отмечает, что наш фьючерс создан Suspend1, // ← Отмечает первую точку `.await` Complete, // ← Отмечает, что фьючерс теперь завершён } struct GivePatsFuture { resume_from: GivePatsState, data: Option<String>, name: Option<&str>, // ← Обратите внимание на отсутствие времени жизни здесь } impl GivePatsFuture { fn new() -> Self { Self { resume_from: GivePatsState::Created, data: None, name: None, } } } impl Future for GivePatsFuture { type Output = (); fn poll(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<Self::Output> { ... } } }
Время жизни GivePatsFuture::name неизвестно, в основном потому, что мы не можем его назвать. И поскольку десугаринг происходит в компиляторе, ему тоже не нужно называть время жизни. Мы поговорим об этом подробнее позже в этом посте. Поскольку это генерирует самоссылающуюся стейт-машину, этот фьючерс нужно будет сначала зафиксировать на месте с помощью Pin. После закрепления метод Future::poll можно вызывать в цикле до тех пор, пока фьючерс не вернёт Ready. Десугаринг для этого будет выглядеть примерно так:
#![allow(unused)] fn main() { let mut future = IntoFuture::into_future(GivePatsFuture::new()); let mut pinned = unsafe { Pin::new_unchecked(&mut future) }; loop { match pinned.poll(&mut current_context) { Poll::Ready(ready) => break ready, Poll::Pending => yield Poll::Pending, } } }
И, наконец, просто для справки, вот как выглядят используемые нами трейты сегодня. Основной момент, который интересен здесь для целей этого поста, заключается в том, что Future принимает Pin<&mut Self>, и мы объясним, как его можно заменить более простой системой на протяжении оставшейся части этого поста.
#![allow(unused)] fn main() { pub trait IntoFuture { type Output; type IntoFuture: Future<Output = Self::Output>; fn into_future(self) -> Self::IntoFuture; } pub trait Future { type Output; fn poll(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<Self::Output>; } }
Теперь, когда мы взглянули на то, как компилятор сегодня десугарит самоссылающиеся фьючерсы, давайте посмотрим, как мы можем постепенно заменить это безопасной, конструируемой пользователем системой.
Самоссылающиеся времена жизни
В нашем мотивирующем примере мы показали GivePatsFuture, у которого есть поле name, указывающее на поле data. Это явно ссылка, но она не несёт никакого времени жизни:
#![allow(unused)] fn main() { struct GivePatsFuture { resume_from: GivePatsState, data: Option<String>, name: Option<&str>, // ← Обратите внимание на отсутствие времени жизни } }
Причина, по которой здесь нет времени жизни, не является внутренней; это потому, что мы на самом деле не можем назвать время жизни здесь. Это не 'static, потому что оно не действительно до конца программы. В сегодняшнем компиляторе, я считаю, мы можем просто опустить время жизни, потому что генерация кода происходит после того, как времена жизни уже были проверены. Но скажем, мы хотели бы написать это вручную сегодня как есть; нам понадобилась бы концепция «непроверенного времени жизни», что-то вроде этого:
#![allow(unused)] fn main() { struct GivePatsFuture { resume_from: GivePatsState, data: Option<String>, name: Option<&'unsafe str>, // ← Непроверенное время жизни } }
Возможность написать время жизни, которое не проверяется компилятором, была бы первой ступенькой для включения написания самоссылающихся структур вручную. Это просто потребовало бы много unsafe и тревоги, чтобы сделать правильно. Но, по крайней мере, это было бы возможно. Я считаю, что люди из T-compiler уже работают над добавлением этого, что кажется отличной идеей.
Но ещё лучше было бы, если бы мы могли описывать здесь проверенные времена жизни. Что мы на самом деле хотим написать здесь, так это время жизни, которое действительно на протяжении всего существования значения — и оно всегда гарантированно было бы действительным. Добавление этого времени жизни потребовало бы дополнительных ограничений, которые мы рассмотрим позже в этом посте (например, тип не сможет перемещаться), но что мы действительно хотим, так это иметь возможность написать что-то вроде этого:
#![allow(unused)] fn main() { struct GivePatsFuture { resume_from: GivePatsState, data: Option<String>, name: Option<&'self str>, // ← Действительно на протяжении всего существования `Self` } }
Чтобы немного отвлечься; добавление именованного времени жизни 'self также могло бы позволить нам убрать шаблон where Self: 'a при использовании времён жизни в обобщённых ассоциированных типах. Если вы когда-либо работали со временем жизни в ассоциированных типах, то, вероятно, сталкивались с ошибкой «missing required bounds». Нико предложил в этом issue использовать 'self в качестве одного из возможных решений для шаблонности ограничений. Я думаю, его значение было бы немного другим, чем при использовании с самоссылками? Но я не верю, что использование этого было бы двусмысленным. И в целом, я думаю, это выглядит довольно аккуратно:
#![allow(unused)] fn main() { // Трейт заимствующего итератора, как мы должны писать его сегодня: trait LendingIterator { type Item<'a> where Self: 'a; fn next(&mut self) -> Self::Item<'_>; } // Трейт заимствующего итератора, если бы у нас был `'self`: trait LendingIterator { type Item<'self>; fn next(&mut self) -> Self::Item<'_>; } }
Конструирование типов на месте
Чтобы 'self было действительным, мы должны обещать, что наше значение не будет перемещаться в памяти. И прежде чем мы сможем обещать, что значение не будет перемещаться, мы должны сначала сконструировать его где-то, где можем быть уверены, что оно может остаться и не будет перемещено дальше. Глядя на наш мотивирующий пример, способ, которым мы достигаем этого с помощью Pin, немного дурацкий. Вот тот же пример снова:
impl GivePatsFuture { fn new() -> Self { Self { resume_from: GivePatsState::Created, data: None, name: None, } } } async fn main() { let mut future = IntoFuture::into_future(GivePatsFuture::new()); let mut pinned = unsafe { Pin::new_unchecked(&mut future) }; loop { match pinned.poll(&mut current_context) { Poll::Ready(ready) => break ready, Poll::Pending => yield Poll::Pending, } } }
В этом примере мы видим, что GivePatsFuture конструируется внутри функции new, перемещается из неё и только затем закрепляется на месте с помощью Pin::new_unchecked. Даже если GivePatsFuture: !Unpin, трейт Unpin влияет на типы только после того, как они находятся внутри структуры Pin. И мы не можем просто возвращать Pin из new, потому что стековые фреймы функции отбрасываются в момент возврата из функции.
Было бы лучше, если бы мы позволили типам описывать, как они могут конструировать себя на месте. Это означает больше никаких внешних вызовов Pin::new_unchecked; только внутренне предоставленные конструкторы. Это позволяет нам делать самоссылающиеся типы полностью самодостаточными, с внутренне предоставленными конструкторами, заменяющими внешний танец с закреплением. Вот как мы могли бы переписать GivePatsFuture::new, чтобы использовать внутренний конструктор:
#![allow(unused)] fn main() { impl GivePatsFuture { fn new(slot: Pin<&mut MaybeUninit<Self>>) { let slot = unsafe { slot.get_unchecked_mut() }; let this: *mut Self = slot.as_mut_ptr(); unsafe { addr_of_mut!((*this).resume_from).write(GivePatsState::Created); addr_of_mut!((*this).data).write(None); addr_of_mut!((*this).name).write(None); }; } } }
Если вам это не нравится: это понятно. Я не думаю, что кому-то нравится. Но потерпите; мы вместе отправляемся в небольшое дидактическое путешествие по изготовлению колбасы. Мне жаль, что вы это видите; давайте быстро двинемся дальше.
В недавнем посте в блоге я предположил, что мы могли бы рассматривать такие параметры как «острое возвращаемое значение». Джеймс Маннс (James Munns) указал, что в C++ у этой функции есть название: out-указатели. И Джек Хьюи (Jack Huey) сделал интересную связь между этим и дизайном super let. Чтобы нам снова не пришлось смотреть на кучу небезопасного кода, давайте представим, что мы можем объединить это во что-то связное:
#![allow(unused)] fn main() { impl GivePatsFuture { fn new() -> super Pin<&'super mut Self> { pin!(Self { // просто представьте, что это работает resume_from: GivePatsState::Created, data: None, name: None, }) } } }
Я знаю, знаю — я показываю здесь синтаксис. Лично мне всё равно, как это выглядит, и мы поговорим позже о том, как мы можем это улучшить, но я надеюсь, мы все согласимся, что само тело функции примерно на 400% более читаемо, чем куча unsafe, с которой мы работали ранее. Мы также дойдём до того, как мы можем полностью убрать Pin из сигнатуры, так что, пожалуйста, не зацикливайтесь на этом слишком сильно.
Я прошу вас немного поиграть вместе сейчас и предположить, что мы могли бы сделать что-то подобное, чтобы мы могли добраться до части этого поста, где мы действительно сможем это исправить. Или, возможно, сформулировать это более чётко: этот пост меньше о предложении конкретных дизайнов для проблемы, а больше о том, как мы можем разделить проблему «неперемещаемых типов» на отдельные функции, которые мы можем решать независимо друг от друга.
Преобразование в неперемещаемые типы
Хорошо, у нас есть представление о том, как мы могли бы конструировать неперемещаемые типы на месте, предоставляемые как конструктор, определённый на типе. Теперь, хотя это и приятно, мы также потеряли важное свойство: всякий раз, когда конструируется GivePatsFuture, ему нужно иметь фиксированное место в памяти. Раньше мы могли свободно перемещать его, пока не начинали .await.
Одна из основных причин, почему async полезен, заключается в том, что он позволяет ad-hoc конкурентное выполнение. Это означает, что мы хотим иметь возможность брать фьючерсы и передавать их операциям конкурентности, чтобы обеспечить конкурентность через композицию. Мы не можем перемещать фьючерсы, которые имеют фиксированное местоположение в памяти, поэтому нам нужен краткий момент, когда фьючерсы могут быть перемещены, прежде чем они будут готовы оставаться на месте и опрашиваться до завершения.
Способ, которым Pin работает с этим сегодня, заключается в том, что тип может быть !Unpin — но это становится актуальным только после того, как он помещён внутрь структуры Pin. С фьючерсами это обычно не происходит до тех пор, пока не начинается их опрос, обычно через .await, и поэтому мы получаем свободу перемещать фьючерсы !Unpin до тех пор, пока не начинаем их .await. Вот почему !Unpin не отмечает: «Тип, который нельзя перемещать», он отмечает: «Тип, который нельзя перемещать после того, как он был закреплён». Это определённо сбивает с толку, так что не волнуйтесь, если трудно следовать.
#![allow(unused)] fn main() { fn foo<T: Unpin>(t: &mut T); // Тип не закреплён, тип можно перемещать. fn foo<T: !Unpin>(t: &mut T); // Тип не закреплён, тип можно перемещать. fn foo<T: Unpin>(t: Pin<&mut T>); // Тип закреплён, тип можно перемещать. fn foo<T: !Unpin>(t: Pin<&mut T>); // Тип закреплён, тип нельзя перемещать. }
Если мы хотим, чтобы «неперемещаемость» была безусловной частью типа, мы не можем заставить её вести себя так же, как Unpin. Вместо этого кажется лучше разделить требования перемещаемости / неперемещаемости на два отдельных типа. Мы сначала конструируем тип, который можно свободно перемещать, — и как только мы готовы довести его до завершения, мы преобразуем его в тип, который является неперемещаемым, и начинаем вызывать его. Это идеально соответствует разделению между IntoFuture и Future, которое мы уже используем.
Давайте снова взглянем на наш первый пример, но немного изменим его. То, что я предлагаю здесь, заключается в том, что вместо того, чтобы give_pats возвращал impl Future, он должен возвращать impl IntoFuture. Этот тип не закреплён и может свободно перемещаться. Только когда мы готовы его .await, мы вызываем .into_future, чтобы получить неперемещаемый фьючерс, — и затем вызываем его.
#![allow(unused)] fn main() { struct GivePatsFuture { ... } impl GivePatsFuture { fn new() -> super Pin<&'super mut Self> { ... } // пожалуйста, поверьте на время } struct GivePatsIntoFuture; impl IntoFuture for GivePatsIntoFuture { type Output = (); type IntoFuture = GivePatsFuture; // Мы вызываем конструктор `Future::new`, который даёт нам // `Pin<&'super GivePatsFuture>`, и затем вместо того, чтобы записывать // его в стековый фрейм текущей функции, мы записываем его в стековый фрейм // вызывающей стороны. // // (продолжайте верить ещё немного) fn into_future(self) -> super Pin<&'super mut GivePatsFuture> { GivePatsFuture::new() // создать в области видимости вызывающей стороны } } }
Так же, как мы можем продолжать возвращать значения из функций, чтобы передавать их дальше по стеку вызовов, мы должны иметь возможность использовать out-указатели / размещение / острое выделение в стековом фрейме дальше по стеку вызовов. Хотя, даже если мы не поддерживали бы это с самого начала, мы, вероятно, могли бы встроить GivePatsFuture::new в GivePatsIntoFuture::into_future, и всё равно всё работало бы. И с этим наш десугаринг .await мог бы выглядеть примерно так:
async fn main() { let into_future: GivePatsIntoFuture = give_pats(); let mut future: Pin<&mut GivePats> = GivePatsIntoFuture.into_future(); loop { match future.poll(&mut current_context) { Poll::Ready(ready) => break ready, Poll::Pending => yield Poll::Pending, } } }
Чтобы повторить, почему этот раздел существует: мы можем получить ту же функциональность, которую предоставляют сегодня Pin + Unpin, создав два отдельных типа. Один тип, который можно свободно перемещать. И другой тип, который после конструирования не будет перемещаться в памяти.
Пока что единственная формулировка «неперемещаемых типов», которую я видел, — это единые типы, которые обладают обоими этими свойствами, — так же, как Unpin делает сегодня. То, что я пытаюсь здесь выразить, заключается в том, что мы можем избежать этой проблемы, если решим создать два типа вместо одного, позволяя одному конструировать другой и заставляя их предоставлять отдельные гарантии. Я думаю, это новое понимание, и я счёл важным уделить ему некоторое время.
Неперемещаемые типы
Хорошо, я просил людей поверить, что мы действительно можем каким-то образом выполнить конструирование на месте типа Pin<&mut Self>, и всё сработает так, как мы хотим. Я и сам не уверен, но для повествования этого поста было проще, если бы мы просто на секунду притворились, что можем.
Настоящее решение здесь, конечно, — полностью избавиться от Pin. Вместо этого сами типы должны иметь возможность сообщать, имеют ли они стабильное местоположение в памяти или нет. Простейшая формулировка для этого — добавить новый встроенный автотрейт Move, который сообщает компилятору, можно ли перемещать тип или нет.
#![allow(unused)] fn main() { auto trait Move {} }
Это, конечно, не новая идея: мы знали о возможности Move по крайней мере с 2017 года. Это было до того, как я начал работать с Rust. В сообществе Rust были некоторые стойкие сторонники Move, но в конечном итоге это не тот дизайн, с которым мы остановились. Я думаю, ретроспективно большинство из нас признают, что недостатки Pin достаточно реальны, поэтому пересмотр Move и проработка его ограничений кажется хорошей идеей1. Чтобы объяснить, что такое трейт Move: это был бы трейт на уровне языка, который регулирует доступ к следующим возможностям:
- Возможность передаваться по значению в функции и типы.
- Возможность передаваться по изменяемой ссылке в
mem::swap,mem::takeиmem::replace. - Возможность использоваться с любыми синтаксическими эквивалентами предыдущих пунктов, такими как присваивание изменяемым ссылкам, захваты замыканий и так далее.
И наоборот, когда тип реализует !Move, у него не будет доступа ни к одной из этих возможностей, что делает невозможным его перемещение после того, как он получил фиксированное местоположение в памяти. И по умолчанию мы бы предполагали во всех ограничениях, что типы являются Move, за исключением мест, которые явно отказываются, используя + ?Move. Вот примеры того, что считается перемещением:
#![allow(unused)] fn main() { // # примеры перемещения // ## обмен двух значений let mut x = new_thing(); let mut y = new_thing(); swap(&mut x, &mut y); // ## передача по значению fn foo<T>(x: T) {} let x = new_thing(); foo(x); // ## возврат значения fn make_value() -> Foo { Foo { x: 42 } } // ## захваты замыканий с `move` let x = new_thing(); thread::spawn(move || { let x = x; }) }
А вот некоторые вещи, которые не считаются перемещением:
#![allow(unused)] fn main() { // # вещи, которые не являются перемещением // ## передача ссылки fn take_ref<T>(x: &T) {} let x = new_thing(); take_ref(&x); // ## передача изменяемых ссылок тоже нормально, // но нужно быть осторожным, как вы их используете fn take_mut_ref<T>(x: &mut T) {} let mut x = new_thing(); take_mut_ref(&mut x); }
Передача типов по значению никогда не будет совместима с типами !Move, потому что это и есть перемещение. Передача типов по ссылке всегда будет совместима с типами !Move, потому что они неизменяемы2. Единственное место с некоторой неоднозначностью — когда мы работаем с изменяемыми ссылками, поскольку такие операции, как mem::swap, позволяют нам нарушать гарантии неперемещаемости.
Если функция хочет принимать изменяемую ссылку, которая может быть неперемещаемой, ей придётся добавить + ?Move. Если функция не использует + ?Move для своей изменяемой ссылки, то тип !Move не может быть ей передан. На практике это будет работать следующим образом:
#![allow(unused)] fn main() { fn meow<T>(cat: T); // по значению, нельзя передавать значения `!Move` fn meow<T>(cat: &T); // по ссылке, можно передавать значения `!Move` fn meow<T>(cat: &mut T); // по изм. ссылке, нельзя передавать значения `!Move` fn meow<T: ?Move>(cat: &mut T) // по изм. ссылке, можно передавать значения `!Move` }
По умолчанию все ограничения cat: &mut T подразумевали бы + Move. И только там, где мы соглашаемся на + ?Move, могли бы передаваться типы !Move. На практике, вероятно, в большинстве мест можно будет добавить + ?Move, поскольку гораздо чаще записывают в поле изменяемой ссылки, чем заменяют её целиком с помощью mem::swap. Такие вещи, как внутренняя изменяемость, вероятно, также в основном допустимы в соответствии с этими правилами, поскольку даже если доступ осуществляется через общие ссылки, обновление значений в указателях должно будет взаимодействовать с ранее установленными нами правилами, — и они по умолчанию безопасны.
Чтобы быть полностью точными, мы также должны учитывать внутреннюю изменяемость. Это позволяет нам изменять значения через общие ссылки — но только за счёт возможности условно преобразовывать их в ссылки &mut во время выполнения. То, что мы разрешаем приведение &T к &mut T во время выполнения, не означает, что правила, которые мы применили к системе, всё ещё не работают. Скажем, мы держали &mut T: !Move внутри Mutex. Если бы мы попытались вызвать метод deref_mut, мы получили бы ошибку компиляции, потому что это ограничение ещё не объявило, что T: ?Move. Мы могли бы, вероятно, добавить это, но поскольку по умолчанию это не работает, у нас была бы возможность проверить его корректность перед добавлением.
В любом случае, пока достаточно теории о том, как это, вероятно, должно работать. Давайте попробуем обновить наш предыдущий пример, заменив Pin на !Move. Это должно быть так же просто, как добавить impl !Move для GivePatsFuture.
#![allow(unused)] fn main() { struct GivePatsFuture { resume_from: GivePatsState, data: Option<String>, name: Option<&'self str>, } impl !Move for GivePatsFuture {} }
И как только у нас это есть, мы можем изменить наши конструкторы, чтобы возвращать super Self вместо super Pin<&'super mut Self>. Мы уже знаем, что размещение с использованием чего-то вроде super Self (не фактическая нотация) для записи в фиксированные места памяти кажется правдоподобным. Всё, что нам тогда нужно сделать, — это добавить автотрейт, который сообщает системе типов, что дальнейшие операции перемещения не разрешены.
#![allow(unused)] fn main() { struct GivePatsFuture { ... } impl !Move for GivePatsFuture {} impl GivePatsFuture { fn new() -> super Self { ... } // создать в области видимости вызывающей стороны } struct GivePatsIntoFuture; impl IntoFuture for GivePatsIntoFuture { type Output = (); type IntoFuture = GivePatsFuture; fn into_future(self) -> super GivePatsFuture { GivePatsFuture::new() // создать в области видимости вызывающей стороны } } }
Наверное, мне следовало сказать это раньше, но скажу сейчас: в этом посте я намеренно не беспокоюсь об обратной совместимости. Снова суть в том, чтобы разбить сложное пространство дизайна «неперемещаемых типов» на более мелкие проблемы, которые мы можем решать одну за другой. Выяснение того, как соединить Pin и !Move, — это то, что мы захотим понять в какой-то момент, но не сейчас.
Что касается async {} и Future: это должно работать! Это позволяет нам свободно перемещать блоки async, которые десугарируются в IntoFuture. И только когда мы готовы начать их опрашивать, мы вызываем into_future, чтобы получить impl Future + !Move. Система, подобная этой, эквивалентна существующей системе Pin, но не нуждается в Pin в своей сигнатуре. Для верности вот как мы смогли бы переписать сигнатуру Future с этим изменением:
#![allow(unused)] fn main() { // Текущий трейт `Future` // использующий `Pin<&mut Self>` pub trait Future { type Output; fn poll(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<Self::Output>; } // Трейт `Future`, использующий `Move` // использующий `&mut self` pub trait Future { type Output; fn poll(&mut self, cx: &mut Context<'_>) -> Poll<Self::Output>; } }
Это также означало бы: больше никаких проекций Pin. Больше никаких несовместимостей с Drop. Поскольку это автотрейт, который регулирует поведение языка, пока базовые правила корректны, взаимодействие со всеми другими частями Rust будет корректным.
Также, что, вероятно, наиболее актуально для меня, это сделало бы возможным писать стейт-машины фьючерсов с использованием методов и функций, в отличие от текущего статус-кво, когда мы просто сваливаем всё в тело функции poll. После того как за последние шесть лет я написал невероятное количество фьючерсов вручную, я не могу передать, как сильно я хотел бы иметь такую возможность.
Мотивирующий пример, переработанный
Теперь, когда мы рассмотрели самоссылающиеся времена жизни, конструирование на месте, поняли, что async {} должен возвращать IntoFuture, и увидели !Move, мы готовы объединить эти функции, чтобы переработать наш мотивирующий пример. Вот с чего мы начали, используя обычный код async/.await:
async fn give_pats() { let data = "chashu tuna".to_string(); let name = data.split(' ').next().unwrap(); pat_cat(&name).await; println!("patted {name}"); } async fn main() { give_pats().await; }
И вот во что, с этими новыми возможностями, мог бы десугариться async fn give_pats. Обратите внимание на время жизни 'self, impl !Move для фьючерса, отсутствие Pin везде и конструирование типа на месте.
#![allow(unused)] fn main() { enum GivePatsState { Created, Suspend1, Complete, } struct GivePatsFuture { resume_from: GivePatsState, data: Option<String>, name: Option<&'self str>, // ← Обратите внимание на время жизни `'self` } impl !Move for GivePatsFuture {} // ← Этот тип неперемещаемый impl Future for GivePatsFuture { type Output = (); fn poll(&mut self, cx: &mut Context<'_>) // ← `Pin` не требуется -> Poll<Self::Output> { ... } } struct IntoGivePatsFuture {} impl IntoFuture for IntoGivePatsFuture { type Output = (); type IntoFuture: GivePatsFuture fn into_future(self) -> super GivePatsFuture { // ← Записывает в стабильный адрес Self { resume_from: GivePatsState::Created, data: None, name: None, } } } }
И, наконец, мы можем затем десугарить вызов give_pats().await в конкретные типы, которые мы конструируем и вызываем до завершения:
#![allow(unused)] fn main() { let into_future = IntoGivePatsFuture {}; 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, } } }
И с этим у нас должен быть рабочий пример блоков async {}, десугарированных в конкретные типы и трейты, которые вообще не используют Pin. Доступ к полям внутри не проходил бы через какую-либо проекцию закрепления, и больше не было бы необходимости в таких вещах, как стековое закрепление. Неперемещаемость была бы просто свойством самих типов, конструируемых, когда они нам нужны, в том месте, где мы хотим их использовать.
О, и, наверное, стоит упомянуть: функции, работающие с этими трейтами, всегда будут хотеть использовать T: IntoFuture, а не T: Future. Это не большое изменение и, на самом деле, то, что люди уже должны делать сегодня. Но я подумал, что упомяну это на случай, если люди запутаются в том, какими должны быть ограничения для операций конкурентности.
Поэтапная инициализация
Мы не показали этого в нашем примере, но есть ещё один аспект самоссылающихся типов, который стоит осветить: поэтапная инициализация. Это когда вы инициализируете части типа в разные моменты времени. В нашем мотивирующем примере нам не пришлось использовать это, потому что самоссылки находились внутри Option. Это означает, что когда мы инициализировали тип, мы могли просто передать None, и всё было хорошо. Однако, скажем, мы действительно хотим инициализировать самоссылку, как мы поступим?
#![allow(unused)] fn main() { struct Cat { data: String, name: &'self str, } impl !Move for Cat {} impl Cat { fn new(data: String) -> super Self { Cat { data: "chashu tuna".to_string(), name: /* Как мы ссылаемся здесь на `self.data`? */ } } } }
Конечно, поскольку String выделяется в куче, её адрес фактически стабилен, и поэтому мы могли бы написать что-то вроде этого:
#![allow(unused)] fn main() { struct Cat { data: String, name: &'self str, } impl !Move for Cat {} impl Cat { fn new(data: String) -> super Self { let data = "chashu tuna".to_string(); Cat { name: data.split(' ').next().unwrap(), data, } } } }
Это явно жульничество, и не то, что мы хотим, чтобы люди делали. Но это указывает нам на то, как, вероятно, должно работать решение: сначала нам нужен стабильный адрес, на который можно указывать. И как только у нас есть этот адрес, мы можем ссылаться на него. Мы не можем сделать этого, если должны построить всё за один раз. Но что, если бы мы могли сделать это в несколько этапов? Именно об этом говорилось в недавнем посте Нико о проверке заимствований и типах-представлениях (view types). Это позволило бы нам изменить наш пример, чтобы он вместо этого был написан так:
#![allow(unused)] fn main() { struct Cat { data: String, name: &'self str, } impl !Move for Cat {} impl Cat { fn new(data: String) -> super Self { super let this = Cat { data: "chashu tuna".to_string() }; // ← частичная инициализация this.name = this.data.split(' ').next().unwrap(); // ← завершение инициализации this } } }
Мы сначала инициализируем владеемые данные в Cat. И как только они у нас есть, мы можем затем инициализировать ссылки на них. Эти ссылки были бы 'self, мы добавляем аннотацию super let, чтобы указать, что помещаем это в область видимости вызывающей стороны, и всё должно затем проверяться.
Миграция с Pin на Move
Что мы не рассмотрели в этом посте, так это какую-либо историю миграции с существующих API на основе Pin на новую систему на основе Move. Если мы хотим отказаться от Pin в пользу Move, единственный правдоподобный путь, который я вижу, — это создание новых трейтов, которые не несут Pin в своей сигнатуре, и предоставление имплементаций-мостов от старых трейтов к новым. Базовая конверсия с явным методом могла бы выглядеть так, хотя общие имплементации также могли бы быть возможностью:
#![allow(unused)] fn main() { pub trait NewFuture { type Output; fn poll(&mut self, ...) { ... } } pub trait Future { type Output; fn poll(self: Pin<&mut Self>, ...) { ... } /// Преобразует этот фьючерс в `NewFuture`. fn into_new_future(self: Pin<&mut Self>) -> NewFutureWrapper<&mut Self> { ... } } /// Обёртка, связывающая старый трейт фьючерса с новым. struct NewFutureWrapper<'a, F: Future>(Pin<&'a mut F>); impl !Move for NewFutureWrapper {} impl<'a, F> NewFuture for NewFutureWrapper<'a, F> { ... } }
Я повторяю эту строку как минимум три года: но если мы хотим исправить проблемы с Pin, первый шаг, который нам нужно сделать, — это не усугублять проблему. Если стандартной библиотеке нужно исправить трейт Future один раз, это неприятно, но нормально, и мы найдём способ сделать это. Но если мы свяжем Pin с рядом других трейтов, проблемы усугубятся, и я больше не уверен, сможем ли мы избавиться от Pin. И это проблема, потому что Pin широко не любим, и мы активно хотим от него избавиться.
Совместимость с самоссылающимися типами важна не только для итерации; это обобщённое свойство, которое в конечном итоге взаимодействует почти с каждым трейтом, функцией и языковой возможностью. Move просто компонуется с любым другим трейтом, и поэтому нет необходимости в специальном PinnedRead или чём-то подобном. Вместо этого тип просто реализовал бы Read + Move, и этого было бы достаточно для работы самоссылающегося читателя. И мы можем повторять это для любой другой комбинации трейтов.
Конструирование на месте, конечно, меняет сигнатуру трейтов. Но чтобы поддерживать это обратно совместимым образом, всё, что нам нужно сделать, — это позволить трейтам соглашаться на «может выполнять конструирование на месте». И возможность постепенно внедрять такие возможности — именно то, над чем мы работаем в рамках обобщённых эффектов.
#![allow(unused)] fn main() { pub trait IntoFuture { type Output; type IntoFuture: Future<Output = Self::Output>; // Помечен как совместимый с конструированием на месте, при этом // реализации могут решать, хотят ли они использовать это или нет. fn into_future(self) -> #[maybe(super)] Self::IntoFuture; } }
Если мы хотим, чтобы самоссылающиеся типы были общеупотребительными, они должны практически компоноваться с большинством других возможностей, которые у нас есть. И поэтому действительно первый шаг к этому — прекратить стабилизировать любые новые трейты в стандартной библиотеке, которые используют Pin в своей сигнатуре.
Превращение неперемещаемых типов в перемещаемые
До сих пор мы много говорили о самоссылающихся типах и о том, что нам нужно гарантировать, что они не могут быть перемещены, потому что их перемещение было бы плохо. Но что, если бы мы всё-таки разрешили их перемещать? В C++ это возможно с помощью функции под названием «конструкторы перемещения», и если мы поддерживаем самоссылающиеся типы в Rust, это не кажется большим скачком — поддержать это тоже.
Прежде чем мы пойдём дальше, я хочу предупредить: я слышал от людей, которые работали с конструкторами перемещения в C++, что с ними может быть довольно сложно работать. Я сам с ними не работал, поэтому не могу говорить по опыту. Лично у меня нет особых случаев использования, когда бы я чувствовал, что хотел бы конструкторы перемещения, поэтому я не особо за или против их поддержки. Я пишу этот раздел в основном из академического интереса, потому что знаю, что найдутся люди, которые об этом задумаются. И правила того, как это должно работать, кажутся довольно простыми.
Нико Мацакис недавно написал две части о трейте Claim (первая, вторая), предлагая новый трейт Claim, чтобы заполнить пробел между Clone и Copy. Этот трейт был бы для типов, которые «дёшево» клонировать, таких как типы Arc и Rc. И с помощью autoclaim компилятор автоматически вставлял бы вызовы .claim по мере необходимости. Например, когда замыкание move || захватывает тип, реализующий Claim, но он уже используется где-то ещё, — оно автоматически вызовет .claim, чтобы скомпилировалось.
Включение возможности релокации для неперемещаемых типов работало бы примерно так же, как и авто-захват. Нам нужно было бы ввести новый трейт, который мы здесь назовём Relocate, с методом relocate. Всякий раз, когда мы пытались бы переместить в противном случае неперемещаемое значение, мы автоматически вызывали бы .relocate вместо этого. Сигнатура трейта Relocate принимала бы self как изменяемую ссылку. И возвращала бы экземпляр Self, сконструированный на месте:
#![allow(unused)] fn main() { trait Relocate { fn relocate(&mut self) -> super Self; } }
Обратите внимание на сигнатуру self здесь: мы принимаем его по изменяемой ссылке — не по владению и не по общей ссылке. Это потому, что то, что мы пишем, по сути является неперемещаемым эквивалентом Into, но мы не можем взять self по значению — поэтому мы должны взять его по ссылке и сказать людям просто сделать mem::swap. Применяя это к нашему предыдущему примеру с Cat, мы смогли бы реализовать это следующим образом:
#![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 } } }
Мы делаем одно сомнительное предположение здесь: нам нужно иметь возможность взять владеемые данные из self, не сталкиваясь с проблемой, когда данные не могут быть перемещены, потому что они уже заимствованы из self. Это общая проблема, которую нам нужно решить, и один из способов, которым мы могли бы, например, обойти это, — создание фиктивных указателей в основной структуре, чтобы гарантировать, что типы всегда действительны, — но мы делаем типы недействительными:
#![allow(unused)] fn main() { struct Cat { data: String, dummy_data: String, // никогда не инициализируется значением name: &'self str, } impl Relocate for Cat { fn relocate(&mut self) -> super Self { self.name = &self.dummy_data; // больше нет ссылок на `self.data` let data = mem::take(&mut self.data); // короче, чем `mem::swap` super let cat = Cat { data }; cat.name = cat.data.split(' ').next().unwrap(); cat } } }
В этом примере Cat реализовал бы Move, даже если у него есть время жизни 'self, потому что мы можем свободно перемещать его. Когда тип уничтожается после того, как был передан в Relocate, он не должен вызывать свою impl Drop. Потому что семантически мы не пытаемся уничтожить тип — всё, что мы делаем, — это обновляем его местоположение в памяти. В соответствии с этими правилами доступ к 'self в структурах был бы доступен как если Self: !Move, так и если Self: Relocate.
Я хочу снова подчеркнуть, что я здесь не прямо выступаю за введение конструкторов перемещения в Rust. Лично я довольно нейтрально к ним отношусь, и меня можно убедить в любую сторону. В основном я хотел хотя бы один раз пройтись по тому, как могли бы работать конструкторы перемещения, потому что кажется хорошей идеей знать, что постепенный путь здесь возможен. Надеюсь, эта мысль здесь доходит нормально.
Дальнейшее чтение
RFC Pin — интересное чтение, так как оно описывает систему неперемещаемых типов, с которой мы в итоге остановились сегодня. В частности, сравнение между Pin и Move, а также раздел о недостатках интересно перечитать. Особенно когда мы сравниваем его с документацией по pin и видим, чего не было в RFC, но что позже оказалось серьёзными практическими проблемами (например, проекции pin, взаимодействие с остальной частью языка).
Tmandry представил интересную серию (блог 1, блог 2, доклад) о внутренностях async. В частности, он рассказывает, как блоки async {} десугарируются в стейт-машины на основе Future. Этот пост использует это десугаринг в качестве мотивирующего примера, так что для тех, кто хочет узнать больше о том, что происходит за кулисами, это отличный ресурс. Раздел о десугаринге .await в справочнике Rust также стоит прочитать, так как он отражает текущее положение дел в компиляторе.
Более недавно доклад и крейт Мигеля Янга де ла Сота (mcyoung) для поддержки конструкторов перемещения C++ интересно почитать. Что-то, что я ещё не до конца осмыслил, но что интересно, — это трейт New, который он предоставляет. Его можно использовать для конструирования типов на месте как в стеке, так и в куче, что, в идеале, что-то вроде нотации super let / super Type тоже могло бы поддерживать. Можно думать о конструкторах перемещения C++ как о дальнейшей эволюции неперемещаемых типов, так что неудивительно, что существует много общих концепций.
Два года назад я пытался сформулировать способ, которым мы могли бы использовать типы-представления для безопасной проекции pin (пост), и у меня не получилось в нескольких аспектах. В частности, я не был уверен, как разобраться с взаимодействиями с #[repr(packed)], как сделать Drop совместимым и как пометить Unpin как unsafe. Возможно, есть путь для последнего, но я не знаю никаких практических решений для первых двух проблем. Этот пост, по сути, является продолжением того поста, но меняющим предпосылку с: «Как мы можем исправить Pin?» на «Как мы можем заменить Pin?».
Серия Нико о типах-представлениях также стоит прочтения. Его первый пост обсуждает, что такое типы-представления, как они будут работать и почему они полезны. И в одном из его самых последних постов он обсуждает, как типы-представления вписываются в более широкую «дорожную карту из 4 частей для borrow checker» (также известную как «borrow checker within»). В своём последнем посте он также напрямую освещает поэтапную инициализацию с использованием типов-представлений, что является одной из функций, которые мы обсуждаем в этом посте в связи с самоссылающимися типами.
Наконец, я бы предложил взглянуть на крейт ouroboros. Он позволяет безопасную поэтапную инициализацию для самоссылающихся типов в стабильном Rust, используя макросы и замыкания. Способ его работы заключается в том, что сначала инициализируются поля, использующие владеемые данные. А затем выполняются замыкания для инициализации полей, ссылающихся на данные. Поэтапная инициализация с использованием типов-представлений, описанная в этом посте, имитирует этот подход, но включает её напрямую из языка через общеполезную функцию.
Заключение
В этом посте мы разобрали «самоссылающиеся типы» на четыре составные части:
- Времена жизни
'unsafe(непроверенные) и'self(проверенные), которые сделают возможным выражение самоссылающихся времён жизни. - Моральный эквивалент
super let/-> super Typeдля безопасной поддержки out-указателей. - Способ обратно совместимо добавлять опциональные нотации
-> super Type. - Новый автотрейт
Move, который регулирует доступ к операциям перемещения. - Функция типов-представлений, которая сделает возможным конструирование самоссылающихся типов без танцев с
Option.
Финальное прозрение, которое предоставляет этот пост, заключается в том, что сегодняшнюю систему Pin + Unpin можно эмулировать с помощью Move, создавая обёртки Move, которые могут возвращать типы !Move. В контексте async шаблоном было бы создание обёртки impl IntoFuture + Move, которая конструирует фьючерс impl Future + !Move на месте через out-указатель.
Люди в целом не любят Pin, и, насколько я могу судить, существует широкая поддержка исследования альтернативных решений, таких как Move. Сейчас единственный трейт, который использует Pin в стандартной библиотеке, — это Future. Чтобы облегчить миграцию с Pin на что-то вроде Move, нам будет хорошо не вводить дальше никаких API на основе Pin в стандартную библиотеку. Миграция с одного API потребует усилий, но в конечном итоге кажется выполнимой. Миграция с множества API потребует больше усилий и делает более вероятным, что мы навсегда останемся с трудностями Pin.
Цель этого поста заключалась в том, чтобы распутать большую страшную проблему «неперемещаемых типов» на её составные части, чтобы мы могли начать решать их одну за другой. Ни один из синтаксисов или семантик в этом посте не предназначены быть конкретными или окончательными. В основном я хотел хотя бы один раз пройти через всё необходимое для работы неперемещаемых типов, чтобы другие могли углубиться, подумать вместе, и мы могли начать уточнять конкретные детали.
Примечания
-
Для всех, кто хочет возложить здесь вину или вытащить скелеты из шкафов: пожалуйста, не делайте этого. Высший смысл здесь в том, что у нас есть
Pinсегодня, он явно не работает так хорошо, как надеялись в то время, и мы хотели бы заменить его чем-то лучшим. Я думаю, самое интересное, что здесь можно исследовать, — это как мы можем двигаться вперёд и делать лучше. ← ↩ -
Да, да, мы дойдём до внутренней изменяемости через секунду. ← ↩