Замыкания в 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, остальные разбираются в тренажёре.
- Замыкание объявлено с move и только печатает захваченную String. Какие трейты вызова оно реализует?A)Только FnOnce: move означает потребление захваченногоB)FnMut и FnOnce: move снимает возможность реализовать FnC)Ни одного: замыкание с move вызывают через указатель fnD)Все три: чтения достаточно для самого сильного из них
показать ответ и разбор
+D)Все три: чтения достаточно для самого сильного из них// разбор: move управляет тем, как значение попало внутрь — по владению, а не по ссылке. Трейт вызова определяет другое: что замыкание делает с захваченным. Печать — это чтение через &self, поэтому получается Fn, а из него автоматически FnMut и FnOnce. FnOnce остаётся единственным вариантом там, где захваченное отдают наружу или потребляют, например возвращают саму String.
- Почему этот код не собирается?
let mut n = 0; let inc = || { n += 1; }; inc();A)Изменяющее замыкание вызывается через &mut — нужен let mut incB)Обычный 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 нужен дважды: на данных и на самом замыкании, и второй раз про него забывают.
- Функция возвращает замыкание. Чем 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>, только для вызываемых значений.
- Когда замыкание можно передать в параметр типа fn(i32) -> i32?A)Замыкание и указатель на функцию — это один и тот же типB)Только если оно ничего не захватывает из окруженияC)Только если оно помечено moveD)Только через границы Fn, FnMut и FnOnce: fn тут не подходит
показать ответ и разбор
+B)Только если оно ничего не захватывает из окружения// разбор: Без захвата у замыкания нет состояния, и компилятор приводит его к обычному указателю на функцию. С захватом состояние есть — оно живёт в структуре замыкания, и в один указатель не помещается: сборка падает с E0308, expected fn pointer, found closure. Поэтому в обычном API берут дженерик с F: Fn(..), а fn оставляют для FFI и таблиц колбэков, где нужен именно адрес.
- Почему 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, и потребляющее замыкание туда уже не отдать.
дальше
Теорию прочитали. Навык ставится повторением
В Сеньорчике эта подтема идёт в ежедневных сессиях: движок возвращает её, пока ответы не станут уверенными, и ведёт прогресс отдельно по каждой подтеме. Теория внутри тоже бесплатна, лимит только на количество вопросов в день.