История вопроса восходит к решению Rust реализовать замыкания как абстракции нулевой стоимости через анонимные структуры, а не объекты функции с автоматическим управлением памятью. В отличие от таких языков, как JavaScript или Python, Rust должен непосредственно встраивать правила владения, заимствования и изменяемости в тип замыкания. Три трейта — Fn, FnMut и FnOnce — образуют строгую иерархию в зависимости от параметра self в их методах call, позволяя компилятору проверять во время компиляции, что использование замыкания соответствует инвариантам безопасности памяти его захваченного окружения.
Проблема заключается в различии между тем, как замыкание захватывает переменные (по ссылке или по значению через move), и как оно использует их внутри. FnOnce требует self (потребляя владение), позволяя замыканию перемещать захваченные переменные из своего окружения, но ограничивая его одним вызовом. FnMut требует &mut self, позволяя изменять захваченное состояние, но требуя уникального доступа к самому замыканию. Fn требует &self, позволяя множественные параллельные вызовы, но запрещая изменение захваченных переменных, если не используется внутреннее изменение. Замыкание, которое перемещает тип, не являющийся Copy, в своё тело становится FnOnce, поскольку первый вызов оставляет окружение в состоянии перемещения, что делает последующие вызовы недопустимыми. Кандидаты часто путают ключевое слово move, которое просто заставляет захват происходить по значению, с трейтом FnOnce, не осознавая, что move-замыкание, содержащее только Copy типы, всё равно реализует Fn.
Решение заключается в выборе наименее ограничительного предела трейта, необходимого для API. Если замыкание вызывается ровно один раз, используйте FnOnce, чтобы принимать самое широкое разнообразие замыканий (включая те, которые потребляют своё окружение). Если требуется несколько вызовов с изменениями, используйте FnMut. Для параллельного или повторного чтения используйте Fn. Компилятор автоматически выводит эти реализации на основе анализа захвата, не требуя ручной реализации трейта.
fn apply_once<F: FnOnce()>(f: F) { f(); } fn apply_mut<F: FnMut()>(mut f: F) { f(); f(); } fn apply_fn<F: Fn()>(f: F) { f(); f(); } let data = vec![1, 2, 3]; let consume = move || drop(data); // FnOnce: Vec не является Copy apply_once(consume); let mut count = 0; let mut increment = || { count += 1; }; // FnMut: изменяет захват apply_mut(&mut increment); let value = 42; let print = move || println!("{}", value); // Fn: i32 является Copy apply_fn(print); apply_fn(print); // Действительно: print является Fn
Рассмотрим асинхронный планировщик задач в высоконагруженном веб-сервере, который принимает пользовательские хуки для обработки входящих запросов. API планировщика изначально требовал, чтобы все хуки реализовывали Fn для возможности параллельного выполнения.
Описание проблемы: Новая функция требовала от хуков поддерживать статистику по подключению, что требовало изменения захваченных счетчиков. Разработчики пытались передать замыкания с move, захватывающие переменные mut counter, но компилятор отклонил их, поскольку Fn требует &self, что не может изменить принадлежащие mut поля без внутренней изменяемости. Команда столкнулась с выбором между ослаблением предела трейта или реорганизацией сигнатуры хуков.
Решение 1: Внутренняя изменяемость с атомарными типами:
Заменить счетчик u64 на AtomicU64 и захватывать его через Arc. Замыкание реализует Fn, поскольку изменение происходит через атомарные операции на &self, не требуя изменения доступа к самому замыканию.
Плюсы: Поддерживает предел Fn, позволяет планировщику выполнять хуки одновременно из нескольких потоков без синхронизации на самом замыкании.
Минусы: Вводит накладные расходы на уровне аппаратного обеспечения и сложность упорядочивания памяти. Требует выделения Arc, даже для однопоточного использования, что подрывает принципы нулевой стоимости абстракций для простых счетчиков.
Решение 2: Граница FnMut с последовательным выполнением:
Изменить API планировщика, чтобы принимать замыкания FnMut. Планировщик хранит хуки в Vec<Box<dyn FnMut()>> и вызывает их последовательно, удерживая доступ &mut.
Плюсы: Нулевые накладные расходы времени выполнения для изменения. Гарантия на этапе компиляции, что не произойдут гонки данных, так как система типов обеспечивает уникальный доступ во время вызова.
Минусы: Предотвращает параллельный вызов одного и того же хука и усложняет внутреннее хранилище планировщика (требует &mut self на самом планировщике). Нарушает совместимость с существующими хуками Fn, если не использовать обобщенные реализации.
Выбранное решение: Решение 2 (FnMut) было выбрано, поскольку архитектура сервера обрабатывала подключения в каждом потоке, устраняя необходимость в параллельном выполнении хуков. Команда предпочла безопасность на этапе компиляции гибкости параллельных хуков, приняв изменение API как разрывное, но корректное эволюционное решение.
Результат: Планировщик успешно обработал состоявшие хуки без накладных расходов времени выполнения. Система типов предотвратила тонкую ошибку, где два потока могли бы одновременно увеличивать неатомарный счетчик, что было бы возможным, если бы использовался RefCell с Fn без надлежащей синхронизации.
Делает ли ключевое слово move в определении замыкания автоматически так, что это замыкание реализует FnOnce вместо Fn или FnMut?
Нет. Ключевое слово move определяет только то, что захваченные переменные перемещаются в окружение замыкания по значению, а не заимствуются. Реализация трейта зависит исключительно от того, как тело замыкания использует свои захваты. Если замыкание перемещает тип, не являющийся Copy, из своего окружения (потребляя его), оно реализует FnOnce. Если оно только изменяет захваты, оно реализует FnMut. Если оно только читает переменную или вызывает методы &self, оно реализует Fn (и другие). Для захватов по ссылке (&T или &mut T) замыкание удерживает ссылки. Если оно захватывает &mut T, оно обычно реализует FnMut, поскольку его вызов требует уникального доступа к самому замыканию, чтобы сохранить уникальность изменяемого заимствования. Если оно захватывает &T, оно реализует Fn.
Почему функцию, принимающую FnOnce, можно вызвать с замыканием, реализующим Fn, но не наоборот?
Fn является подклассом FnMut, который является подклассом FnOnce. Это означает, что каждое замыкание, реализующее Fn, автоматически реализует FnMut и FnOnce, но обратное неверно. Параметр функции, ограниченный FnOnce, принимает любое замыкание, которое может быть вызвано один раз, что включает те, которые могут быть вызваны несколько раз (Fn и FnMut). Напротив, функция, требующая Fn, требует, чтобы замыкание поддерживало вызов через разделённую ссылку (&self), что замыкания, потребляющие своё окружение (**только FnOnce), не могут удовлетворить. Это соответствует стандартному подтипированию: более способный тип (Fn) может использоваться там, где требуется менее способный (FnOnce).
Как компилятор определяет, какой трейт реализует замыкание, когда оно захватывает ссылки на переменные в окружающей области?
Компилятор анализирует тело замыкания, чтобы увидеть, как захваченные переменные используются. Если замыкание перемещает переменную, захваченную (и тип не является Copy), оно реализует FnOnce. Если оно изменяет захваченную переменную (присваивает ей или вызывает методы &mut self), оно реализует FnMut (и FnOnce). Если оно только читает переменную или вызывает методы &self, оно реализует Fn (и другие). Для захватов по ссылке (&T или &mut T) замыкание удерживает ссылки. Если оно захватывает &mut T, оно обычно реализует FnMut, поскольку вызов требует уникального доступа к самому замыканию, чтобы сохранить уникальность изменяемого заимствования. Если оно захватывает &T, оно реализует Fn.