Замыкания: анонимные функции, способные захватывать своё окружение
Замыкания Rust — это анонимные функции, которые можно сохранить в переменной или передать в качестве аргументов другим функциям. Вы можете создать замыкание в одном месте, а затем вызвать его в другом, чтобы вычислить его в другом контексте. В отличие от функций, замыкания могут захватывать значения из области видимости, в которой они определены. Мы продемонстрируем, как эти возможности замыканий позволяют повторно использовать код и настраивать поведение.
Захват окружения с помощью замыканий
Сначала мы рассмотрим, как можно использовать замыкания для захвата значений из окружения, в котором они определены, для последующего использования. Вот сценарий: время от времени наша компания по производству футболок раздаёт эксклюзивную лимитированную серию футболки кому-то из нашего списка рассылки в рамках акции. Люди в списке рассылки могут по желанию добавить свой любимый цвет в профиль. Если у выбранного для бесплатной футболки человека указан любимый цвет, он получает футболку этого цвета. Если человек не указал любимый цвет, он получает тот цвет, которого у компании в данный момент больше всего.
Существует много способов реализовать это. Для этого примера мы будем использовать перечисление 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);
| --------------- ^- help: try using a conversion method: `.to_string()`
| | |
| | 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;
| ^
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, необходимый для его печати. В этом примере, даже though тело замыкания по-прежнему требует только неизменяемую ссылку, нам нужно указать, что 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 принимает все три вида замыканий и является максимально гибким.
Примечание: Если то, что мы хотим сделать, не требует захвата значения из окружения, мы можем использовать имя функции вместо замыкания. Например, мы могли бы вызвать
unwrap_or_else(Vec::new)для значенияOption<Vec<T>>, чтобы получить новый пустой вектор, если значениеNone. Компилятор автоматически реализует whichever из типажей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");
| ----- 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
|
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 важны при определении или использовании функций или типов, которые используют замыкания. В следующем разделе мы обсудим итераторы. Многие методы итераторов принимают аргументы-замыкания, поэтому держите эти детали замыканий в уме, пока мы продолжаем!