Проблема с автотрейтами в gen

— 2025-01-13 Yoshua Wuyts Источник

  • утечка автотрейтов из тел gen
  • предотвращение утечки
  • как насчёт других эффектов и автотрейтов?
  • заключение

Один из открытых вопросов, связанных с нестабильной функцией gen {}, — должен ли он возвращать Iterator или IntoIterator. У людей было ощущение, что могут быть веские причины для возврата IntoIterator, но они не обязательно могли их четко сформулировать. Вот почему это было включено в раздел «нерешённых вопросов» RFC по блокам gen.

Поскольку я хотел бы, чтобы блоки gen {} стабилизировались скорее, я подумал, что стоит потратить некоторое время на изучение этого вопроса и выяснить, есть ли причины выбрать одно вместо другого. И я обнаружил то, что считаю довольно неприятной проблемой с возвратом Iterator из gen, которую я начал называть проблемой автотрейтов в gen. В этом посте я расскажу, в чём заключается эта проблема, а также о том, как возврат IntoIterator из gen предотвратил бы её. Итак, без дальнейших церемоний, давайте погрузимся!

Утечка автотрейтов из тел gen

Проблема, которую я обнаружил, связана с реализациями автотрейтов для конкретизированных экземпляров gen {}. Возьмём API thread::spawn: он принимает замыкание, которое возвращает тип T. Если вы раньше не видели его определение, вот оно:

#![allow(unused)]
fn main() {
pub fn spawn<F, T>(f: F) -> JoinHandle<T>
where
    F: FnOnce() -> T + Send + 'static,
    T: Send + 'static,
}

Эта сигнатура утверждает, что замыкание F и тип возврата T должны быть Send. Но она не утверждает, что все локальные значения, созданные внутри замыкания F, также должны быть Send, — что часто встречается в асинхронном мире. Теперь давайте создадим итератор с помощью блока gen {}, который мы попытаемся отправить между потоками. Мы можем создать итератор, который выдаёт одно u32:

#![allow(unused)]
fn main() {
let iter = gen {
    yield 12u32;
};
}

Теперь, если мы попытаемся передать это в thread::spawn, проблем не возникнет. Наш итератор реализует + Send, и всё будет работать как ожидалось:

#![allow(unused)]
fn main() {
// ✅ Ok
thread::spawn(move || {
    for num in iter {
        println("{num}");
    }
}).unwrap();
}

Теперь, чтобы показать вам проблему, давайте попробуем снова, но на этот раз с блоком gen {}, который внутри создаёт std::rc::Rc. Этот тип !Send, что означает, что любой тип, который содержит его, также будет !Send. Это означает, что iter в нашем примере теперь больше не является потокобезопасным:

#![allow(unused)]
fn main() {
let iter = gen { // `-> impl Iterator + !Send`
    let rc = Rc::new(...);
    yield 12u32;
    rc.do_something();
};
}

Это означает, что если мы попытаемся отправить его между потоками, мы получим ошибку компилятора:

#![allow(unused)]
fn main() {
// ❌ нельзя отправить тип `!Send` между потоками
thread::spawn(move || {   // ← `iter` перемещается
    for num in iter {     // ← `iter` используется
        println("{num}");
    }
}).unwrap();
}

И вот в чём проблема. Несмотря на то, что итераторы ленивые и Rc в нашем блоке gen {} фактически не создаётся до начала итерации в новом потоке, генераторный блок должен резервировать для него место внутри, и поэтому он наследует ограничение !Send. Это приводит к несовместимостям, когда локальные переменные, определённые полностью внутри самого gen {}, влияют на публичный API. Это оказывается очень тонким и сложным для отладки, если вы уже не знакомы с десугарингом генераторов.

Предотвращение утечки

Решить проблему автотрейтов в gen не так уж сложно. Мы хотим, чтобы поля !Send в генераторе не появлялись в сгенерированном типе до тех пор, пока мы не будем готовы начать итерацию по нему. Звучит немного страшно, но на практике всё, что нам нужно сделать, — это заставить gen {} возвращать impl IntoIterator, а не impl Iterator. Сам Iterator всё равно будет !Send, но наш тип IntoIterator будет Send:

#![allow(unused)]
fn main() {
let iter = gen { // `-> impl IntoIterator + Send`
    let rc = Rc::new(...);
    yield 12u32;
    rc.do_something();
};
}

Поскольку наше значение iter теперь реализует Send, мы можем без проблем передавать его через границы потоков. И наш код продолжает работать как ожидалось, поскольку for..in работает с IntoIterator, который реализован для всех Iterator:

#![allow(unused)]
fn main() {
// ✅ Ok
thread::spawn(move || {   // ← `iter` перемещается
    for num in iter {     // ← `iter` используется
        println("{num}");
    }
}).unwrap();
}

Если подумать, с теоретической точки зрения это также имеет много смысла. Мы можем рассматривать for..in как наш способ обработки эффекта итерации, который ожидает IntoIterator. gen {} — это двойник этого, используемый для создания новых экземпляров эффекта итерации. Совсем не странно, что он возвращает тот же трейт, который ожидает for..in. С дополнительным бонусом в том, что он не просачивает автотрейты из тел реализаций в сигнатуру типа.

Как насчёт других эффектов и автотрейтов?

Эта проблема в первую очередь затрагивает эффекты, которые используют преобразование генератора. А именно: итерацию и асинхронность. Теоретически я считаю, что да, async {}, вероятно, должен возвращать IntoFuture, а не Future. В рабочей группе Async мы регулярно видим проблемы, связанные с утечкой автотрейтов из тел функций в сигнатуры типов. Если бы async {} (2019) возвращал IntoFuture (2022), а не Future (2019), это, определённо, могло бы помочь. Хотя сегодня, когда всё уже стабильно, было бы сложнее внести такое изменение.

Со стороны трейтов это затрагивает не только Send: это относится ко всем автотрейтам, настоящим и будущим. Хотя Send — безусловно, самый распространённый трейт, с которым люди сталкиваются сегодня, в меньшей степени это уже относится и к Sync. А в будущем, возможно, и к автотрейтам вроде Freeze, Leak и Move. Хотя это не должно быть главным мотиватором, предотвращение потенциальных будущих проблем тоже небесполезно.

Заключение

Признаюсь, худшая часть возврата IntoIterator из gen {} — это то, что название трейта откровенно неудачное. IntoIterator звучит как вспомогательный по отношению к Iterator, и поэтому кажется немного неправильным делать его тем, что мы возвращаем из gen {}. Но помимо этого: это много символов для написания.

Интересно, что бы произошло, если бы мы взяли подход, более похожий на Swift. В Swift есть Sequence, который похож на IntoIterator в Rust, и IteratorProtocol, который похож на Iterator в Rust. Основной интерфейс, который люди должны использовать, короткий и запоминающийся. В то время как вторичный интерфейс не предназначен для прямого использования, поэтому у него гораздо более длинное и менее запоминающееся название. Поскольку мы всё чаще думаем об IntoIterator как об основном интерфейсе для асинхронной итерации, возможно, в будущем мы захотим пересмотреть схему именования трейтов.

В заключение: кажется хорошей идеей предотвратить утечку автотрейтов из тел gen при конкретизации. Это как раз та проблема, которую, если мы можем предотвратить, мы должны предотвратить, поскольку её может быть трудно диагностировать и сложно обойти. Тот факт, что утечка автотрейтов не является проблемой для блоков-генераторов, кажется стоящим.