Keyboard shortcuts

Press ← or → to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

Замыкания

Замыкания в 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>, и мы вызываем у user_preference метод unwrap_or_else. Метод unwrap_or_else у Option<T> определён в стандартной библиотеке. Он принимает один аргумент — замыкание без аргументов, которое возвращает значение типа T (того же типа, что хранится в варианте Some у Option<T>, в нашем случае ShirtColor). Если Option<T> — вариант Some, unwrap_or_else возвращает значение из Some. Если None — вызывает замыкание и возвращает то, что вернуло замыкание.

Аргументом 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` 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.

Файл: 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();, где Rust нужна либо аннотация типа, либо значения какого-то типа, вставленные в Vec, чтобы вывести тип.

Для каждого параметра замыкания и для его возвращаемого значения компилятор выводит один конкретный тип. Например, в листинге 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);
  |             --------------- ^ 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: чтобы вывести значение, ему достаточно неизменяемой ссылки.

Файл: 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` 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. Теперь замыкание захватывает изменяемую ссылку.

Файл: 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` 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, изменённый так, чтобы вектор выводился не в главном потоке, а в новом.

Файл: 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 перемещается в замыкание, поставив move в начале определения замыкания. Если главный поток выполнит ещё какие-то операции до вызова join у нового потока, новый поток может закончиться раньше главного, а может и главный закончиться первым. Если бы главный поток владел list, но завершился раньше нового и уничтожил list, неизменяемая ссылка в новом потоке стала бы недействительной. Поэтому компилятор требует переместить list в замыкание, которое получает новый поток, — так ссылка останется действительной. Попробуйте убрать move или использовать list в главном потоке после определения замыкания и посмотрите, какие будут ошибки компиляции!

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

С замыканием и захваченными значениями связаны два отдельных вопроса:

  1. Как замыкание захватывает значения при определении — по ссылке или забирая владение. От этого зависит, что перемещается в замыкание.
  2. Что делает с захваченным тело замыкания, когда его вызывают. От этого зависит, что перемещается из замыкания.

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

От того, как замыкание захватывает значения из окружения и что с ними делает, зависит, какие трейты оно реализует. А через трейты функции и структуры указывают, какие замыкания они готовы принимать. В зависимости от того, что тело замыкания делает со значениями, замыкание автоматически реализует один, два или все три трейта 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, то есть замыкания, которое мы передаём при вызове unwrap_or_else.

Ограничение трейтом у обобщённого типа F — FnOnce() -> T. Это значит, что значение типа F можно вызвать хотя бы один раз, аргументов оно не принимает и возвращает T. Ограничение FnOnce выражает требование, что unwrap_or_else вызовет f не больше одного раза. По телу unwrap_or_else видно: если Option — это Some, f не вызывается; если None — вызывается один раз. А раз FnOnce реализуют все замыкания, unwrap_or_else принимает все три вида замыканий и максимально гибок.

Примечание: если для нашей задачи не нужно захватывать значения из окружения, там, где требуется что-то реализующее один из трейтов Fn, можно указать имя функции, а не замыкание. Например, у значения Option<Vec<T>> можно вызвать unwrap_or_else(Vec::new), чтобы при None получить новый пустой вектор. Для определений функций компилятор автоматически реализует подходящие трейты Fn.

Теперь посмотрим на метод стандартной библиотеки sort_by_key, определённый для срезов, и разберёмся, чем он отличается от unwrap_or_else и почему в его ограничении стоит 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` 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 компилятор такое замыкание использовать не даст.

Файл: 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 вектору 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: `Fn` and `FnMut` closures require captured values to be able to be consumed multiple times, but `FnOnce` closures may consume them only once
  --> /rustc/88d9e12ae178fab0fb5cc050a94da85685d449ea/library/alloc/src/slice.rs:249:11
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 и, значит, может вызываться многократно.

Файл: 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 важны, когда вы определяете или используете функции и типы, работающие с замыканиями. В следующем разделе мы поговорим об итераторах. Многие методы итераторов принимают аргументами замыкания, так что держите эти подробности о замыканиях в голове!