$ sudo teach IT
Модуль 5 · Коллекции · Урок 5.2

Итераторы и замыкания

Вы научитесь считать, отбирать и преобразовывать траты одной цепочкой вызовов вместо цикла со счётчиком.

Теория + практика~25 минутСредний

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

Замыкание: функция без имени

Иногда функции нужно передать совсем маленькую инструкцию: «умножь на два» или «возьми, если больше 500». Заводить ради этого отдельную fn с именем и типами — всё равно что писать официальное письмо ради одной фразы. Удобнее клейкая записка.

В Rust такая записка называется замыканием (closure). Это функция без имени, записанная на месте. Параметры стоят между двумя вертикальными чертами, после них — выражение, которое и есть результат: |x| x * 2 читается как «дай x, получишь x умножить на два». Типы параметров обычно не пишут: компилятор сам выводит их из того, как замыкание используют.

Rust · main.rs
fn main() {
    let double = |x| x * 2;
    println!("{}", double(21));

    let min = 500;
    let is_big = |amount| amount > min;
    println!("{}", is_big(1200));
    println!("{}", is_big(99));
}

Разбор по строкам

  • let double = |x| x * 2; — замыкание кладём в переменную, как любое значение. x — параметр, x * 2 — результат; return и фигурные скобки не нужны.
  • double(21) — вызываем так же, как обычную функцию: имя переменной и скобки. Напечатается 42.
  • let min = 500; — обычная переменная снаружи замыкания.
  • |amount| amount > min — замыкание использует min, хотя это не его параметр. Результат — true или false.
  • is_big(1200) и is_big(99) — напечатают true и false.

Обратите внимание на последние пункты разбора: замыкание «видит» переменные вокруг себя. Это называют захватом (capture). Обычная функция fn так не умеет: ей всё нужное приходится передавать параметрами. Работает это как заимствование: замыкание заглядывает в min, и пока оно живо, менять min нельзя.

Итератор: конвейер, который стоит, пока его не включили

Представьте фабричный конвейер: на ленте детали, вдоль неё станки — один красит, другой отбраковывает. Пока рубильник не включён, лента стоит и ни одна деталь не тронута. После включения детали едут по одной, и в конце вы получаете ящик готовых деталей или просто их число.

Именно так устроены итераторы (iterator) в Rust. Итератор — значение, которое умеет выдавать элементы коллекции по одному. Получить его у вектора, массива или среза можно методом iter(): он выдаёт ссылки на элементы, так что вектор остаётся у владельца и после обработки никуда не пропадает. К итератору подключают станки двух видов:

  • адаптеры (adapter) превращают один итератор в другой: map преобразует элементы, filter отбирает нужные. Сами они работу не делают — только достраивают конвейер;
  • потребители (consumer) включают рубильник и забирают результат: sum, count, max, collect.

Про итераторы говорят, что они ленивые: без потребителя в конце ничего не происходит. Компилятор об этом знает и ругается, если вы построили конвейер и забыли его включить:

Rust · main.rs
fn main() {
    let amounts = [350, 1200, 99];
    amounts.iter().map(|a| a * 2);
}
Ошибка компилятора
warning: unused `Map` that must be used
 --> src/main.rs:3:5
  |
3 |     amounts.iter().map(|a| a * 2);
  |     ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
  |
  = note: iterators are lazy and do nothing unless consumed

Это предупреждение (warning), а не ошибка: программа соберётся, но ничего не посчитает.

map, filter, sum, count: основной набор

Начнём с главного набора. Пусть expenses — вектор трат, как в проекте. Сумму всех трат считаем так:

Rust · main.rs
let spent: u32 = expenses.iter().map(|e| e.amount).sum();

Читайте цепочку слева направо как рецепт: «возьми траты по одной → из каждой выбери сумму → сложи». Метод map применяет замыкание к каждому элементу, filter пропускает дальше только элементы с ответом true, sum складывает, count считает, сколько доехало до конца.

Rust · main.rs
let big_count = expenses.iter().filter(|e| e.amount >= 1000).count();

let food = expenses
    .iter()
    .filter(|e| e.category == Category::Food)
    .map(|e| e.amount)
    .sum::<u32>();

Разбор по строкам

  • filter(|e| e.amount >= 1000) — оставляем траты от тысячи рублей; e — одна трата, до поля amount добираемся через точку, как всегда.
  • .count() — потребитель: возвращает число оставшихся трат (тип usize).
  • Вторая цепочка записана в столбик, по звену на строку: так её читать легче, cargo fmt делает так же.
  • filter(|e| e.category == Category::Food) — отбираем траты на еду. Замыкание отвечает «да» для трат на еду и «нет» для остальных.
  • .map(|e| e.amount) — каждую оставшуюся трату заменяем её суммой.
  • .sum::<u32>() — складываем суммы; запись ::<u32> объяснена ниже.

Зачем в sum::<u32>() нужен тип

Метод sum умеет складывать в разные типы: в u32, в i64, в f64. Из самой цепочки Rust не угадает, какой вам нужен, поэтому тип нужно назвать. Есть два способа: написать его у переменной (let spent: u32 = ...sum();) или после имени метода (sum::<u32>()). Вторая запись неофициально зовётся «турборыбой» (turbofish) и знакома вам по parse::<u32>().

Двойная ссылка в filter

Со структурами вроде нашей траты всё гладко, потому что точка в e.amount сама проходит сквозь ссылки. Но если фильтровать обычные числа, вас ждёт сюрприз. Метод iter() выдаёт &u32 (ссылку на число), а filter показывает замыканию ещё одну ссылку на этот элемент — получается &&u32. Сравнить такое с числом напрямую нельзя:

Rust · main.rs
let amounts: [u32; 3] = [350, 1200, 99];
let big = amounts.iter().filter(|x| x > 500).count();
Ошибка компилятора
error[E0308]: mismatched types
 --> src/main.rs:3:45
  |
3 |     let big = amounts.iter().filter(|x| x > 500).count();
  |                                             ^^^ expected `&&u32`, found integer

Компилятор говорит: «ждал &&u32, а получил обычное число». Лечится двумя способами, выбирайте любой:

Rust · main.rs
let a = amounts.iter().filter(|x| **x > 500).count();
let b = amounts.iter().filter(|&&x| x > 500).count();

Разбор по строкам

  • **x — звёздочка означает «загляни внутрь ссылки». Ссылки две, поэтому и звёздочек две: после них остаётся само число.
  • |&&x| — то же самое, но распаковка делается прямо в параметре: «пришла ссылка на ссылку, а мне нужно только число, назову его x». Дальше в теле можно писать просто x > 500.

collect, max, any, all: ещё четыре потребителя

Ещё три потребителя закрывают почти всё остальное: collect собирает результат обратно в коллекцию, max находит наибольший элемент, а вопросы «да/нет» задают any («есть ли хотя бы один подходящий?») и all («подходят ли все?»).

Rust · main.rs
let amounts: Vec<u32> = expenses.iter().map(|e| e.amount).collect();

let biggest = expenses.iter().map(|e| e.amount).max();
match biggest {
    Some(n) => println!("Самая большая трата: {} руб.", n),
    None => println!("Трат пока нет"),
}

let has_food = expenses.iter().any(|e| e.category == Category::Food);
let all_small = expenses.iter().all(|e| e.amount < 5000);

Разбор по строкам

  • .collect() — собирает суммы в новый вектор. Собирать можно в разное, поэтому, как и с sum, тип нужно назвать: слева у переменной (Vec<u32>) или так: collect::<Vec<_>>(). Подчёркивание значит «что внутри, догадайся сам».
  • .max() — возвращает не число, а Option: если трат нет, наибольшего элемента тоже нет, и вернётся None. Поэтому дальше идёт match. Есть и min().
  • Перед max идёт map к суммам: числа Rust сравнивать умеет, а целые траты — пока нет.
  • .any(...) и .all(...) — возвращают bool. Замыкание отвечает на вопрос про одну трату; потребитель останавливается, как только ответ ясен.

Пустой вектор

Для пустого списка any вернёт false (подходящих нет), а all — true (нарушителей тоже нет).

Когда цикл всё же понятнее

Цепочка уместна, когда задача читается как рецепт «возьми, отбери, преобразуй, сверни в одно». Цикл for понятнее, когда:

  • внутри нужно что-то печатать, менять несколько переменных или выходить посередине по сложному условию;
  • замыкание разрослось до пяти-шести строк или цепочку трудно прочитать вслух.

По скорости для наших задач разницы нет, так что выбирайте то, что легче прочитать через месяц. Сомневаетесь — пишите цикл: заменить его цепочкой позже просто.

Практика: траты считаются цепочкой

Возвращаемся в проект kopilka. Мы добавим функции total и total_in на итераторах и научим команду отчёт печатать итог со статусом. Остальной main.rs остаётся как после прошлого урока.

Шаг 1. Чтобы сравнивать категории через ==, перечислению нужно умение «сравниваться на равенство». Оно у вас уже есть: derive над enum Category остался с модуля про свои типы. Проверьте, что строка выглядит так (если derive шире, оставьте его, главное, чтобы был PartialEq):

Rust · main.rs
#[derive(Debug, Clone, Copy, PartialEq)]
enum Category {
    Food,
    Transport,
    Home,
    Fun,
    Other,
}

PartialEq разрешает писать == и !=, а Clone и Copy делают категорию «лёгкой», как число: её можно спокойно передавать по значению. В следующем уроке мы допишем сюда ещё два умения.

Шаг 2. Добавьте обе функции над fn main(), рядом с print_list (положение над или под main не важно, но лучше выше):

Rust · main.rs
fn total(expenses: &[Expense]) -> u32 {
    expenses.iter().map(|e| e.amount).sum()
}

fn total_in(expenses: &[Expense], category: Category) -> u32 {
    expenses
        .iter()
        .filter(|e| e.category == category)
        .map(|e| e.amount)
        .sum()
}

Разбор по строкам

  • expenses: &[Expense] — срез: функция только читает траты; вызываем с &expenses.
  • -> u32 и отсутствие точки с запятой в конце — цепочка сама является результатом функции. Тип суммы здесь выводится из -> u32, поэтому sum::<u32>() писать не нужно.
  • category: Category — параметр, который замыкание в filter захватывает.

Шаг 3. Сейчас команду отчёт ловит заглушка Some(_) и отвечает «Скоро научимся». В match в main добавьте для неё ветку выше заглушки, рядом с Some(Command::List):

Rust · main.rs
Some(Command::Report) => {
    let spent = total(&expenses);
    println!("Всего: {} руб. из {} руб.", spent, MONTH_LIMIT);
    print_status(spent, MONTH_LIMIT);
    println!("Из них еда: {} руб.", total_in(&expenses, Category::Food));
}

Внутри ветки let spent заводит новую переменную только для этого блока: старый счётчик spent из main остаётся нетронутым и по-прежнему растёт в ветке Add. Два источника одной суммы — это временно: в последнем уроке модуля счётчик уйдёт, а total будет считать всё по вектору.

Последняя строка (Из них еда) нужна только для проверки total_in: в следующем уроке мы заменим её отчётом по всем категориям сразу.

Шаг 4. Теперь в match обработаны все пять вариантов Command: Add, List, Report, Help и Quit. Заглушка Some(_) больше никогда не сработает, и компилятор об этом предупредит: warning: unreachable pattern. Удалите строку Some(_) => println!("Скоро научимся"),: ветка None остаётся для непонятных команд.

Запустите проект (предупреждений быть не должно) и введите несколько команд:

Терминал
cargo run
Текст · ввод
добавить 350 еда обед в столовой
добавить 1200 транспорт проездной
добавить 99 еда булка
отчёт
выход

Должно получиться:

Вывод
Записано: 350 руб., еда, «обед в столовой»
Потрачено: 350 руб. из 30000 руб.
Статус: в норме
Записано: 1200 руб., транспорт, «проездной»
Потрачено: 1550 руб. из 30000 руб.
Статус: в норме
Записано: 99 руб., еда, «булка»
Потрачено: 1649 руб. из 30000 руб.
Статус: в норме
Всего: 1649 руб. из 30000 руб.
Статус: в норме
Из них еда: 449 руб.
Итого потрачено: 1649 руб. из 30000 руб.

Проверьте себя.

  • Сумма 1649 получилась из 350 + 1200 + 99, а «Из них еда» — 449: это 350 + 99. Если у вас другие числа, перепроверьте условие в filter.
  • Добавьте ещё одну трату на еду и снова запустите отчёт: обе суммы должны вырасти.
  • Временно уберите PartialEq из derive и соберите проект. Какую ошибку выдаёт компилятор и в какой строке? Верните умение обратно.
  • Замените .sum() в total на .sum::<u32>(): всё ли работает? Почему оба варианта допустимы?

Частые ошибки

Забыли тип у sum

Что видно: ошибка error[E0283]: type annotations needed на строке с let s = amounts.iter().sum();.

Почему: sum умеет складывать в разные типы, а какой нужен, не сказано.

Как исправить: назовите тип: let s: u32 = ...sum(); или sum::<u32>().

Цепочка без потребителя

Что видно: warning: unused `Map` that must be used, а результата нет.

Почему: итераторы ленивые.

Как исправить: допишите потребитель (sum, count, collect, max) или пройдите по итератору циклом for.

Сравнение с двойной ссылкой в filter

Что видно: error[E0308]: mismatched types с текстом expected `&&u32`, found integer.

Почему: элемент приходит как &&u32.

Как исправить: |x| **x > 500 или |&&x| x > 500. С полями структуры (e.amount) звёздочки не нужны.

max() принят за число

Что видно: error[E0308]: mismatched types с текстом expected `u32`, found `Option<u32>`.

Почему: max возвращает Option.

Как исправить: разберите результат через match или if let; если уверены, что пустым список быть не может, unwrap_or(0) тоже подойдёт.

Что важно запомнить

  • Замыкание — безымянная функция: |x| x * 2. Параметры между чертами, типы обычно выводятся сами.
  • Замыкание «захватывает» переменные вокруг: |e| e.amount > min использует внешнюю min.
  • Итераторы ленивые: iter(), map и filter только строят конвейер, работу делает потребитель в конце.
  • Потребители: sum, count, max (возвращает Option), collect, any, all. У sum и collect нужно назвать тип: у переменной или через ::<Тип>.
  • В filter замыкание получает ссылку на элемент от iter(), то есть для чисел &&u32: пишите **x или |&&x|.
  • Если цепочка стала длинной или внутри нужны побочные действия, обычный цикл for понятнее.

Итог шага: total и total_in в «Копилке» работают на итераторах. В следующем уроке соберём суммы по всем категориям сразу в словаре HashMap.

На следующий урок