Введение#
Замыкания (или лямбда-выражения) — это анонимные функции, которые мы можем создавать прямо на лету и передавать в другие функции. Но их главная суперсила — умение запоминать контекст, то есть захватывать переменные из той области видимости, где они были объявлены.
В языках программирования с автоматической сборкой мусора (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
FnOnce(Поглотитель)- Что делает: Забирает захваченные переменные по значению и расходует их (уничтожает или передает дальше).
- Ограничение: Поскольку данные расходуются при первом вызове, вызвать такое замыкание можно только один раз.
FnMut(Мутатор)- Что делает: Изменяет захваченные переменные (использует эксклюзивную ссылку
&mut self). - Ограничение: Можно вызывать сколько угодно раз, но только последовательно (нельзя запустить одновременно из разных потоков без синхронизации).
- Что делает: Изменяет захваченные переменные (использует эксклюзивную ссылку
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
}Хранение в структурах#
Если структура должна хранить замыкание, у нас есть два пути:
- Дженерики (быстро): Структура жестко привязывается к типу замыкания во время компиляции. Нет накладных расходов.
struct Runner<F: Fn()> { action: F } - Box (гибко): Замыкание прячется в кучу под динамический указатель. Позволяет хранить разные замыкания в одном типе структуры.
struct Runner { action: Box<dyn Fn()> }
5. Что происходит под капотом компилятора?#
Замыкания в Rust — это просто удобный синтаксический сахар. На самом деле компилятор превращает каждое замыкание в обычную структуру:
- Создается анонимная структура, полями которой становятся все захваченные переменные.
- Для этой структуры пишется реализация вызова (методы трейтов
call,call_mutилиcall_once). - В месте вызова замыкания компилятор просто подставляет обычный вызов метода этой структуры.
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.
История обновлений
- Создана новая статья о замыканиях в Rust, их типах (Fn, FnMut, FnOnce), низкоуровневой реализации и HRTBs с примерами кода и интерактивными тестами.


