Итераторы и замыкания
открытый урокЭтот раздел читается без входа. Войди, чтобы отмечать прогресс, вести заметки и решать задачи в редакторе. войти
Итераторы и замыкания
Итератор это трейт с одним обязательным методом
next, на котором стандартная библиотека строит десятки ленивых адаптеров. Урок закрывает блок про систему типов: итератор как трейт в действии, замыкания как три трейтаFn, и проверка дизассемблером, что цепочкаmap().filter().sum()не медленнее цикла.
Идея
Подсчитаем критичные алерты в ленте мониторинга. Рука сама пишет цикл с аккумулятором:
let mut critical = 0;
for alert in &alerts {
if alert.is_critical {
critical += 1;
}
}
Работает. Но в этих пяти строчках смешаны два разных дела: что мы ищем (критичные) и как по ленте идти (заведи счётчик, крути цикл, прибавляй). Итераторы разделяют их:
let critical = alerts.iter().filter(|a| a.is_critical).count();
Одна строчка, ноль mut, и читается как фраза: взять, отфильтровать, посчитать. Такой конвейер ты собирал и в JS, и в Haskell, но у Rust здесь два своих козыря. Первый: вся машинерия построена на обычном трейте с одним обязательным методом, и к концу урока ты напишешь её участника своими руками. Второй: цепочка компилируется в тот же машинный код, что и ручной цикл. Красота без налога, проверим это дизассемблером в финале.
И сразу главный принцип, на котором всё держится. Адаптеры ленивые: map и filter ничего не вычисляют, они только собирают машину. Запускает её потребитель, count, sum или collect, и пока его нет, лента не двигается. Целый язык, построенный на этой идее, ты видел в уроке про ленивые вычисления; Rust взял её точечно, для одного трейта.
Один метод next
Вот весь обязательный контракт итератора:
pub trait Iterator {
type Item;
fn next(&mut self) -> Option<Self::Item>;
// ...и семь десятков методов по умолчанию поверх next
}
type Item это ассоциированный тип, «что выдаёт этот итератор». А next отвечает на один вопрос: есть ещё элемент или нет. Протокол можно прокрутить руками:
let scores = vec![7, 21, 3];
let mut it = scores.iter();
assert_eq!(it.next(), Some(&7));
assert_eq!(it.next(), Some(&21));
assert_eq!(it.next(), Some(&3));
assert_eq!(it.next(), None); // лента кончилась
Обрати внимание на &mut self в сигнатуре: итератор хранит позицию, и каждый вызов next сдвигает её. Итератор это курсор с состоянием, а Option<Item> кодирует исчерпание тем же типом, которым урок про перечисления кодировал отсутствие значения. Никакого hasNext, никаких исключений StopIteration: один метод, один Option.
Всё остальное богатство, map, filter, sum и далее по списку, это методы по умолчанию из урока про трейты, выраженные через next. Реализуешь один метод, получаешь все.
for это сахар над IntoIterator
Цикл for не магия. Компилятор разворачивает его примерно так:
for score in scores { /* тело */ }
// превращается в:
let mut it = scores.into_iter();
while let Some(score) = it.next() { /* тело */ }
Метод into_iter приходит из второго трейта, IntoIterator: «из меня можно получить итератор». Им владеет не только сама коллекция, но и ссылки на неё, поэтому у for три режима. Для Vec<String>:
| Запись | Эквивалент | Item | Коллекция после цикла |
|---|---|---|---|
for s in v | v.into_iter() | String | поглощена, v больше нет |
for s in &v | v.iter() | &String | жива, только читали |
for s in &mut v | v.iter_mut() | &mut String | жива, элементы изменены |
Узнаёшь тройку? Это режимы доступа из уроков про владение и заимствование: забрать, читать, менять. Итераторы не придумали новых правил, они переложили старые на проход по коллекции. Отсюда и классическая ошибка новичка: прогнал for s in v и удивился, что v дальше не компилируется. Цикл не «прошёлся по вектору», он его съел.
Адаптеры: собрать машину, не запуская
Теперь самое непривычное. Напишем цепочку и оборвём её до потребителя:
let pipeline = scores.iter().map(|x| {
println!("считаю {x}");
x * x
});
// warning: unused `Map` that must be used
// note: iterators are lazy and do nothing unless consumed
Ни одной строчки «считаю» в выводе. Компилятор прямым текстом предупреждает: итераторы ленивы и не делают ничего, пока их не потребят. Что же тогда лежит в pipeline? Структура. Каждый адаптер берёт предыдущий итератор по значению, заворачивает его в себя вместе с замыканием и возвращает новый тип:
let chain = scores.iter() // Iter<'_, i64>
.map(|x| x * x) // Map<Iter<'_, i64>, {замыкание}>
.filter(|x| x % 2 == 1); // Filter<Map<Iter<'_, i64>, ...>, ...>
Матрёшка из типов, где next внешнего слоя дёргает next внутреннего и применяет своё замыкание. Потребитель, скажем sum, крутит next верхнего слоя до None, и каждый элемент проходит всю трубу за один заход: взяли 7, возвели в квадрат, проверили нечётность, прибавили, перешли к следующему. Сравни с JS, где каждый .map() материализует новый массив целиком: здесь промежуточных коллекций нет вовсе, какой бы длинной цепочка ни была.
Зоопарк адаптеров большой, держи рабочий минимум:
| Адаптер | Что делает |
|---|---|
map(f) | преобразует каждый элемент |
filter(p) | пропускает только подходящие |
take(n) · skip(n) | первые n · всё после первых n |
enumerate() | подклеивает индекс: (0, a), (1, b), ... |
zip(other) | идёт по двум лентам парами, кончается с короткой |
chain(other) | вторая лента после первой |
flat_map(f) | map, где f выдаёт итератор, и всё разглаживается |
scan(init, f) | map с состоянием между шагами |
И потребители, те, кто запускает машину: collect собирает в коллекцию, sum, count, max, min сворачивают в число, any и all отвечают на вопрос и останавливаются на первом же решающем элементе, find и position ищут. Общий предок их всех, fold:
let total: i64 = scores.iter().fold(0, |acc, x| acc + x); // то же, что .sum()
Аккумулятор из цикла в начале урока никуда не делся, он переехал в аргумент.
Отдельно про collect: он полиморфен по цели, и компилятору нужно её знать. Либо из аннотации, либо через turbofish:
let squares: Vec<i64> = scores.iter().map(|x| x * x).collect();
let squares = scores.iter().map(|x| x * x).collect::<Vec<i64>>(); // то же самое
Цель не обязана быть Vec: String из символов, HashMap из пар, а ещё collect умеет выворачивать ошибки. Лента из Result собирается в Result от ленты, первый Err обрывает работу:
let parsed: Result<Vec<i32>, _> = ["1", "2", "x"].iter()
.map(|s| s.parse::<i32>())
.collect();
assert!(parsed.is_err()); // на «x» всё закончилось
Это тот же конвейер ошибок, что в уроке про обработку ошибок, только без единого ?.
Замыкания: Fn, FnMut, FnOnce
Каждому адаптеру мы передавали |x| ..., пора разобраться, что это такое. Замыкание в Rust это анонимная структура, в полях которой лежит захваченное окружение:
let limit = 10;
let over_limit = |x: i64| x > limit;
Компилятор сгенерирует примерно такое:
struct OverLimit<'a> {
limit: &'a i64, // захваченная переменная
}
// плюс реализация вызова: Fn(i64) -> bool
Никакой кучи, никаких скрытых аллокаций: структура с одним полем-ссылкой. А «реализация вызова» это один из трёх трейтов, и выбор между ними решает знакомая тройка режимов доступа. Как замыкание обращается с окружением, такой трейт оно и получает:
| Трейт | Захват | Сколько раз можно вызвать |
|---|---|---|
Fn | читает окружение (&) | сколько угодно |
FnMut | меняет окружение (&mut) | сколько угодно, но нужен mut |
FnOnce | поглощает окружение | ровно один |
Иерархия вложенная: всякое Fn годится туда, где ждут FnMut, всякое FnMut туда, где ждут FnOnce. Читающее замыкание принимают везде, поглощающее только там, где готовы к единственному вызову. Посмотри на все три вживую:
let limit = 10;
let over = |x: i64| x > limit; // Fn: только читает limit
let mut count = 0;
let mut tick = || { count += 1; }; // FnMut: меняет count, переменная замыкания тоже mut
let name = String::from("api");
let take_name = move || name; // FnOnce: отдаёт name наружу
let n = take_name();
// take_name(); // ❌ use of moved value: take_name
Слово move заставляет замыкание захватить переменные владением, даже когда хватило бы ссылки. Зачем: чтобы замыкание могло пережить функцию, в которой родилось, например уехать в возвращаемое значение или в другой поток (это пригодится в блоке про многопоточность). Заметь: move отвечает за способ захвата, а не за трейт; move-замыкание, которое только читает, остаётся Fn.
Теперь понятны сигнатуры адаптеров. map объявлен как F: FnMut(Self::Item) -> B: он зовёт замыкание много раз, значит, одноразовое FnOnce не подойдёт. Вот как это ломается:
let banner = String::from("итог: ");
let labels: Vec<String> = scores.iter()
.map(move |x| banner + &x.to_string()) // ❌ banner поглощается первым же вызовом
.collect();
// error: cannot move out of `banner`, a captured variable in an `FnMut` closure
Сложение String + &str забирает левый операнд по значению, ты видел это в уроке про строки. Значит, первый вызов замыкания съест banner, второй звать уже нечего: замыкание получилось FnOnce, а map требует FnMut. Лечится тем, чтобы не отдавать владение: format!("итог: {x}") внутри замыкания читает, а не поглощает.
Свой итератор
Обещанный момент: пишем участника конвейера руками. Возьмём задачу из жизни сервисов: повторные попытки с экспоненциальной задержкой. Первая через 100 мс, дальше вдвое больше, и так до потолка:
struct Backoff {
current: u64,
cap: u64,
}
impl Backoff {
fn new(start: u64, cap: u64) -> Self {
Backoff { current: start, cap }
}
}
impl Iterator for Backoff {
type Item = u64;
fn next(&mut self) -> Option<u64> {
if self.current > self.cap {
return None;
}
let delay = self.current;
self.current *= 2;
Some(delay)
}
}
Структура с состоянием, один метод, и всё. Никакой регистрации, никаких базовых классов: реализовал трейт, и твой тип полноправный гражданин экосистемы. Все семь десятков методов по умолчанию уже работают, и for тоже, потому что каждый Iterator автоматически получает IntoIterator через blanket impl из урока про трейты:
let delays: Vec<u64> = Backoff::new(100, 3200).collect();
// [100, 200, 400, 800, 1600, 3200]
let budget: u64 = Backoff::new(100, 3200).take(3).sum(); // 700: первые три попытки
for delay in Backoff::new(100, 3200) {
println!("ждём {delay} мс");
}
Убери проверку cap, и итератор станет бесконечным. В мире ленивых машин это законно: диапазон (0..) из стандартной библиотеки ровно такой, и цепочка (0..).map(|x| x * 2).take(5) спокойно отщипывает пять элементов от бесконечности. Падает не бесконечный итератор, падает потребитель без take.
Последний штрих: как вернуть итератор из функции. Тип матрёшки Filter<Map<...>> с замыканием внутри даже не имеет имени, которое можно написать руками. Спасает impl Trait из урока про дженерики:
fn evens() -> impl Iterator<Item = u64> {
(0..).map(|x| x * 2)
}
Вызывающий получает ленивую машину и сам решает, сколько из неё забрать. А если ветки функции собирают разные цепочки, вспоминай прошлый урок: Box<dyn Iterator<Item = u64>> стирает тип за vtable, по цене виртуального вызова на каждый next.
Цена против цикла
Главное возражение против цепочек звучит так: «красиво, но в проде я напишу цикл, он быстрее». Проверим. Две функции:
pub fn sum_squares_loop(nums: &[i64]) -> i64 {
let mut total = 0;
for i in 0..nums.len() {
total += nums[i] * nums[i];
}
total
}
pub fn sum_squares_iter(nums: &[i64]) -> i64 {
nums.iter().map(|x| x * x).sum()
}
Скорми обе godbolt с флагом -O или прогони cargo asm: машинный код итераторной версии не хуже, обе сворачиваются в один векторизованный цикл. Причина тебе уже известна: мономорфизация из урока про дженерики. Вся матрёшка Map<Iter<...>> со своим замыканием это конкретные типы, известные компилятору насквозь; каждый next инлайнится, обёртки испаряются, и LLVM оптимизирует результат как обычный цикл.
Больше того, ручной цикл с индексами иногда медленнее. nums[i] обязан проверять границы массива на каждой итерации, помнишь панику по выходу за границы из урока про коллекции? Итератор по построению не выходит за границы, и проверки исчезают из горячего пути вместе с самой возможностью ошибки на единицу. Та самая «нулевая абстракция»: ты платишь за удобство на этапе компиляции, не в рантайме. Как это выглядит на уровне ассемблера, разберём в уроке про машинный код, в блоке про биты и машину.
Честная оговорка, чтобы картинка не была глянцевой: гарантия касается типичных цепочек. Очень длинные конвейеры с хитрыми замыканиями изредка упираются в пределы инлайнинга, а Box<dyn Iterator> честно платит виртуальным вызовом за каждый элемент. Правило то же, что и в прошлом уроке: пиши ясно, измеряй, и только потом оптимизируй.
Практика
Четыре задачи на весь сюжет урока: от чтения готовых адаптеров до собственных типов, которые умеют в for. Первая разминает цепочки, дальше пишешь next руками, возвращаешь impl Iterator из функций и заканчиваешь двойной реализацией IntoIterator.
ДЗ
Дальше
Блок про типы и трейты закрыт: владение, дженерики, две диспетчеризации и итераторы как трейт в действии. Код ты теперь пишешь идиоматичный, но он пока живёт в одном файле. В уроке 14 займёмся тем, как растущий проект раскладывается по модулям и файлам: дерево модулей, пути, pub и его градации, и почему в Rust граница публичного API проектируется, а не получается сама собой.