Проблема с автотрейтами в 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 при конкретизации. Это как раз та проблема, которую, если мы можем предотвратить, мы должны предотвратить, поскольку её может быть трудно диагностировать и сложно обойти. Тот факт, что утечка автотрейтов не является проблемой для блоков-генераторов, кажется стоящим.