Замыкания
Замыкания Rust – это анонимные функции, которые можно сохранить в переменной или передать как аргументы другим функциям. Вы можете создать замыкание в одном месте, а затем вызвать его в другом, чтобы выполнить его в другом контексте. В отличие от функций, замыкания могут захватывать значения из области видимости, в которой они определены. Мы покажем, как эти возможности замыканий позволяют повторно использовать код и настраивать поведение.
Захват окружения
Сначала мы рассмотрим, как использовать замыкания для захвата значений из окружения, в котором они определены, чтобы использовать эти значения позже. Сценарий такой: время от времени наша компания по продаже футболок в рамках рекламной акции раздает эксклюзивную футболку ограниченной серии кому-нибудь из нашей рассылки. Люди из рассылки могут при желании добавить в свой профиль любимый цвет. Если у человека, выбранного для бесплатной футболки, указан любимый цвет, он получает футболку этого цвета. Если человек не указал любимый цвет, он получает футболку того цвета, которого у компании сейчас больше всего.
Есть много способов реализовать это. Для этого примера мы будем использовать
enum с именем ShirtColor, у которого есть варианты Red и Blue (для
простоты мы ограничим количество доступных цветов). Запасы компании мы
представим структурой Inventory, у которой есть поле shirts, содержащее
Vec<ShirtColor> с цветами футболок, которые сейчас есть в наличии. Метод
giveaway, определенный для Inventory, получает необязательное предпочтение
цвета футболки победителя и возвращает цвет футболки, которую он получит. Эта
настройка показана в листинге 13-1.
#[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> и вызываем метод unwrap_or_else для user_preference.
Метод 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` profile [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.
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 с целым числом, получим ошибку.
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);
| --------------- ^ expected `String`, found integer
| |
| arguments to this function are incorrect
|
note: expected because the closure was earlier called with an argument of type `String`
--> src/main.rs:4:29
|
4 | let s = example_closure(String::from("hello"));
| --------------- ^^^^^^^^^^^^^^^^^^^^^ expected because this argument is of type `String`
| |
| in this closure call
note: closure parameter defined here
--> src/main.rs:2:28
|
2 | let example_closure = |x| x;
| ^
help: try using a conversion method
|
5 | let n = example_closure(5.to_string());
| ++++++++++++
For more information about this error, try `rustc --explain E0308`.
error: could not compile `closure-example` (bin "closure-example") due to 1 previous error
Когда мы впервые вызываем example_closure со значением String, компилятор
выводит, что тип x и возвращаемый тип замыкания – String. Затем эти типы
закрепляются за замыканием в example_closure, и мы получаем ошибку типа,
когда далее пытаемся использовать другой тип с тем же замыканием.
Захват ссылок или перемещение владения
Замыкания могут захватывать значения из своего окружения тремя способами, которые напрямую соответствуют трем способам, которыми функция может принять параметр: неизменяемое заимствование, изменяемое заимствование и получение владения. Замыкание решает, какой из этих способов использовать, исходя из того, что тело функции делает с захваченными значениями.
В листинге 13-4 мы определяем замыкание, которое захватывает неизменяемую
ссылку на вектор с именем list, потому что ему нужна только неизменяемая
ссылка, чтобы напечатать значение.
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
одновременно, list все еще доступен из кода до определения замыкания, после
определения замыкания, но до его вызова, и после вызова замыкания. Этот код
компилируется, запускается и печатает:
$ cargo run
Compiling closure-example v0.1.0 (file:///projects/closure-example)
Finished `dev` profile [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. Теперь замыкание захватывает изменяемую ссылку.
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` profile [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]
Обратите внимание, что между определением и вызовом замыкания borrows_mutably
больше нет println!: когда borrows_mutably определено, оно захватывает
изменяемую ссылку на list. Мы больше не используем замыкание после его
вызова, поэтому изменяемое заимствование заканчивается. Между определением
замыкания и его вызовом неизменяемое заимствование для печати не разрешено,
потому что при наличии изменяемого заимствования никакие другие заимствования
не разрешены. Попробуйте добавить туда println! и посмотреть, какое
сообщение об ошибке вы получите!
Если вы хотите заставить замыкание забрать владение значениями, которые оно
использует из окружения, даже если телу замыкания строго не требуется владение,
можно использовать ключевое слово move перед списком параметров.
Этот прием в основном полезен при передаче замыкания новому потоку, чтобы
переместить данные и сделать их принадлежащими новому потоку. Мы подробно
обсудим потоки и причины их использования в главе 16, когда будем говорить о
конкурентности, но пока кратко рассмотрим создание нового потока с помощью
замыкания, которому нужно ключевое слово move. В листинге 13-6 показан
листинг 13-4, измененный так, чтобы печатать вектор в новом потоке, а не в
главном потоке.
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();
}
move, чтобы заставить замыкание для потока забрать владение listМы создаем новый поток, передавая потоку замыкание для выполнения как аргумент.
Тело замыкания печатает список. В листинге 13-4 замыкание захватывало list
только через неизменяемую ссылку, потому что это минимальный объем доступа к
list, необходимый для печати. В этом примере, хотя телу замыкания по-прежнему
нужна только неизменяемая ссылка, нам нужно указать, что list должен быть
перемещен в замыкание, поставив ключевое слово move в начало определения
замыкания. Если бы главный поток выполнил больше операций перед вызовом join
для нового потока, новый поток мог бы завершиться до завершения остальной
части главного потока или главный поток мог бы завершиться первым. Если бы
главный поток сохранил владение list, но завершился раньше нового потока и
удалил list, неизменяемая ссылка в потоке стала бы недействительной. Поэтому
компилятор требует переместить list в замыкание, переданное новому потоку,
чтобы ссылка была действительной. Попробуйте убрать ключевое слово move или
использовать list в главном потоке после определения замыкания, чтобы
увидеть, какие ошибки компилятора вы получите!
Перемещение захваченных значений из замыканий
После того как замыкание захватило ссылку или захватило владение значением из окружения, где оно определено (тем самым влияя на то, что, если что-то вообще, перемещается в замыкание), код в теле замыкания определяет, что происходит со ссылками или значениями, когда замыкание вычисляется позже (тем самым влияя на то, что, если что-то вообще, перемещается из замыкания).
Тело замыкания может делать любое из следующего: переместить захваченное значение из замыкания, изменить захваченное значение, не перемещать и не изменять значение или вообще ничего не захватывать из окружения.
То, как замыкание захватывает и обрабатывает значения из окружения, влияет на
то, какие трейты реализует замыкание; а трейты – это способ, которым функции
и структуры могут указать, какие виды замыканий они могут использовать.
Замыкания автоматически реализуют один, два или все три из этих трейтов 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. Например, для значенияOption<Vec<T>>можно вызватьunwrap_or_else(Vec::new), чтобы получить новый пустой вектор, если значение равноNone. Компилятор автоматически реализует для определения функции тот из трейтовFn, который применим.
Теперь посмотрим на метод стандартной библиотеки sort_by_key, определенный
для срезов, чтобы увидеть, чем он отличается от unwrap_or_else и почему
sort_by_key использует FnMut, а не FnOnce, в ограничении трейта.
Замыкание получает один аргумент в виде ссылки на текущий рассматриваемый
элемент среза и возвращает значение типа K, которое можно упорядочить. Эта
функция полезна, когда вы хотите отсортировать срез по определенному атрибуту
каждого элемента. В листинге 13-7 у нас есть список экземпляров Rectangle, и
мы используем sort_by_key, чтобы упорядочить их по атрибуту width от
меньшего к большему.
#[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:#?}");
}
sort_by_key для упорядочивания прямоугольников по ширинеЭтот код печатает:
$ cargo run
Compiling rectangles v0.1.0 (file:///projects/rectangles)
Finished `dev` profile [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.
#[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("closure called");
list.sort_by_key(|r| {
sort_operations.push(value);
r.width
});
println!("{list:#?}");
}
FnOnce с sort_by_keyЭто искусственный, запутанный способ (который не работает) попытаться
посчитать, сколько раз 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("closure called");
| ----- ------------------------------ move occurs because `value` has type `String`, which does not implement the `Copy` trait
| |
| captured outer variable
16 |
17 | list.sort_by_key(|r| {
| --- captured by this `FnMut` closure
18 | sort_operations.push(value);
| ^^^^^ `value` is moved here
|
help: consider cloning the value if the performance cost is acceptable
|
18 | sort_operations.push(value.clone());
| ++++++++
For more information about this error, try `rustc --explain E0507`.
error: could not compile `rectangles` (bin "rectangles") due to 1 previous error
Ошибка указывает на строку в теле замыкания, которая перемещает value из
окружения. Чтобы исправить это, нужно изменить тело замыкания так, чтобы оно
не перемещало значения из окружения. Хранение счетчика в окружении и
увеличение его значения в теле замыкания – более прямой способ посчитать,
сколько раз вызывается замыкание. Замыкание в листинге 13-9 работает с
sort_by_key, потому что оно захватывает только изменяемую ссылку на счетчик
num_sort_operations и поэтому может быть вызвано больше одного раза.
#[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!("{list:#?}, sorted in {num_sort_operations} operations");
}
FnMut с sort_by_key разрешено.Трейты Fn важны при определении или использовании функций либо типов, которые
используют замыкания. В следующем разделе мы обсудим итераторы. Многие методы
итераторов принимают аргументы-замыкания, поэтому держите эти подробности о
замыканиях в уме, пока мы продолжаем!