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 — це анонімні функції, які ви можете зберегти у змінній або передати як аргументи іншим функціям. Ви можете створити замикання в одному місці, а потім викликати замикання в іншому, щоб обчислити його в іншому контексті. На відміну від функцій, замикання можуть захоплювати значення з області видимості, у якій їх визначено. Ми продемонструємо, як ці можливості замикань дозволяють повторно використовувати код і налаштовувати поведінку.

Захоплення середовища

Спершу ми розглянемо, як можна використовувати замикання, щоб захоплювати значення з середовища, у якому їх визначено, для подальшого використання. Ось сценарій: час від часу наша компанія футболок роздає ексклюзивну футболку обмеженої серії комусь зі списку розсилки як промоакцію. Люди зі списку розсилки можуть за бажанням додати у свій профіль улюблений колір. Якщо в людини, обраної для безплатної футболки, задано улюблений колір, вона отримає футболку цього кольору. Якщо людина не вказала улюблений колір, вона отримає той колір, якого в компанії зараз найбільше.

Є багато способів реалізувати це. У цьому прикладі ми використаємо enum під назвою ShirtColor, який має варіанти Red і Blue (для простоти обмеживши кількість доступних кольорів). Ми представляємо запаси компанії за допомогою структури Inventory, яка має поле під назвою shirts, що містить Vec<ShirtColor>, який представляє кольори футболок, що зараз є на складі. Метод giveaway, визначений для Inventory, отримує необов’язкову перевагу щодо кольору футболки від переможця безплатної футболки та повертає колір футболки, який отримає людина. Ця схема показана у Listing 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. Анотації типів потрібні для функцій, тому що типи є частиною явного інтерфейсу, відкритого вашим користувачам. Чітке визначення цього інтерфейсу важливе для того, щоб усі погоджувалися, які типи значень функція використовує і повертає. Замикання, з іншого боку, не використовуються в такому відкритому інтерфейсі: вони зберігаються у змінних і використовуються без іменування та відкриття для користувачів нашої бібліотеки.

Замикання зазвичай короткі й релевантні лише в межах вузького контексту, а не в будь-якому довільному сценарії. У межах цих обмежених контекстів компілятор може вивести типи параметрів і тип значення, що повертається, подібно до того, як він може виводити типи більшості змінних (є рідкісні випадки, коли компілятору також потрібні анотації типів замикання).

Як і для змінних, ми можемо додати анотації типів, якщо хочемо підвищити явність і зрозумілість ціною більшої багатослівності, ніж це суворо необхідно. Анотування типів для замикання виглядало б так, як визначення, показане в Listing 13-2. У цьому прикладі ми визначаємо замикання й зберігаємо його у змінній, а не визначаємо замикання в тому місці, де передаємо його як аргумент, як ми зробили в Listing 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 міг вивести тип.

Для визначень замикань компілятор виведе один конкретний тип для кожного з їхніх параметрів і для їхнього значення, що повертається. Наприклад, Listing 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, і ми отримуємо помилку типу, коли наступного разу намагаємося використати інший тип із тим самим замиканням.

Захоплення посилань або переміщення власності

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

У Listing 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]

Далі, у Listing 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. Listing 13-6 показує Listing 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();
}

Ми створюємо новий потік, передаючи потоку замикання для виконання як аргумент. Тіло замикання друкує список. У Listing 13-4 замикання захоплювало лише list за допомогою незмінного посилання, тому що це найменший обсяг доступу до list, який потрібен, щоб надрукувати його. У цьому прикладі, хоча тіло замикання все ще потребує лише незмінного посилання, нам потрібно вказати, що list слід перемістити в замикання, розмістивши ключове слово move на початку визначення замикання. Якби головний потік виконував більше операцій перед викликом join у новому потоці, новий потік міг би завершитися до того, як закінчиться решта головного потоку, або головний потік міг би завершитися першим. Якби головний потік зберігав власність над list, але завершився раніше за новий потік і знищив би list, незмінне посилання в потоці стало б недійсним. Тому компілятор вимагає, щоб list було переміщено в замикання, передане новому потоку, щоб посилання було дійсним. Спробуйте прибрати ключове слово move або використати list у головному потоці після того, як замикання визначено, щоб побачити, які помилки компілятора ви отримаєте!

Переміщення захоплених значень із замикань

Після того як замикання захопило посилання або захопило власність над значенням із середовища, де замикання визначено (тим самим впливаючи на те, що, якщо взагалі щось, переміщується в замикання), код у тілі замикання визначає, що стається з посиланнями або значеннями, коли замикання буде обчислено пізніше (тим самим впливаючи на те, що, якщо взагалі щось, переміщується з замикання).

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

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

  • FnOnce застосовується до замикань, які можна викликати один раз. Усі замикання реалізують принаймні цей трейт, тому що всі замикання можна викликати. Замикання, яке переміщує захоплені значення зсередини свого тіла, реалізовуватиме лише FnOnce і жоден з інших трейт-ів Fn, тому що його можна викликати лише один раз.
  • FnMut застосовується до замикань, які не переміщують захоплені значення з їхнього тіла, але можуть змінювати захоплені значення. Такі замикання можна викликати більше ніж один раз.
  • Fn застосовується до замикань, які не переміщують захоплені значення з їхнього тіла і не змінюють захоплені значення, а також до замикань, які нічого не захоплюють зі свого середовища. Такі замикання можна викликати більше ніж один раз без зміни їхнього середовища, що важливо в таких випадках, як одночасний багаторазовий виклик замикання.

Погляньмо на визначення методу unwrap_or_else для Option<T>, яке ми використовували в Listing 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, яке можна впорядкувати. Ця функція корисна коли ви хочете відсортувати зріз за певним атрибутом кожного елемента. У Listing 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 не захоплює, не змінює і не переміщує нічого зі свого середовища, тому воно відповідає вимогам обмеження трейт-у.

На відміну від цього, Listing 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. Цей код намагається зробити цей підрахунок, поміщаючи valueString із середовища замикання — у вектор 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 із середовища. Щоб виправити це, нам потрібно змінити тіло замикання так, щоб воно не переміщувало значення з середовища. Утримувати лічильник у середовищі й збільшувати його значення в тілі замикання — це більш прямий спосіб порахувати кількість викликів замикання. Замикання в Listing 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 важливі під час визначення або використання функцій чи типів, які використовують замикання. У наступному розділі ми обговоримо ітератори. Багато методів ітераторів приймають замикання як аргументи, тож пам’ятайте про ці деталі замикань, коли ми рухатимемося далі!