Замыкания
Замыкания в Rust — это анонимные функции, которые можно сохранять в переменных или передавать в качестве аргументов другим функциям. Вы можете создать замыкание в одном месте, а затем вызвать его в другом месте для вычисления в другом контексте. В отличие от функций, замыкания могут захватывать значения из области видимости, в которой они определены. Мы продемонстрируем, как эти возможности замыканий позволяют повторно использовать код и настраивать поведение.
Захват окружения
Сначала мы рассмотрим, как можно использовать замыкания для захвата значений из окружения, в котором они определены, для последующего использования. Вот сценарий: Время от времени наша компания по производству футболок раздает эксклюзивные футболки ограниченного выпуска кому-то из нашего списка рассылки в качестве акции. Люди в списке рассылки могут по желанию добавить свой любимый цвет в свой профиль. Если выбранный для бесплатной футболки человек указал свой любимый цвет, он получает футболку этого цвета. Если человек не указал любимый цвет, он получает тот цвет, которого у компании в настоящее время больше всего.
Есть много способов реализовать это. Для этого примера мы будем использовать перечисление ShirtColor с вариантами Red и Blue (ограничивая количество доступных цветов для простоты). Мы представляем инвентарь компании с помощью структуры Inventory, которая имеет поле shirts, содержащее Vec<ShirtColor>, представляющее цвета футболок, currently имеющиеся в наличии. Метод 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, которые мы определили, или о логике, которую мы хотим использовать в этом сценарии. Замыкание захватывает неизменяемую ссылку на экземпляр self Inventory и передает ее с указанным нами кодом в метод unwrap_or_else. Функции, с другой стороны, не могут захватывать свое окружение таким образом.
Вывод и аннотирование типов замыканий
Между функциями и замыканиями есть больше различий. Замыкания обычно не требуют от вас аннотирования типов параметров или возвращаемого значения, как это делают функции fn. Аннотации типов требуются для функций, потому что типы являются частью явного интерфейса, предоставляемого вашим пользователям. Жесткое определение этого интерфейса важно для обеспечения того, чтобы все соглашались с тем, какие типы значений использует и возвращает функция. Замыкания, с другой стороны, не используются в открытом интерфейсе подобным образом: они хранятся в переменных и используются без их именования и предоставления пользователям нашей библиотеки.
Замыкания обычно короткие и актуальны только в узком контексте, а не в любом произвольном сценарии. В этих ограниченных контекстах компилятор может вывести типы параметров и тип возвращаемого значения, подобно тому, как он может выводить типы большинства переменных (бывают редкие случаи, когда компилятору также нужны аннотации типов замыканий).
Как и с переменными, мы можем добавлять аннотации типов, если хотим повысить явность и ясность за счет большей многословности, чем это strictly необходимо. Аннотирование типов для замыкания будет выглядеть как определение, показанное в листинге 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]
Обратите внимание, что больше нет println! между определением и вызовом замыкания borrows_mutably: Когда borrows_mutably определяется, оно захватывает изменяемую ссылку на list. Мы не используем замыкание снова после вызова замыкания, поэтому изменяемое заимствование заканчивается. Между определением замыкания и вызовом замыкания неизменяемое заимствование для вывода не допускается, потому что никакие другие заимствования не разрешены, когда есть изменяемое заимствование. Попробуйте добавить println! там, чтобы увидеть, какое сообщение об ошибке вы получите!
Если вы хотите заставить замыкание взять владение значениями, которые оно использует в окружении, даже если тело замыкания strictly не нуждается во владении, вы можете использовать ключевое слово 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(); }
Мы порождаем новый поток, передавая потоку замыкание для запуска в качестве аргумента. Тело замыкания выводит список. В листинге 13-4 замыкание захватывало list только с помощью неизменяемой ссылки, потому что это минимальный доступ к list, необходимый для его вывода. В этом примере, даже though тело замыкания все еще нуждается только в неизменяемой ссылке, нам нужно указать, что list должен быть перемещен в замыкание, поместив ключевое слово move в начало определения замыкания. Если бы основной поток выполнял больше операций перед вызовом join для нового потока, новый поток мог бы завершиться до завершения остальной части основного потока, или основной поток мог бы завершиться первым. Если бы основной поток сохранял владение list, но завершался до нового потока и удалял list, неизменяемая ссылка в потоке была бы недействительной. Поэтому компилятор требует, чтобы list был перемещен в замыкание, переданное новому потоку, чтобы ссылка была действительной. Попробуйте удалить ключевое слово move или использовать list в основном потоке после определения замыкания, чтобы увидеть, какие ошибки компилятора вы получите!
Перемещение захваченных значений из замыканий
После того как замыкание захватило ссылку или владение значением из окружения, где замыкание определено (таким образом влияя на то, что, если вообще что-либо, перемещается в замыкание), код в теле замыкания определяет, что происходит со ссылками или значениями, когда замыкание вычисляется позже (таким образом влияя на то, что, если вообще что-либо, перемещается из замыкания).
Тело замыкания может делать любое из следующего: перемещать захваченное значение из замыкания, изменять захваченное значение, ни перемещать, ни изменять значение или изначально ничего не захватывать из окружения.
То, как замыкание захватывает и обрабатывает значения из окружения, влияет на то, какие трейты реализует замыкание, а трейты — это то, как функции и структуры могут указывать, какие виды замыканий они могут использовать. Замыкания будут автоматически реализовывать один, два или все три из этих трейтов Fn, аддитивно, в зависимости от того, как тело замыкания обрабатывает значения:
FnOnceприменяется к замыканиям, которые могут быть вызваны один раз. Все замыкания реализуют по крайней мере этот трейт, потому что все замыкания могут быть вызваны. Замыкание, которое перемещает захваченные значения из своего тела, будет реализовывать толькоFnOnceи никакие другие трейтыFn, потому что оно может быть вызвано только один раз.FnMutприменяется к замыканиям, которые не перемещают захваченные значения из своего тела, но могут изменять захваченные значения. Эти замыкания могут быть вызваны более одного раза.Fnприменяется к замыканиям, которые не перемещают захваченные значения из своего тела и не изменяют захваченные значения, а также к замыканиям, которые ничего не захватывают из своего окружения. Эти замыкания могут быть вызваны более одного раза без изменения их окружения, что важно в таких случаях, как многократный вызов замыкания concurrently.
Давайте посмотрим на определение метода 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:#?}"); }
Этот код выводит:
$ 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 не захватывает, не изменяет и не перемещает ничего из своего окружения, поэтому оно соответствует требованиям ограничения трейта.
В contrast, листинг 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:#?}");
}
Это надуманный, запутанный способ (который не работает) попытаться подсчитать, сколько раз 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 из окружения. Чтобы исправить это, нам нужно изменить тело замыкания так, чтобы оно не перемещало значения из окружения. Сохранение счетчика в окружении и увеличение его значения в теле замыкания — более straightforward способ подсчитать количество вызовов замыкания. Замыкание в листинге 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"); }
Трейты Fn важны при определении или использовании функций или типов, которые используют замыкания. В следующем разделе мы обсудим итераторы. Многие методы итераторов принимают аргументы-замыкания, так что помните об этих деталях замыканий, пока мы продолжаем!