Замыкания: анонимные функции, которые запечатлевают ("захватывают") своё окружение

Замыкания в 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
    );
}

Листинг 13-1: Ситуация с раздачей рубашек компанией

В магазине 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 вызовет замыкание и вернёт значение, возвращённое замыканием.

В качестве аргумента unwrap_or_else мы передаём замыкание || self.most_stocked(). Это замыкание, которое не принимает никаких параметров (если бы у замыкания были параметры, они были бы перечислены между двумя вертикальными полосами). В теле замыкания вызывается 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, которые мы определили, или о логике, которую мы хотим использовать в этом сценарии. Замыкание фиксирует неизменяемую ссылку на self Inventory и передаёт её с указанным нами кодом в метод 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);
}

Листинг 13-2: Добавление необязательных аннотаций типов параметров и возвращаемых значений в замыкании

С добавлением аннотаций типов синтаксис замыканий выглядит более похожим на синтаксис функций. Здесь мы, для сравнения, определяем функцию, которая добавляет 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);
}

Листинг 13-3: Попытка вызова замыкания, типы которого выводятся из двух разных типов

Компилятор вернёт нам вот такую ошибку:

$ 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, поскольку неизменяемой ссылки достаточно для печати значения:

Имя файла: 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:?}");
}

Листинг 13-4: Определение и вызов замыкания, которое захватывает неизменяемую ссылку

Этот пример также иллюстрирует, то что переменная может быть привязана к определению замыкания, и в дальнейшем мы можем вызвать замыкание, используя имя переменной и круглые скобки, как если бы имя переменной было именем функции.

Поскольку мы можем иметь несколько неизменяемых ссылок на 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:?}");
}

Листинг 13-5. Определение и вызов замыкания, захватывающего изменяемую ссылку

Этот код компилируется, запускается и печатает:

$ 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]

Обратите внимание, что между определением и вызовом замыкания borrows_mutably больше нет println!: когда определяется 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-6: Использование move для принуждения замыкания потока принять на себя владение list

Мы порождаем новый поток, передавая ему в качестве аргумента замыкание для выполнения. Тело замыкания распечатывает список. В листинге 13-4 замыкание захватило list только с помощью неизменяемой ссылки, потому что это минимально необходимый доступ к list для его печати. В этом примере, несмотря на то, что тело замыкания по-прежнему требует только неизменяемой ссылки, нам нужно указать, что list должен быть перемещён в замыкание, поместив ключевое слово move в начало определения замыкания. Новый поток может завершиться раньше, чем завершится основной поток, или основной поток может завершиться первым. Если основной поток сохранил владение list, но завершился раньше нового потока и удалил list, то неизменяемая ссылка в потоке будет недействительной. Поэтому компилятор требует, чтобы list был перемещён в замыкание, переданное новому потоку, чтобы ссылка была действительной. Попробуйте убрать ключевое слово move или использовать list в основном потоке после определения замыкания и посмотрите, какие ошибки компилятора вы получите!

Перемещение захваченных значений из замыканий и трейты Fn

После того, как замыкание захватило ссылку или владение значением из среды, в которой оно определено (тем самым влияя на то, что перемещается в замыкание), код в теле замыкания определяет, что происходит со ссылками или значениями, в момент последующего выполнения замыкания (тем самым влияя на то, что перемещается из замыкания). Тело замыкания может делать любое из следующих действий: перемещать захваченное значение из замыкания, изменять захваченное значение, не перемещать и не изменять значение или вообще ничего не захватывать из среды.

То, как замыкание получает и обрабатывает значения из своего окружения, указывает на то, какие трейты реализует замыкание, а с помощью трейтов функции и структуры могут определять, какие типы замыканий они могут использовать. Замыканиям автоматически присваивается реализация одного, двух или всех трёх из нижеперечисленных трейтов Fn, аддитивным образом, в зависимости от того, как тело замыкания обрабатывает значения:

  1. FnOnce применяется к замыканиям, которые могут быть вызваны один раз. Все замыкания реализуют по крайней мере этот трейт, потому что все замыкания могут быть вызваны. Замыкание, которое перемещает захваченные значения из своего тела, реализует только FnOnce и ни один из других признаков Fn, потому что оно может быть вызвано только один раз.
  2. FnMut применяется к замыканиям, которые не перемещают захваченные значения из своего тела, но могут изменять захваченные значения. Такие замыкания могут вызываться более одного раза.
  3. 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:#?}");
}

Листинг 13-7: Использование sort_by_key для сортировки прямоугольников по ширине

Этот код печатает:

$ 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("closure called");

    list.sort_by_key(|r| {
        sort_operations.push(value);
        r.width
    });
    println!("{list:#?}");
}

Листинг 13-8: Попытка использовать замыкание 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

For more information about this error, try `rustc --explain E0507`.
error: could not compile `rectangles` (bin "rectangles") due to 1 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!("{list:#?}, sorted in {num_sort_operations} operations");
}

Листинг 13-9: Использование замыкания FnMut с sort_by_key разрешено

Трейты Fn важны при определении или использовании функций или типов, использующих замыкания. В следующем разделе мы обсудим итераторы. Многие методы итераторов принимают аргументы в виде замыканий, поэтому не забывайте об этих деталях, пока мы продвигаемся дальше!