В прошлом уроке траты поселились в векторе, а общую сумму по-прежнему копит отдельная переменная spent: прибавляем при каждой записи. Это работает, но стоит спросить «сколько ушло только на еду?» или «какая трата самая большая?» — и придётся писать новый цикл, каждый раз со своей переменной-счётчиком. В этом уроке вы научитесь собирать такие вычисления из готовых деталей, как из конструктора.
Замыкание: функция без имени
Иногда функции нужно передать совсем маленькую инструкцию: «умножь на два» или «возьми, если больше 500». Заводить ради этого отдельную fn с именем и типами — всё равно что писать официальное письмо ради одной фразы. Удобнее клейкая записка.
В Rust такая записка называется замыканием (closure). Это функция без имени, записанная на месте. Параметры стоят между двумя вертикальными чертами, после них — выражение, которое и есть результат: |x| x * 2 читается как «дай x, получишь x умножить на два». Типы параметров обычно не пишут: компилятор сам выводит их из того, как замыкание используют.
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.
Про итераторы говорят, что они ленивые: без потребителя в конце ничего не происходит. Компилятор об этом знает и ругается, если вы построили конвейер и забыли его включить:
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 — вектор трат, как в проекте. Сумму всех трат считаем так:
let spent: u32 = expenses.iter().map(|e| e.amount).sum();
Читайте цепочку слева направо как рецепт: «возьми траты по одной → из каждой выбери сумму → сложи». Метод map применяет замыкание к каждому элементу, filter пропускает дальше только элементы с ответом true, sum складывает, count считает, сколько доехало до конца.
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. Сравнить такое с числом напрямую нельзя:
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, а получил обычное число». Лечится двумя способами, выбирайте любой:
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 («подходят ли все?»).
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):
#[derive(Debug, Clone, Copy, PartialEq)]
enum Category {
Food,
Transport,
Home,
Fun,
Other,
}
PartialEq разрешает писать == и !=, а Clone и Copy делают категорию «лёгкой», как число: её можно спокойно передавать по значению. В следующем уроке мы допишем сюда ещё два умения.
Шаг 2. Добавьте обе функции над fn main(), рядом с print_list (положение над или под main не важно, но лучше выше):
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):
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.