Замикання: Анонімні Функції, що Захоплюють Своє Середовище

У 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. Якщо ж 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 [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(); потребує або анотацію типів, або додати значення певного типу у 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 struct `String`, found integer
  |             arguments to this function are incorrect
  |
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` due to 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, до нього можливий доступ до визначення замикання, після визначення, але до виклику замикання і після виклику замикання. Цей код компілюється, виконується і виводить:

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

Зверніть увагу, що тепер немає println! між визначенням і викликом замикання borrows_mutably: коли визначається 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("by key 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("by key 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` due to 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!("{:#?}, sorted in {num_sort_operations} operations", list);
}

Блок коду 13-9: Використання замикання FnMut у sort_by_key дозволене

Трейти Fn мають важливе значення при визначенні або використанні функцій або типів, які використовують замикання. У наступному підрозділі ми обговоримо ітератори. Багато методів ітератора приймають аргументи-замикання, тому не забувайте, що дізналися про замикання, коли ми продовжимо!