Почему Pin является частью сигнатур трейтов (и почему это проблема)

— 2024-10-15 Yoshua Wuyts Источник

  • почему Pin является частью сигнатур методов
  • последствия
  • заключение

Некоторое время я задавался вопросом, почему метод Future::poll принимает self: Pin<&mut Self> в своей сигнатуре. Я предполагал, что на это должна быть веская причина, но когда я спросил своих коллег из рабочей группы Async, никто, кажется, не знал навскидку, почему именно так. Или, может быть, они знали, а мне просто было трудно понять. В любом случае, я думаю, что разобрался, и хочу изложить это для потомков, чтобы другие тоже могли понять.

Почему Pin является частью сигнатур методов

Возьмём, к примеру, тип MyType и трейт MyTrait. Мы можем написать реализацию MyTrait, которая доступна только тогда, когда MyType закреплён:

#![allow(unused)]
fn main() {
trait MyTrait {
    fn my_method(&mut self) {}
}
struct MyType;
impl<T> MyTrait for Pin<&mut MyType> {}
}

Внутри функций мы даже можем написать ограничения для этого. Отдельное спасибо Эрику Холку (Eric Holk), который показал мне, что, оказывается, левая часть ограничений трейта может содержать произвольные типы — не только обобщённые типы или даже типы, являющиеся частью сигнатуры функции. Я не знал.

С этим мы можем выразить, что мы принимаем некоторый тип T по значению, и как только мы закрепим это значение, оно будет реализовывать MyTrait:

#![allow(unused)]
fn main() {
fn my_function<T>(input: T)
where
    for<'a> Pin<&'a mut T>: MyTrait,
{
    let pinned_input = pin!(input);
}
}

Внутри MyTrait::my_method тип self будет &mut Pin<&mut Self>. Это не то же самое, что владеемый тип Pin<&mut Self>, но, к счастью, мы можем преобразовать это во владеемый тип, вызвав Pin::as_mut. Документация содержит большое объяснение, почему здесь безопасно переходить от изменяемой ссылки к владеемому экземпляру, что интуитивно противоречит правилам владения Rust.

Но что произойдёт теперь, если вместо написания обобщённого типа T с условием where мы захотим использовать impl trait в ассоциированной позиции (APIT). Мы можем захотеть написать что-то вроде этого:

#![allow(unused)]
fn main() {
// как мы выражаем те же самые ограничения здесь?
fn my_function<T>(input: impl ???) {
    let pinned_input = pin!(input);
}
}

Но у нас нет возможности выразить это точное ограничение. В отличие от обычных обобщённых типов, APIT не могут выражать левую часть ограничения (lvalue), они могут называть только правую часть (rvalue). Это становится ещё более заметным, когда мы пытаемся использовать impl Trait в позиции возврата (RPIT).

Возьмём, к примеру, функцию, которая возвращает некоторый тип T. Используя конкретные ограничения трейтов, мы можем выразить, что она возвращает тип, который при закреплении реализует MyTrait:

#![allow(unused)]
fn main() {
fn my_function<T>() -> T
where
    for<'a> Pin<&'a mut T>: MyTrait,
{
    MyType {}
}
}

Но если мы попытаемся выразить ту же функцию, используя RPIT, мы теряем возможность выразить это ограничение. Единственное решение для выражения -> impl Trait, который раскрывает функциональность при закреплении, — это сделать Pin непосредственно частью сигнатуры методов и не реализовывать трейт для Pin<&mut Type>:

#![allow(unused)]
fn main() {
trait MyTrait {
    fn my_method(self: Pin<&mut Self>) {} // ← обратите внимание на сигнатуру self здесь
}
struct MyType;
impl MyTrait for MyType {} // ← больше не реализован для `Pin<&mut MyType>`
}

И теперь внезапно мы можем выразить -> impl MyTrait, методы которого можно вызывать только тогда, когда MyType закреплён. Unpin является отказом для типов, для которых это не так.

#![allow(unused)]
fn main() {
fn my_function() -> impl MyTrait { // Может быть как закреплённым, так и нет!
    MyType {}
}
}

Последствия

Конкретно это означает, что если вы хотите иметь трейт, который хочет работать с закреплёнными значениями и работать со всеми языковыми возможностями как обычно, вы должны использовать self: Pin<&mut Self> как часть сигнатуры метода. Может быть, это не большая проблема для новых трейтов, но это имеет последствия для каждого существующего трейта в стандартной библиотеке.

Возьмём, к примеру, трейт Iterator. Мы не можем просто написать impl Iterator for Pin<&mut T> и ожидать, что RPIT будет работать. Вместо этого ожидаемый путь здесь, кажется, должен заключаться во введении нового трейта PinnedIterator, который принимает self: Pin<&mut T>. Это обратно несовместимое изменение, общее для всех существующих трейтов в стандартной библиотеке, за исключением Future, который уже принимает self: Pin<&mut Self>. Это довольно большое ограничение, и его стоит учитывать в обсуждениях о жизнеспособности Pin за пределами Future. Для Iterator это означает, что мы захотим создать как минимум следующие варианты:

#![allow(unused)]
fn main() {
// итератор
trait Iterator {
    type Item;
    fn next(&mut self) -> Option<Item>;
}

// чувствительный к адресу итератор
trait PinnedIterator {
    type Item;
    fn next(self: Pin<&mut self>) -> Option<Item>;
}
}

Чтобы пробежаться по ещё нескольким последствиям этого: если мы хотим, чтобы пользователи Rust могли объявлять чувствительные к адресу типы в Rust, то наиболее вероятный путь сейчас — дублирование трейтов в подмодуле std::io, принимающее форму, похожую на эту:

#![allow(unused)]
fn main() {
mod io {
    pub trait Read { ... }
    pub trait PinnedRead { ... }
    pub trait Write { ... }
    pub trait PinnedWrite { ... }
    pub trait Seek { ... }
    pub trait PinnedSeek { ... }
    pub trait BufRead { ... }
    pub trait PinnedBufRead { ... }
}
}

Закрепление (Pinning), как async и try, является комбинаторным свойством трейтов, которое приводит к экспоненциальному количеству дублирования. К счастью для нас, дублирование трейтов — не единственный возможный путь, который мы можем выбрать: некоторая форма полиморфизма существующих интерфейсов относительно Pin также кажется возможной — если мы готовы изменить нашу формулировку. Это то, что привело меня к формулировке моего дизайна для автотрейта Move, который является композируемым, как, например, Send и Sync.

Заключение

Я хочу быстро поблагодарить моих коллег из рабочей группы Async. Мы много говорили об этом, и совместная работа над этим была полезной. Даже если у меня ушло пару месяцев, чтобы наконец опубликовать это. Любые ошибки в этом посте, однако, определённо мои собственные.

В этом посте я в основном хотел объяснить, почему Future принимает self: Pin<&mut Self>, а не &mut self, и полагается на impl Future for Pin<&mut T>. Я думаю, я нашёл хорошую причину для этого, и она снова связана с левой и правой частями ограничений. Для меня это также подтверждает мою гипотезу о том, что любой дизайн для обобщённых самоссылающихся типов должен уметь учитывать следующее:

  1. Возможность пометить тип как неперемещаемый
  2. Возможность переводить типы из перемещаемых в неперемещаемые
  3. Возможность конструировать неперемещаемые типы на месте
  4. Возможность расширять существующие интерфейсы с учётом неперемещаемости
  5. Возможность описывать самоссылающиеся времена жизни
  6. Возможность безопасно инициализировать самоссылки без Option

Этот пост специально затрагивал четвёртое требование: возможность расширять существующие интерфейсы с учётом неперемещаемости. Решение этого может принимать форму дублирования интерфейсов (например, Iterator против PinnedIterator) или композиции через (авто-)трейты (например, Move). Другие методы также могут быть возможны, и я бы призвал людей с идеями делиться ими.

PoignardAzur независимо описал, почему и как закреплённые типы должны быть способны конструироваться на месте. Его пост показал примеры как третьего, так и шестого требований в списке. Он представил форму размещения с помощью нотации -> pin Type. Это похоже на более общую нотацию -> super Type, которую я представил в своём посте, адаптированную из поста Мары (Mara) о super let.

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