Перейти к основному содержимому
  1. Rust/

Замыкания в Rust: функции с собственной памятью

1282 слова·7 минут· loading · loading · · ·Rust-middle
about-rust - Эта статья часть цикла.
Статей прочитано 0/36
0%
📚 Введение и дополнительные материалы
🟢 Начальный уровень (Rust-basic)
Не прочитана
Не прочитана
Не прочитана
Не прочитана
Не прочитана
🔵 Средний уровень (Rust-middle)

Введение
#

Замыкания (или лямбда-выражения) — это анонимные функции, которые мы можем создавать прямо на лету и передавать в другие функции. Но их главная суперсила — умение запоминать контекст, то есть захватывать переменные из той области видимости, где они были объявлены.

В языках программирования с автоматической сборкой мусора (GC) разработчики даже не задумываются о том, как замыкания хранят эти переменные. Но в Rust, где памятью управляет компилятор, правила работы с замыканиями подчиняются строгой логике владения и заимствования.

В этой статье мы разберем, как устроены замыкания в Rust, чем они отличаются от обычных функций, как делятся на трейты Fn, FnMut и FnOnce и как компилятор собирает их «под капотом».


1. Указатели на функции (fn)
#

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

В Rust имя функции может выступать в роли указателя на нее. Тип такого указателя пишется строчными буквами: например, fn(i32) -> i32.

  • Как это устроено в памяти:
    • Указатель fn — это «тонкий» указатель. Он хранит только один адрес — адрес первой инструкции скомпилированного кода в памяти.
    • Такие функции не имеют состояния. Они не могут заглянуть во внешние переменные и прочитать что-то, чего им не передали в аргументах.

2. Замыкания: функции с рюкзаком за спиной
#

Замыкание — это анонимная функция, у которой за спиной есть невидимый «рюкзак» (контекст), куда она может складывать внешние переменные.

let coefficient = 10;
let multiply = |x| x * coefficient; // Замыкание захватило coefficient!

Аргументы замыкания пишутся внутри двух вертикальных черт |...|, а тело — следом. Фигурные скобки нужны только для многострочного кода.

Вывод типов
#

Компилятор Rust сам понимает, какие типы данных принимает и возвращает замыкание, анализируя место его первого вызова. Но при желании вы можете прописать типы вручную:

let multiply = |x: i32| -> i32 { x * coefficient };

Захват окружения и слово move
#

  • По умолчанию (по ссылке): Замыкание пытается быть максимально экономным. Оно не копирует переменные, а берет их по ссылке. Компилятор сам решает, какая ссылка нужна: обычная для чтения (&T) или мутабельная для записи (&mut T).
  • Принудительный перенос (move): Если замыкание должно жить дольше, чем окружающий код (например, мы возвращаем его из функции или передаем в другой поток), нам нужно перенести владение переменными внутрь замыкания. Для этого перед чертами пишется слово move:
    let print_data = move || println!("Данные: {}", data);

3. Три трейта замыканий: Fn, FnMut и FnOnce
#

В зависимости от того, что именно замыкание делает с переменными в своем «рюкзаке», оно автоматически реализует один из трех трейтов:

graph TD
    FnOnce --> FnMut
    FnMut --> Fn
    
    style FnOnce fill:#f9f,stroke:#333,stroke-width:2px,color:#000
    style FnMut fill:#ccf,stroke:#333,stroke-width:1px,color:#000
    style Fn fill:#cfc,stroke:#333,stroke-width:1px,color:#000
  1. FnOnce (Поглотитель)
    • Что делает: Забирает захваченные переменные по значению и расходует их (уничтожает или передает дальше).
    • Ограничение: Поскольку данные расходуются при первом вызове, вызвать такое замыкание можно только один раз.
  2. FnMut (Мутатор)
    • Что делает: Изменяет захваченные переменные (использует эксклюзивную ссылку &mut self).
    • Ограничение: Можно вызывать сколько угодно раз, но только последовательно (нельзя запустить одновременно из разных потоков без синхронизации).
  3. Fn (Читатель)
    • Что делает: Только читает данные (использует разделяемую ссылку &self).
    • Ограничение: Никаких ограничений! Можно вызывать многократно и параллельно из разных потоков.

Иерархия трейтов
#

Они образуют матрешку:

  • Любое замыкание типа Fn автоматически умеет работать как FnMut и FnOnce.
  • Обычные указатели на функции (fn) не имеют состояния, поэтому всегда реализуют самый верхний трейт Fn.

4. Как замыкания работают в коде
#

Передача в функции
#

Поскольку точный тип замыкания анонимен, мы используем дженерики или impl Trait:

fn execute(mut action: impl FnMut()) {
    action();
}

Возврат из функций
#

Вернуть замыкание напрямую нельзя — компилятор не знает его точный размер. Поэтому мы возвращаем его через impl Trait с обязательным словом move (чтобы забрать переменные с собой):

fn create_adder(x: i32) -> impl Fn(i32) -> i32 {
    move |y| x + y
}

Хранение в структурах
#

Если структура должна хранить замыкание, у нас есть два пути:

  1. Дженерики (быстро): Структура жестко привязывается к типу замыкания во время компиляции. Нет накладных расходов.
    struct Runner<F: Fn()> { action: F }
  2. Box (гибко): Замыкание прячется в кучу под динамический указатель. Позволяет хранить разные замыкания в одном типе структуры.
    struct Runner { action: Box<dyn Fn()> }

5. Что происходит под капотом компилятора?
#

Замыкания в Rust — это просто удобный синтаксический сахар. На самом деле компилятор превращает каждое замыкание в обычную структуру:

  1. Создается анонимная структура, полями которой становятся все захваченные переменные.
  2. Для этой структуры пишется реализация вызова (методы трейтов call, call_mut или call_once).
  3. В месте вызова замыкания компилятор просто подставляет обычный вызов метода этой структуры.

6. Времена жизни на лету (HRTBs)
#

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

Для этого используется синтаксис Higher-Rank Trait Bounds (или HRTBs) через ключевое слово for<'a>:

fn run_with_ref<F>(f: F)
where F: for<'a> Fn(&'a str) 
{
    let local = String::from("данные");
    f(&local); // Время жизни ссылки ограничено только этой функцией!
}

Конструкция for<'a> Fn(&'a str) говорит компилятору: «Это замыкание умеет принимать ссылку на строку с абсолютно любым временем жизни 'a, которое мы дадим ему в момент вызова».

Давайте посмотрим, как работают замыкания разного типа, на интерактивных примерах ниже:

Замыкания в Rust
Шаг 1/4
 1fn call_with_five(f: fn(i32) -> i32) {
 2    println!("Вызов fn pointer: {}", f(5));
 3}
 4
 5fn main() {
 6    // 1. Указатель на обычную функцию
 7    fn add_one(x: i32) -> i32 { x + 1 }
 8    call_with_five(add_one);
 9    
10    // 2. Замыкание с захватом внешнего окружения
11    let coefficient = 10;
12    let closure = |x| x * coefficient;
13    
14    // call_with_five(closure); // Ошибка! Замыкание с состоянием не приводится к fn pointer.
15    println!("Вызов замыкания: {}", closure(5));
16}
17

Указатели на функции и замыкания

  • Указатель на функцию fn является “тонким” указателем: он хранит только адрес машинного кода.
  • Замыкание (анонимная функция) может захватывать внешние переменные и нести с собой состояние.
  • Замыкания получают уникальный анонимный тип, генерируемый компилятором.

Проверь свои знания!
#

Пройдите короткий интерактивный тест, чтобы закрепить понимание замыканий в Rust.

История обновлений

  • Создана новая статья о замыканиях в Rust, их типах (Fn, FnMut, FnOnce), низкоуровневой реализации и HRTBs с примерами кода и интерактивными тестами.
Статья прочитана
Пожалуйста, оцените насколько статья была вам полезна и понятна
Цикл статей
about-rust - Эта статья часть цикла.
Статей прочитано 0/36
0%
📚 Введение и дополнительные материалы
🟢 Начальный уровень (Rust-basic)
Не прочитана
Не прочитана
Не прочитана
Не прочитана
Не прочитана
🔵 Средний уровень (Rust-middle)

Связанные статьи