Кто, черт возьми, такие Fn, FnMut и FnOnce? Rust-замыкания под микроскопом

Онлайн-тренажер Python для начинающих

Изучайте Python легко и без перегрузки теорией. Решайте практические задачи с автоматической проверкой, получайте подсказки на русском языке и пишите код прямо в браузере — без необходимости что-либо устанавливать.

Начать курс

Замыкания в Rust: магия или строгая математика?

Если вы когда-нибудь писали на Rust что-то вроде |x| x + 1, вы использовали замыкания. Но как только вы пытаетесь передать это замыкание в функцию или сохранить в структуру, компилятор вдруг начинает требовать: «А какой трейт? Fn, FnMut или FnOnce?».

И тут многие впадают в ступор. Особенно если вы пришли из JavaScript, Python или Ruby, где замыкания — это «просто функции, которые захватывают переменные». В Rust всё сложнее (и интереснее).

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

Коротко о главном: три трейта замыканий

В Rust есть три трейта, которые реализуют замыкания:

  • FnOnce — может быть вызвано один раз. Забирает владение захваченными переменными.
  • FnMut — может быть вызвано многократно, но может изменять захваченные переменные (берёт &mut).
  • Fn — может быть вызвано многократно и не изменяет захваченные переменные (берёт &).

Каждое замыкание автоматически реализует все подходящие трейты. То есть если замыкание реализует Fn, оно автоматически реализует и FnMut, и FnOnce. Но наоборот — не работает.

Почему? Потому что Fn — это самый строгий, а FnOnce — самый слабый. Иерархия наследования выглядит так:

FnOnce  ←  FnMut  ←  Fn

То есть FnMut: FnOnce, а Fn: FnMut. Запомните эту лесенку — она ключевая.

Как Rust решает, какой трейт дать замыканию?

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

  • По ссылке (&T) — замыкание только читает переменную.
  • По изменяемой ссылке (&mut T) — замыкание изменяет переменную.
  • По значению (T) — замыкание забирает владение переменной.

Вот как это влияет на трейты:

Способ захватаРеализуемые трейтыПример
Только &TFn + FnMut + FnOnce|| println!("{}", x)
&mut T (но не T)FnMut + FnOnce|| x += 1
T (владение)Только FnOnce|| drop(x) или move || x

Давайте разберём каждый случай с живым кодом.

Пример 1: Только чтение → Fn

let x = 42;
let read_closure = || println!("x = {}", x);

// Можно вызывать сколько угодно раз
read_closure();
read_closure();

// И даже передать в функцию, требующую Fn
fn call_fn<F: Fn()>(f: F) {
    f();
}
call_fn(read_closure);

Здесь замыкание просто читает x. Ему не нужно его менять или забирать. Rust понимает: «Ок, это безопасно — даём Fn».

Пример 2: Изменение → FnMut

let mut x = 42;
let mut mut_closure = || {
    x += 1;
    println!("x = {}", x);
};

mut_closure(); // x = 43
mut_closure(); // x = 44

// Передаём в функцию, требующую FnMut
fn call_fn_mut<F: FnMut()>(mut f: F) {
    f();
    f();
}
call_fn_mut(mut_closure);

Обратите внимание: мы объявили mut_closure как let mut. Почему? Потому что вызов FnMut требует изменяемой ссылки на само замыкание (оно же хранит &mut x внутри).

Важно: Если попробовать передать это замыкание в функцию, которая ждёт Fn, компилятор выдаст ошибку. Замыкание, которое изменяет захваченные данные, не может быть Fn — оно нарушило бы правило «никаких изменений».

Пример 3: Захват владения → FnOnce

let s = String::from("hello");

let once_closure = || {
    drop(s); // забираем владение!
    println!("s is dropped");
};

// Работает — один раз
once_closure();

// А вот второй раз уже не получится:
// once_closure(); // ОШИБКА: use of moved value: `once_closure`

Почему так? Потому что drop(s) перемещает s внутрь замыкания. После первого вызова s уничтожено, и замыкание больше не может быть вызвано — оно потеряло свои данные.

Компилятор видит: «Замыкание забирает владение → только FnOnce».

Ключевой нюанс: ключевое слово move

Иногда вы хотите принудительно передать владение переменной в замыкание. Для этого есть ключевое слово move:

let x = vec![1, 2, 3];

let closure = move || {
    println!("{:?}", x); // x теперь принадлежит замыканию
};

// println!("{:?}", x); // ОШИБКА: x moved

closure();
closure(); // А вот это — сюрприз! Работает!

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

Блоги

Рекомендаций по книгам