Замикання: Анонімні Функції, що Захоплюють Своє Середовище
У Rust замикання - це анонімні функції, які можна зберігати в змінній або передавати як аргументи до інших функцій. Ви можете створити замикання в одному місці, а потім викликати деінде для обчислення в іншому контексті. На відміну від функції, замикання здатні використовувати значення з області видимості в якій вони були визначені. Ми продемонструємо, як наявність замикань дозволяє повторно використовувати код та змінювати поведінку програми.
Захоплення Середовища з Замиканнями
Спочатку ми розглянемо, як можна використовувати замикання для фіксації значень середовища, в якому вони визначені, для подальшого використання. Ось сценарій: Час від часу, наша компанія по виробництву футболок роздає ексклюзивну футболку, випущену ексклюзивним тиражем, комусь із нашого списку розсилки як рекламу. Люди зі списку розсилки можуть за бажанням додати свій улюблений колір до свого профілю. Якщо людина, якій надіслали безплатну футболку, обрала свій улюблений колір, вона отримає футболку такого ж кольору. Якщо людина не зазначила свій улюблений колір, то вона отримає футболку такого кольору, якого в компанії найбільше всього.
Існує багато способів це реалізувати. Для цього прикладу, ми використаємо енум ShirtColor
, який складається з варіантів Red
та Blue
(обмежимо кількість доступних кольорів для простоти). Ми представлятимемо товарні запаси компанії за допомогою структури Inventory
, яка має поле, що зветься shirts
, яке містить Vec<ShirtColor>
, що представляє кольори наявних на складі футболок. Метод giveaway
, визначений для Inventory
, отримує опціональний бажаний колір футболки для вручення переможцю та повертає колір футболки, яку цей переможець отримає. Ця ситуація показана в Блоці коду 13-1:
Файл: src/main.rs
#[derive(Debug, PartialEq, Copy, Clone)]
enum ShirtColor {
Red,
Blue,
}
struct Inventory {
shirts: Vec<ShirtColor>,
}
impl Inventory {
fn giveaway(&self, user_preference: Option<ShirtColor>) -> ShirtColor {
user_preference.unwrap_or_else(|| self.most_stocked())
}
fn most_stocked(&self) -> ShirtColor {
let mut num_red = 0;
let mut num_blue = 0;
for color in &self.shirts {
match color {
ShirtColor::Red => num_red += 1,
ShirtColor::Blue => num_blue += 1,
}
}
if num_red > num_blue {
ShirtColor::Red
} else {
ShirtColor::Blue
}
}
}
fn main() {
let store = Inventory {
shirts: vec![ShirtColor::Blue, ShirtColor::Red, ShirtColor::Blue],
};
let user_pref1 = Some(ShirtColor::Red);
let giveaway1 = store.giveaway(user_pref1);
println!(
"The user with preference {:?} gets {:?}",
user_pref1, giveaway1
);
let user_pref2 = None;
let giveaway2 = store.giveaway(user_pref2);
println!(
"The user with preference {:?} gets {:?}",
user_pref2, giveaway2
);
}
Змінна store
, визначена в main
, містить дві сині футболки і одну червону футболку, які лишилися для роздачі у рекламній акції. Ми викликаємо метод giveaway
для користувача, що віддає перевагу червоній футолці, і для користувача, що не має особливих побажань.
Знову ж таки, цей код може бути реалізований багатьма способами, і тут, щоб сфокусуватися на замиканнях, ми дотримуватимемося концепцій, які ви вже вивчили, окрім тіла методу giveaway
, який використовує замикання. У методі giveaway
ми отримуємо параметром побажання типу Option<ShirtColor>
і викликаємо на user_preference
метод unwrap_or_else
. Метод unwrap_or_else
для Option<T>
визначений у стандартній бібліотеці. Він приймає один аргумент: замикання без аргументів, що повертає значення типу T
(того ж типу, що міститься у варіанті Some
Option<T>
, у цьому випадку ShirtColor
). Якщо Option<T>
є варіантом Some
, unwrap_or_else
поверне значення, що міситься у Some
. Якщо ж Option<T>
є варіантом None
, unwrap_or_else
викликає замикання і повертає значення, повернене з замикання.
Ми зазначаємо вираз замикання || self.most_stocked()
аргументом unwrap_or_else
. Це замикання не приймає параметрів (якби замикання мало параметри, вони б з'явилися між вертикальними лініями). Тіло замикання викликає self.most_stocked()
. Тут ми визначаємо замикання, і реалізація unwrap_or_else
обчислить це замикання пізніше, якщо знадобиться його результат.
Виконавши цей код, в консолі виведеться:
$ cargo run
Compiling shirt-company v0.1.0 (file:///projects/shirt-company)
Finished dev [unoptimized + debuginfo] target(s) in 0.27s
Running `target/debug/shirt-company`
The user with preference Some(Red) gets Red
The user with preference None gets Blue
Тут один цікавий момент полягає в тому, що ми вже передали замикання, яке викликає self.most_stocked()
для поточного екземпляра Inventory
. Стандартній бібліотеці непотрібно нічого знати про типи Inventory
або ShirtColor
, які ми визначили, або про логіку, яку ми бажаємо використати у даному сценарії. Замикання захоплює немутабельне посилання на езкемпляр Inventory
self
і передає його з написаним нами кодом у метод unwrap_or_else
. Функції, з іншого боку, не можуть захоплювати своє середовище у такий спосіб.
Виведення та Анотація Типу Замикання
Між функціями та замиканнями існує більше відмінностей. Замикання зазвичай не потребують анотації типів параметрів чи типу, який вони повертають, на відміну від функцій fn
. Анотації типів потрібні функціям, бо типи є частиною явного інтерфейсу, відкритого вашим користувачам. Жорстке визначення інтерфейсу важливе для забезпечення того, щоб всі погоджувались з тим, які значення функція приймає та повертає. Замикання, з іншого боку, не використовуються у подібному відкритому інтерфейсі: вони зберігаються у змінних і використовуються без назв і відкривання їх користувачам ваших бібліотек.
Замикання зазвичай короткі та актуальні тільки у конкретному контексті, а не в будь-якому довільному сценарії. У цих обмежених контекстах компілятор може вивести типи параметрів і типу, що повертається, так само як може вивести типи більшості змінних (трапляються рідкісні випадки, коли компілятор потребує також анотації типів замикань).
Як і зі змінними, ми можемо за бажання додати анотації типів, коли хочемо збільшити виразність і ясність ціною більшої багатослівності, ніж потрібно. Анотування типів для замикання виглядатиме як визначення, наведене у Блоці коду 13-2. У цьому прикладі ми визначаємо замикання і зберігаємо його у змінній замість визначення замикання у місці, де ми передаємо його як аргумент, як ми робили у Блоці коду 13-1.
Файл: src/main.rs
use std::thread; use std::time::Duration; fn generate_workout(intensity: u32, random_number: u32) { let expensive_closure = |num: u32| -> u32 { println!("calculating slowly..."); thread::sleep(Duration::from_secs(2)); num }; if intensity < 25 { println!("Today, do {} pushups!", expensive_closure(intensity)); println!("Next, do {} situps!", expensive_closure(intensity)); } else { if random_number == 3 { println!("Take a break today! Remember to stay hydrated!"); } else { println!( "Today, run for {} minutes!", expensive_closure(intensity) ); } } } fn main() { let simulated_user_specified_value = 10; let simulated_random_number = 7; generate_workout(simulated_user_specified_value, simulated_random_number); }
Із анотаціями типів синтаксис замикань виглядає більш схожим на синтаксис функцій. Тут ми визначаємо функцію, що додає 1 до свого параметра і замикання, що має таку саму поведінку, для порівняння. Ми додали кілька пробілів для вирівнювання відповідних частин. Це ілюструє, чим синтаксис замикань подібний до синтаксису функцій, за виключенням використання вертикальних ліній і обсягу необов'язкового синтаксису:
fn add_one_v1 (x: u32) -> u32 { x + 1 }
let add_one_v2 = |x: u32| -> u32 { x + 1 };
let add_one_v3 = |x| { x + 1 };
let add_one_v4 = |x| x + 1 ;
У першому рядку визначення функції, а в другому анотоване визначення замикання. На третьому рядку ми прибираємо анотацію типу з визначення замикання. На четвертому рядку ми прибираємо дужки, які є опціональними через те, що замикання містить в собі тільки один вираз. Усе це є коректними визначеннями, які будуть демонструвати під час їх виклику одну й ту саму поведінку. Рядки add_one_v3
та add_one_v4
вимагають, щоб замикання викликали для компіляції, бо типи будуть виведені з того, як їх використовують. Це схоже на те, як let v = Vec::new();
потребує або анотацію типів, або додати значення певного типу у Vec
, щоб Rust міг вивести тип.
Для визначень замикань компілятор виведе один конкретний тип для кожного параметра і для значення, що повертається. Наприклад, у Блоці коду 13-3 показано визначення замикання, що повертає значення, переданого йому як параметр. Це замикання не дуже корисне, окрім як для цього прикладу. Зауважте, що ми не додавали анотації типів до визначення. Оскільки тут немає анотації типів, ми можемо викликати замикання для будь-якого типу, що ми тут вперше і зробили з String
. Якщо ми потім спробуємо викликати example_closure
з цілим параметром, то дістанемо помилку.
Файл: src/main.rs
fn main() {
let example_closure = |x| x;
let s = example_closure(String::from("hello"));
let n = example_closure(5);
}
Компілятор повідомляє про таку помилку:
$ cargo run
Compiling closure-example v0.1.0 (file:///projects/closure-example)
error[E0308]: mismatched types
--> src/main.rs:5:29
|
5 | let n = example_closure(5);
| --------------- ^- help: try using a conversion method: `.to_string()`
| | |
| | expected struct `String`, found integer
| arguments to this function are incorrect
|
note: closure parameter defined here
--> src/main.rs:2:28
|
2 | let example_closure = |x| x;
| ^
For more information about this error, try `rustc --explain E0308`.
error: could not compile `closure-example` due to previous error
Коли ми уперше викликали example_closure
зі значенням String
, компілятор вивів, що тип x
і тип, що повертається із замикання, як String
. Ці типи були зафіксовані для замикання example_closure
, і ми отримаємо помилку типу, коли ще раз намагаємося використати інший тип для цього ж замикання.
Захоплення Посилань чи Передання Володіння
Замикання можуть захоплювати значення зі свого середовища у три способи, що прямо відповідають трьом способам передачі параметра у функцію: немутабельне позичання, мутабельне позичання і взяття володіння. Замикання вирішує, яким способом скористатися, виходячи з того, що тіло функції робить із захопленими значеннями.
У Блоці коду 13-4 ми визначаємо замикання, яке захоплює немутабельне посилання на вектор з назвою list
, тому що йому потрібно лише немутабельне посилання для виведення значення:
Файл: src/main.rs
fn main() { let list = vec![1, 2, 3]; println!("Before defining closure: {:?}", list); let only_borrows = || println!("From closure: {:?}", list); println!("Before calling closure: {:?}", list); only_borrows(); println!("After calling closure: {:?}", list); }
Цей приклад також ілюструє, що змінна може бути зв'язана з визначенням замикання, і ми можемо пізніше викликати замикання, використовуючи назву змінної та дужки та, якби назва змінної була назвою функції.
Оскільки ми можемо мати одночасно декілька немутабельних посилань на list
, до нього можливий доступ до визначення замикання, після визначення, але до виклику замикання і після виклику замикання. Цей код компілюється, виконується і виводить:
$ cargo run
Compiling closure-example v0.1.0 (file:///projects/closure-example)
Finished dev [unoptimized + debuginfo] target(s) in 0.43s
Running `target/debug/closure-example`
Before defining closure: [1, 2, 3]
Before calling closure: [1, 2, 3]
From closure: [1, 2, 3]
After calling closure: [1, 2, 3]
Далі в Блоці коду 13-5 ми змінюємо тіло замикання, щоб воно додавало елемент до вектора list
. Це замикання тепер захоплює мутабельне посилання:
Файл: src/main.rs
fn main() { let mut list = vec![1, 2, 3]; println!("Before defining closure: {:?}", list); let mut borrows_mutably = || list.push(7); borrows_mutably(); println!("After calling closure: {:?}", list); }
Цей код компілюється, виконується і виводить:
$ cargo run
Compiling closure-example v0.1.0 (file:///projects/closure-example)
Finished dev [unoptimized + debuginfo] target(s) in 0.43s
Running `target/debug/closure-example`
Before defining closure: [1, 2, 3]
After calling closure: [1, 2, 3, 7]
Зверніть увагу, що тепер немає println!
між визначенням і викликом замикання borrows_mutably
: коли визначається borrows_mutably
, воно захоплює мутабельне посилання на list
. Ми не використовуємо замикання знову після його виклику, тож мутабельне позичання закінчується. Між визначенням замикання і його викликом не дозволене немутабельне позичання, потрібне для виведення, оскільки ніякі інші позичання не дозволені, коли є немутабельне позичання. Спробуйте додати туди println!
, щоб побачити, яке повідомлення про помилку ви дістанете!
Якщо ви хочете змусити замикання прийняти володіння значеннями, яке воно використовує у середовищі навіть якщо тіло замикання не обов'язково потребує володіння, ви можете використати ключове слово move
перед списком параметрів.
Ця техніка особливо корисна при передачі замикання новому потоку, щоб переміщеними даними володів цей новий потік. Ми обговоримо потоки і нащо вам хотілося б користуватися ними у Розділі 16, коли ми говоримо про одночасне виконання, але поки що давайте коротко дослідимо створення нового потоку за допомогою замикання, що вимагає ключове слово move
. Блок коду 13-6 показує змінений Блок коду 13-4, що виводить вектор у новому потоці, а не у головному:
Файл: src/main.rs
use std::thread; fn main() { let list = vec![1, 2, 3]; println!("Before defining closure: {:?}", list); thread::spawn(move || println!("From thread: {:?}", list)) .join() .unwrap(); }
Ми створюємо новий потік, надаючи йому замикання для виконання як аргумент. Тіло замикання виводить список. У Блоці коду 13-4 це замикання захоплює лише list
за допомогою немутабельного посилання, бо це найменша кількість доступу до list
, потрібна для його виведення. У цьому прикладі, попри те, що тіло замикання все ще потребує лише немутабельного посилання, нам потрібно вказати, що list
слід перемістити у замикання, додавши ключове слово move
на початку визначення замикання. Новий потік може завершитися до завершення решти головного потоку, чи основний потік може завершитися першим. Якщо основний потік утримував володіння list
, але завершився до завершення нового потоку і скинув list
, немутабельне посилання у тому потоці стає некоректним. Відповідно, компілятор вимагає, щоб list
буде переміщений у замикання, що передається у новий потік, щоб посилання буде коректним. Спробуйте видалити ключове слово move
або використати list
в основному потоці після закриття, щоб побачити помилки компілятора, які ви отримуєте!
Переміщення Захоплених Значень із Замикань і Трейти Fn
Коли замикання захопило посилання чи володіння значенням у місці, де це замикання визначене (таким чином впливаючи на те, що було переміщено в замикання), код у тілі замикання визначає, що відбувається з посиланнями або значеннями, коли пізніше замикання обчислюється (тим самим впливаючи на те, що буде переміщено із замикання). Тіло замикання може робити одне з: перемістити захоплене значення із замикання, змінити захоплене значення, не перемішати ані змінювати значення, чи взагалі нічого не захоплювати з середовища.
Те, як замикання захоплює і обробляє значення з середовища, впливає на те, які трейти реалізовує замикання, а трейти - спосіб функціям і структурам зазначити, які види замикань вони можуть використовувати. Замикання автоматично реалізують один, два чи всі три ці трейти Fn
із накопиченням, залежно від того, як тіло замикання поводиться зі значеннями:
FnOnce
застосовується до замикань, які можна викликати один раз. Усі замикання реалізовують щонайменше цей трейт, бо всі замикання можна викликати. Замикання, що переміщує захоплені значення зі свого тіла можуть реалізовувати лишеFnOnce
і жодного іншого з трейтівFn
, бо їх можна викликати лише один раз.FnMut
застосовується до замикань, які не переміщують захоплені значення зі свого тіла, але можуть їх змінювати. Ці замикання можуть бути викликані більше ніж один раз.Fn
застосовується до замикань, що не переміщують захоплені значення зі свого тіла і їх не змінюють, а також до замикань, що нічого не захоплюють із середовища. Ці замикання можуть бути викликані більше одного разу без змін середовища, що важливо у таких випадках, як одночасний виклик замикання багато разів.
Погляньмо на визначення методу unwrap_or_else
для Option<T>
, який ми використовували в Блоці Коду 13-1:
impl<T> Option<T> {
pub fn unwrap_or_else<F>(self, f: F) -> T
where
F: FnOnce() -> T
{
match self {
Some(x) => x,
None => f(),
}
}
}
Згадайте, що T
- це узагальнений тип, що представляє тип значення з варіанта Some
із Option
. Цей тип T
також є типом, який повертає поверненим функція unwrap_or_else
: код, що викликає unwrap_or_else
, наприклад, для Option<String>
отримає String
.
Далі, зверніть увагу, що функція unwrap_or_else
має додатковий параметр узагальненого типу F
. Тип F
є типом параметра f
, який є замиканням, яке ми надаємо під час виклику unwrap_or_else
.
Трейтове обмеження, вказане для узагальненого типу F
, FnOnce() -> T
, що означає, що F
має бути можливо викликати один раз, вона не приймає аргументи, і повертає T
. Використання FnOnce
у трейтовому обмеженні виражає обмеження, що unwrap_or_else
збирається викликати f
не більше одного разу. У тілі unwrap_or_else
, як ми можемо бачити, якщо Option
є Some
, f
не буде викликано. Якщо Option
є None
, f
буде викликана один раз. Оскільки всі замикання реалізують FnOnce
, unwrap_or_else
приймає найрізноманітніші типи замикань і гнучка настільки, наскільки це можливо.
Примітка: функції також можуть реалізовувати усі три трейти
Fn
. Якщо те, що ми хочемо зробити, не потребує захоплення значення з середовища, ми можемо використовувати ім'я функції замість замикання там, де нам потрібне щось, що реалізує один з трейтівFn
. Скажімо, для значенняOption<Vec<T>>
ми можемо викликатиunwrap_or_else(Vec:new)
, щоб отримати новий порожній вектор, якщо значення будеNone
.
Тепер подивімося на метод зі стандартної бібліотеки sort_by_key
, визначений для слайсів, щоб побачити, як це відрізняється від unwrap_or_else
, і чому sort_by_key
використовує FnMut
замість FnOnce
як трейтове обмеження. Замикання приймає один аргумент у формі посилання на поточний елемент у слайсі, і повертає значення типу K
, яке можна впорядкувати. Ця функція корисна, коли вам треба відсортувати слайс за певним атрибутом кожного елемента. У Блоці коду 13-7 ми маємо список екземплярів Rectangle
і використовуємо sort_by_key
, щоб впорядкувати їх за атрибутом width
за зростанням:
Файл: src/main.rs
#[derive(Debug)] struct Rectangle { width: u32, height: u32, } fn main() { let mut list = [ Rectangle { width: 10, height: 1 }, Rectangle { width: 3, height: 5 }, Rectangle { width: 7, height: 12 }, ]; list.sort_by_key(|r| r.width); println!("{:#?}", list); }
Цей код виведе:
$ cargo run
Compiling rectangles v0.1.0 (file:///projects/rectangles)
Finished dev [unoptimized + debuginfo] target(s) in 0.41s
Running `target/debug/rectangles`
[
Rectangle {
width: 3,
height: 5,
},
Rectangle {
width: 7,
height: 12,
},
Rectangle {
width: 10,
height: 1,
},
]
sort_by_key
визначено для замикання FnMut
тому, що вона викликає замикання кілька разів: один раз для кожного елемента у слайсі. Замикання |r| r.width
не захоплює, не змінює і не переміщує нічого з його середовища, тож це відповідає вимогам трейтового обмеження.
На противагу цьому, у Блоці коду 13-8 наведено приклад замикання, яке реалізує тільки трейт FnOnce
, тому що воно переміщує значення з середовища. Компілятор не дозволить нам використовувати це замикання у sort_by_key
:
Файл: src/main.rs
#[derive(Debug)]
struct Rectangle {
width: u32,
height: u32,
}
fn main() {
let mut list = [
Rectangle { width: 10, height: 1 },
Rectangle { width: 3, height: 5 },
Rectangle { width: 7, height: 12 },
];
let mut sort_operations = vec![];
let value = String::from("by key called");
list.sort_by_key(|r| {
sort_operations.push(value);
r.width
});
println!("{:#?}", list);
}
Це надуманий, заплутаний спосіб (який не працює) спробувати підрахувати кількість викликів sort_by_key
при сортуванні list
. Цей код намагається виконати підрахунок, виштовхуючи value
- String
з середовища замикання у вектор sort_operations
. Замикання захоплює value
, потім переміщує value
із замикання, передаючи володіння value
до вектора sort_operations
. Це замикання може бути викликане один раз; спроба викликати вдруге не спрацює, оскільки value
більше не буде в середовищі, щоб занести його до sort_operations
знову! Таким чином це замикання реалізує лише FnOnce
. Коли ми намагаємося скомпілювати цей код, то отримуємо помилку про те, що value
не можна перемістити із замикання, оскільки замикання має реалізовувати FnMut
:
$ cargo run
Compiling rectangles v0.1.0 (file:///projects/rectangles)
error[E0507]: cannot move out of `value`, a captured variable in an `FnMut` closure
--> src/main.rs:18:30
|
15 | let value = String::from("by key called");
| ----- captured outer variable
16 |
17 | list.sort_by_key(|r| {
| --- captured by this `FnMut` closure
18 | sort_operations.push(value);
| ^^^^^ move occurs because `value` has type `String`, which does not implement the `Copy` trait
For more information about this error, try `rustc --explain E0507`.
error: could not compile `rectangles` due to previous error
Помилка вказує на рядок у тілі замикання, що переміщує value
з середовища. Щоб виправити це, нам потрібно змінити тіло замикання так, щоб воно не переміщувало значення з середовища. Полічити кількість викликів sort_by_key
, утримуючи лічильник у середовищі та збільшуючи його значення у тілі замикання є прямішим шляхом для цього обчислення. Замикання у Блоці коду 13-9 працює з sort_by_key
, оскільки воно містить лише мутабельне посилання на лічильник num_sort_operations
і тому може бути викликане більше ніж один раз:
Файл: src/main.rs
#[derive(Debug)] struct Rectangle { width: u32, height: u32, } fn main() { let mut list = [ Rectangle { width: 10, height: 1 }, Rectangle { width: 3, height: 5 }, Rectangle { width: 7, height: 12 }, ]; let mut num_sort_operations = 0; list.sort_by_key(|r| { num_sort_operations += 1; r.width }); println!("{:#?}, sorted in {num_sort_operations} operations", list); }
Трейти Fn
мають важливе значення при визначенні або використанні функцій або типів, які використовують замикання. У наступному підрозділі ми обговоримо ітератори. Багато методів ітератора приймають аргументи-замикання, тому не забувайте, що дізналися про замикання, коли ми продовжимо!