сеньорчикОткрыть в Telegram
← вся теориятеория к собесу · Трейты и дженерики

Замыкания в Rust: Fn, FnMut и FnOnce

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

Спрашивают почти на каждом собесе, и почти всегда одинаково: «в чём разница между Fn, FnMut и FnOnce». Вопрос дешёвый на вид, но по ответу сразу видно, понимаешь ли ты, что замыкание это структура с данными, а не просто кусок кода.

Стержень: трейт вызова определяется не тем, как значения попали внутрь, а тем, что замыкание с ними делает - читает, меняет или потребляет.

// Формулировки: «расскажи про Fn, FnMut, FnOnce», «чем замыкание отличается от функции», «как вернуть замыкание из функции».

Замыкание это структура с захваченным

Компилятор разворачивает замыкание в анонимный тип: поля - захваченные значения, метод - тело. Поэтому у двух одинаковых с виду замыканий разные типы, и записать этот тип руками нельзя - только impl Fn(..) или Box<dyn Fn(..)>.

Захват бывает по ссылке, по изменяемой ссылке или по владению: компилятор берёт минимально необходимое, а move заставляет забрать владение. Именно move нужен потоку и tokio::spawn - там замыкание переживает функцию, в которой родилось.

// Отсюда следствие для размера: замыкание, захватившее String, весит как её заголовок, а не как кусок кода.

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

Fn, FnMut, FnOnce - по способу обращения

FnOnce берёт self по значению: захваченное потребляется, вызвать можно один раз. FnMut берёт &mut self: замыкание меняет захваченное между вызовами. Fn берёт &self: только читает, поэтому вызывать можно сколько угодно и из нескольких мест.

Иерархия вложенная: всякое Fn годится и как FnMut, и как FnOnce. Поэтому в сигнатуре ставят самую слабую границу из достаточных - так пройдёт больше замыканий. unwrap_or_else зовёт колбэк максимум раз и требует FnOnce, а map у итератора зовёт на каждый элемент и требует FnMut.

// Грабля из практики: замыкание меняет счётчик, а переменная объявлена без mut - компилятор просит let mut inc, потому что вызов идёт через &mut self.

let mut n = 0;
let mut inc = || { n += 1; };
inc();
inc();
FnOnce
потребляет захваченное, вызывается однократно
FnMut
меняет захваченное, нужен &mut на само замыкание
Fn
только читает, вызывается многократно

move ≠ FnOnce, и как вернуть замыкание

Частая путаница: move не делает замыкание одноразовым. Оно лишь говорит, что значения захвачены по владению. Если тело только читает захваченную String, замыкание остаётся Fn, и именно так работает большинство замыканий, уезжающих в поток. FnOnce получается там, где захваченное отдают наружу или потребляют.

Возврат: impl Fn(i32) -> i32 - один конкретный анонимный тип, без аллокации и с инлайнингом. Как только в разных ветках возвращаются разные замыкания или их складывают в общий Vec, единственного типа нет - тогда Box<dyn Fn(..)> с таблицей методов и кучей.

// Отдельный случай - замыкание без захвата: у него нет состояния, и оно приводится к обычному указателю fn(i32) -> i32. С захватом такое приведение падает с E0308.

fn adder(k: i32) -> impl Fn(i32) -> i32 {
    move |x| x + k
}
let fs: Vec<Box<dyn Fn(i32) -> i32>> =
    vec![Box::new(adder(1)), Box::new(|x| x * 2)];
impl Fn
статический возврат замыкания без аллокации
Box<dyn Fn>
трейт-объект: разные замыкания одного вида

Как отвечать: «В чём разница между Fn, FnMut и FnOnce?»

Это трейты вызова, и различаются они тем, как замыкание обращается с захваченным окружением. Fn берёт self по общей ссылке и только читает, FnMut берёт изменяемую ссылку и может менять захваченное, FnOnce забирает self по значению и потому вызывается один раз - обычно потому, что отдаёт захваченное наружу. Иерархия вложенная: всякое Fn подходит и под FnMut, и под FnOnce. В сигнатуре я пишу самую слабую границу, которая мне подходит: если колбэк зовётся один раз - FnOnce, если в цикле - FnMut. И отдельно: move не делает замыкание FnOnce, он лишь означает захват по владению.

Ответ показывает механику (self по ссылке, по &mut, по значению), знание иерархии и практическое правило выбора границы. Оговорка про move закрывает самое частое заблуждение ещё до того, как интервьюер успел его проверить.

На чём валятся

  • Считают, что move всегда даёт FnOnce: замыкание с move, которое только читает, обычный Fn.
  • Забывают mut у переменной замыкания, когда оно меняет захваченное, и не могут объяснить E0596.
  • Ставят FnOnce там, где колбэк зовут многократно, и удивляются, что вызов не компилируется.
  • Не знают, что замыкание без захвата приводится к fn-указателю, а с захватом - нет.
  • Возвращают Box<dyn Fn> по привычке, хотя impl Fn обошёлся бы без аллокации.

Проверьте себя

Пять вопросов из банка по этой подтеме. Всего их 12, остальные разбираются в тренажёре.

  1. #rs_closures1 / 5
    Замыкание объявлено с move и только печатает захваченную String. Какие трейты вызова оно реализует?
    A)Только FnOnce: move означает потребление захваченного
    B)FnMut и FnOnce: move снимает возможность реализовать Fn
    C)Ни одного: замыкание с move вызывают через указатель fn
    D)Все три: чтения достаточно для самого сильного из них
    показать ответ и разбор
    +D)Все три: чтения достаточно для самого сильного из них

    // разбор: move управляет тем, как значение попало внутрь — по владению, а не по ссылке. Трейт вызова определяет другое: что замыкание делает с захваченным. Печать — это чтение через &self, поэтому получается Fn, а из него автоматически FnMut и FnOnce. FnOnce остаётся единственным вариантом там, где захваченное отдают наружу или потребляют, например возвращают саму String.

  2. #rs_closures2 / 5
    Почему этот код не собирается?
    let mut n = 0;
    let inc = || { n += 1; };
    inc();
    A)Изменяющее замыкание вызывается через &mut — нужен let mut inc
    B)Обычный i32 из замыкания не поменять, нужен Cell<i32>
    C)Замыканию не хватает move, иначе n внутри недоступна
    D)После объявления замыкания n считается перемещённой в него
    показать ответ и разбор
    +A)Изменяющее замыкание вызывается через &mut — нужен let mut inc

    // разбор: Замыкание меняет захваченное, поэтому вызов идёт через &mut self. Взять изменяемую ссылку на inc нельзя, пока сама привязка не mut: компилятор роняет сборку с E0596, cannot borrow inc as mutable. Лечится одним словом — let mut inc. Грабля в том, что mut нужен дважды: на данных и на самом замыкании, и второй раз про него забывают.

  3. #rs_closures3 / 5
    Функция возвращает замыкание. Чем impl Fn отличается от Box<dyn Fn> в возврате?
    A)impl Fn возвращает копию замыкания, Box<dyn Fn> — ссылку на него
    B)impl Fn годится только для замыканий без захвата
    C)impl Fn — один конкретный тип без аллокации, Box<dyn Fn> — любой ценой косвенности
    D)Разницы нет: замыкание в возврате всё равно боксируется
    показать ответ и разбор
    +C)impl Fn — один конкретный тип без аллокации, Box<dyn Fn> — любой ценой косвенности

    // разбор: impl Fn — статическая диспетчеризация: тип анонимный, но единственный, вызов инлайнится, кучи не нужно. Как только в разных ветках возвращаются разные замыкания или их складывают в один Vec, единственного типа больше нет — тогда Box<dyn Fn> с таблицей методов и аллокацией. Это тот же выбор, что impl Trait против Box<dyn Trait>, только для вызываемых значений.

  4. #rs_closures4 / 5
    Когда замыкание можно передать в параметр типа fn(i32) -> i32?
    A)Замыкание и указатель на функцию — это один и тот же тип
    B)Только если оно ничего не захватывает из окружения
    C)Только если оно помечено move
    D)Только через границы Fn, FnMut и FnOnce: fn тут не подходит
    показать ответ и разбор
    +B)Только если оно ничего не захватывает из окружения

    // разбор: Без захвата у замыкания нет состояния, и компилятор приводит его к обычному указателю на функцию. С захватом состояние есть — оно живёт в структуре замыкания, и в один указатель не помещается: сборка падает с E0308, expected fn pointer, found closure. Поэтому в обычном API берут дженерик с F: Fn(..), а fn оставляют для FFI и таблиц колбэков, где нужен именно адрес.

  5. #rs_closures5 / 5
    Почему unwrap_or_else требует FnOnce, а map у итератора — FnMut?
    A)FnOnce быстрее, и для однократного вызова компилятор выбирает его сам
    B)Замыкание в итераторе обязано менять захваченное состояние
    C)Это историческое различие: границы взаимозаменяемы
    D)unwrap_or_else зовёт замыкание максимум раз, map — на каждый элемент
    показать ответ и разбор
    +D)unwrap_or_else зовёт замыкание максимум раз, map — на каждый элемент

    // разбор: Границу выбирают самую слабую из достаточных: чем слабее требование, тем больше замыканий пройдёт. unwrap_or_else дёргает функцию не больше одного раза, поэтому ей хватает FnOnce — и туда пролезет даже замыкание, отдающее захваченное наружу. map зовёт замыкание многократно, значит нужно хотя бы FnMut, и потребляющее замыкание туда уже не отдать.

дальше

Теорию прочитали. Навык ставится повторением

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