Замыкания в Rust

Замыкание в Rust — это анонимная функция, которая может захватывать и хранить переменные из своего окружения.

Замыкания позволяют обращаться к переменным вне области видимости, в которой они определены, и при необходимости могут перемещать или заимствовать их в замыкание.

Замыкания широко используются в Rust в таких областях, как функциональное программирование, конкурентное программирование и событийно-ориентированное программирование.


Различия между замыканиями и функциями

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

ХарактеристикаЗамыканиеФункция
АнонимностьНет имени; обычно присваивается переменнойИмеет фиксированноеfnname
Захват окруженияМожет захватывать внешние переменныеНе может захватывать внешние переменные
Синтаксис определения|parameters| expressionfn name(parameters)
Вывод типовТипы параметров и возвращаемых значений обычно можно опускатьНеобходимо явно указывать типы параметров и возвращаемых значений
Хранение и передачаМожно использовать как переменную, параметр или возвращаемое значениеТакже поддерживается

Объявление и вызов замыканий

Базовый синтаксис замыкания выглядит следующим образом:

let closure_name = |参数列表| 表达式或语句块;

Параметры могут иметь аннотации типов или их можно опустить — компилятор Rust выводит их из контекста.

Пример

fn main() {
    // Замыкание: аннотации типов опущены, компилятор выводит автоматически
    let add_one = |x| x + 1;
    println!("add_one(4) = {}", add_one(4)); // Вывод: 5

    // Замыкание: явно указать типы параметров и возвращаемого значения
    let multiply = |a: i32, b: i32| -> i32 { a * b };
    println!("multiply(3, 7) = {}", multiply(3, 7)); // Вывод: 21

    // Многострочное замыкание требует фигурных скобок
    let greet = |name: &str| {
        let greeting = format!("Hello, {}!", name);
        greeting
    };
    println!("{}", greet("example")); // Вывод: Hello, example!
}

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


Захват внешних переменных

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

Замыкания могут захватывать внешние переменные тремя способами:

Способ захватаСоответствующая семантика RustОписание
Захват по ссылкеПохоже на&TПоведение по умолчанию; замыкание заимствует переменную, и она остаётся доступной снаружи
Захват по изменяемому заимствованиюПохоже на&mut TЗамыканию нужно изменять переменную; само замыкание должно быть объявлено какmut
Захват по значениюПохоже наTИспользуйтеmoveключевое слово, перемещающее владение переменной в замыкание

Для типов, которые реализуютCopyтрейт (например,i32、bool),moveсоздаётся только копия значения, и внешняя переменная остаётся доступной. Поэтому при демонстрации передачи владения выбирайтеString、Vecи другие не-Copyтипы.

Захват по ссылке

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

Пример

fn main() {
    let text = String::from("example");
    // Замыкание захватывает text по ссылке и не забирает владение
    let print_text = || println!("text = {}", text);
    print_text(); // Вывод: text = example
    // Заимствование завершено; text всё ещё можно использовать снаружи
    println!("Всё ещё можно использовать снаружи: {}", text);
}

Захват по изменяемой ссылке

Если замыканию нужно изменить захваченную переменную, Rust захватывает её по изменяемой ссылке. В этом случае само замыкание должно быть объявлено какmut。

Пример

fn main() {
    let mut counter = 0;
    // Замыкание захватывает counter через &mut; само замыкание также должно быть объявлено как mut
    let mut inc = || {
        counter += 1;
    };
    inc();
    inc();
    println!("counter = {}", counter); // Вывод: counter = 2
}

Захват по значению (move)

Добавив ключевое словоmoveперед замыканием, замыкание получает владение захваченными переменными. После передачи владения внешняя область больше не может использовать переменную.

Пример

fn main() {
    let owned = String::from("EXAMPLE");
    // move передаёт владение owned в замыкание
    let take_owned = move || println!("owned = {}", owned);
    take_owned(); // Вывод: owned = EXAMPLE
    // Если раскомментировать строку ниже, компиляция завершится ошибкой: владение owned было перемещено в замыкание
    // println!("{}", owned);
}

Когда замыкание нужно передать в другой поток или вернуть за пределы его области видимости,moveэто особенно часто используется — это гарантирует, что замыкание владеет необходимыми данными, и предотвращает висячие ссылки.


Трейты замыканий: Fn / FnMut / FnOnce

Компилятор Rust автоматически реализует соответствующий трейт для замыкания в зависимости от того, как оно захватывает переменные. Понимание этих трёх трейтов — ключ к использованию замыканий в качестве параметров функций или возвращаемых значений.

ТрейтСпособ захватаКоличество вызововТипичный сценарий
FnНеизменяемое заимствование (&T)Несколько разДоступ к захваченным переменным только для чтения
FnMutИзменяемое заимствование (&mut T)Несколько разТребуется изменять захваченные переменные
FnOnceЗабирает владение (T)Только один разПотребляет захваченные переменные; после этого его нельзя вызвать снова

Отношение наследования между этими тремя таково:FnДаFnMutявляется подтрейтом,FnMutДаFnOnceявляется подтрейтом. То есть замыкание, которое реализуетFnавтоматически также реализуетFnMutиFnOnce。

Примечание:moveКлючевое слово лишь заставляет захватить владение; это не означает, что замыкание обязательно должно бытьFnOnce. Если тело замыкания не потребляет захваченное значение, даже еслиmoveдобавлено, замыкание всё ещё может реализовыватьFn(можно вызывать несколько раз).

Пример

fn main() {
    let name = String::from("example");

    // Замыкание Fn: только читает, не изменяет и может вызываться несколько раз
    let greet = || println!("Hello, {}!", name);
    greet(); // Первый вызов
    greet(); // Второй вызов, всё ещё работает нормально

    // Замыкание FnMut: должно изменять захваченные переменные
    let mut count = 0;
    let mut increment = || {
        count += 1;
        count
    };
    println!("increment() = {}", increment()); // Вывод: 1
    println!("increment() = {}", increment()); // Вывод: 2

    // Замыкание FnOnce: потребляет захваченные переменные, может быть вызвано только один раз
    let data = String::from("EXAMPLE");
    let consume = move || {
        let _ = data; // Перемещает data из окружения замыкания, потребляя её
        println!("данные были потреблены");
    };
    consume();
    // consume(); // Если раскомментировать, компиляция завершится ошибкой: замыкания FnOnce можно вызвать только один раз
}

Замыкания как параметры и возвращаемые значения

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

Замыкания как параметры

При использовании замыкания в качестве параметра необходимо указать тип трейта замыкания с помощью обобщённого ограничения. В следующем примере параметрFограниченFn(i32) -> i32, то есть замыкание, которое принимаетi32параметр и возвращаетi32значение.

Пример

// Определите функцию, которая принимает замыкание в качестве параметра
fn apply<F>(val: i32, f: F) -> i32
где
    F: Fn(i32) -> i32, // F должен реализовывать Fn(i32) -> i32
{
    f(val)
}

fn main() {
    let double = |x| x * 2;
    let result = apply(5, double);
    println!("Result: {}", result); // Вывод: Result: 10

    // Вы также можете передать анонимное замыкание напрямую
    let result2 = apply(3, |x| x + 100);
    println!("Result2: {}", result2); // Вывод: Result2: 103
}

Замыкания как возвращаемые значения

Поскольку тип замыкания анонимен, при возврате замыкания требуетсяimpl TraitилиBox<dyn Trait>описать возвращаемый тип.

Возврат замыкания с impl Fn

Когда тип возвращаемого замыкания может быть определён во время компиляции, используйтеimpl Fnи выделение памяти в куче не требуется.

Пример

// Возвращает замыкание, которое захватывает параметр x и добавляет его к переданному значению
fn make_adder(x: i32) -> impl Fn(i32) -> i32 {
    // Нужно использовать move; иначе x — локальная ссылка, и после возврата замыкания x становится недействительным
    move |y| x + y
}

fn main() {
    let add_five = make_adder(5);
    println!("5 + 3 = {}", add_five(3)); // Вывод: 5 + 3 = 8

    let add_ten = make_adder(10);
    println!("10 + 2 = {}", add_ten(2)); // Вывод: 10 + 2 = 12
}

Используйте Box<dyn Fn> для возврата замыканий

Когда нужно динамически выбирать разные замыкания для возврата во время выполнения, используйтеBox<dyn Fn>Разместите замыкание в куче.

Пример

fn make_adder(x: i32) -> Box<dyn Fn(i32) -> i32> {
    Box::new(move |y| x + y)
}

fn main() {
    let add_ten = make_adder(10);
    println!("10 + 2 = {}", add_ten(2)); // Вывод: 10 + 2 = 12
}
МетодМесто выделенияПрименимые сценарии
impl FnСтекТип замыкания может быть определён во время компиляции, что обеспечивает лучшую производительность
Box<dyn Fn>КучаДинамически выбирать замыкания во время выполнения или требуется хранить замыкания между функциями

Типичные случаи использования

Замыкания повсеместно используются в повседневной разработке на Rust. Вот несколько наиболее типичных сценариев применения.

Замыкания в итераторах

Замыкания часто используются вместе с методами итераторов для пакетной обработки элементов коллекции.

Пример

fn main() {
    let nums = vec![1, 2, 3, 4, 5];

    // map: преобразовать каждый элемент
    let squared: Vec<i32> = nums.iter().map(|x| x * x).collect();
    println!("squares: {:?}", squared); // Вывод: [1, 4, 9, 16, 25]

    // filter: отфильтровать элементы, удовлетворяющие условию
    let even: Vec<&i32> = nums.iter().filter(|x| *x % 2 == 0).collect();
    println!("evens: {:?}", even); // Вывод: [2, 4]

    // fold: свернуть коллекцию в одно значение
    let sum: i32 = nums.iter().fold(0, |acc, x| acc + x);
    println!("sum: {}", sum); // Вывод: 15
}

Замыкания и многопоточность

В многопоточном программировании замыкания часто используются для определения исполняемого тела потока.moveКлючевое слово `move` в этом сценарии почти обязательно, поскольку оно перемещает владение данными в новый поток, не позволяя ссылкам между потоками стать недействительными.

Пример

use std::thread;

fn main() {
    let nums = vec![1, 2, 3, 4, 5];

    // Создать поток для каждого числа; move передаёт владение num в поток
    let handles: Vec<_> = nums.into_iter().map(|num| {
        thread::spawn(move || {
            num * 2
        })
    }).collect();

    // Дождаться завершения всех потоков и собрать результаты
    for handle in handles {
        let result = handle.join().unwrap();
        println!("Result: {}", result);
    }
}

Замыкания и обработка ошибок

Замыкания могут возвращатьResultилиOptionтипы, которые в сочетании с методами итераторов реализуют лаконичную логику обработки ошибок.

Пример

fn main() {
    let nums = vec![3, -1, 4, -5, 9];

    // Использовать замыкание для поиска первого положительного числа
    let first_positive = nums.iter().find(|&amp;&amp;x| x > 0);
    match first_positive {
        Some(&amp;n) => println!("First positive number: {}", n), // Вывод: First positive number: 3
        None => println!("No positive numbers"),
    }

    // Используйте замыкание для фильтрации и преобразования в сочетании с Result для обработки возможных ошибок
    let results: Vec<Result<i32, &amp;str>> = nums.iter().map(|&amp;n| {
        if n > 0 {
            ОК(n * 10)
        } else {
            Err("Отрицательные числа не могут быть обработаны")
        }
    }).collect();

    for r in results {
        совпадение r {
            Ok(v) => println!("Успех: {}", v),
            Err(e) => println!("Ошибка: {}", e),
        }
    }
}

Производительность и времена жизни

Производительность замыканий

Замыкания Rust легковесны. Компилятор выполняет оптимизации встраивания для замыканий, благодаря чему накладные расходы вызова замыканий близки к прямому вызову обычных функций. Сами замыкания не вносят дополнительных вызовов виртуальных функций или выделений памяти в куче (если только явно не используетсяBox)。

Замыкания и времена жизни

Время жизни замыкания тесно связано с переменными, которые оно захватывает. Система времён жизни Rust гарантирует, что замыкание не переживёт ни одну переменную, которую оно захватывает — если замыкание ссылается на локальную переменную, компилятор не позволит вернуть замыкание за пределы области видимости этой переменной во время компиляции.

Пример

fn main() {
    let text = String::from("example");

    // Правильно: используйте замыкание в пределах области видимости text
    let print_text = || println!("{}", text);
    print_text(); // Вывод: example

    // Если вы попытаетесь вернуть это замыкание, компилятор сообщит об ошибке:
    // fn make_closure() -> impl Fn() {
    // let text = String::from("example");
    // || println!("{}", text) // Ошибка: время жизни text недостаточно велико
    // }
    // Решение: используйте move, чтобы передать владение в замыкание
}

Полный пример

В следующем примере всесторонне демонстрируются основные способы использования замыканий, такие как объявление, захват внешних переменных и передача в качестве аргументов.

Пример

// Определите функцию, которая принимает замыкание в качестве параметра
fn apply_operation<F>(num: i32, operation: F) -> i32
где
    F: Fn(i32) -> i32,
{
    operation(num)
}

fn main() {
    let num = 5;

    // Определить замыкание: возвести число в квадрат
    let square = |x| x * x;

    // Передать замыкание в функцию в качестве аргумента
    let result = apply_operation(num, square);
    println!("Квадрат числа {} равен {}", num, result); // Вывод: Квадрат числа 5 равен 25

    // Вы также можете передать анонимное замыкание напрямую
    let result2 = apply_operation(num, |x| x * x * x);
    println!("Куб числа {} равен {}", num, result2); // Вывод: Куб числа 5 равен 125
}

Запустите программу; вывод будет следующим:

Square of 5 is 25
Cube of 5 is 125

Итог

Замыкания в Rust — это мощная абстракция, которая предоставляет гибкий и выразительный способ инкапсуляции логики.

Замыкания могут захватывать переменные окружения и могут передаваться как параметры или возвращаться как возвращаемые значения. В сочетании с итераторами замыкания позволяют удобно реализовывать сложные задачи обработки данных.

Дизайн замыканий в Rust учитывает безопасность, производительность и время жизни — компилятор гарантирует на этапе компиляции, что замыкания не ссылаются на недействительные переменные, и обеспечивает нулевую стоимость абстракции для вызовов замыканий за счёт оптимизации встраивания.

Другие расширения