У вас бывало такое, что вы никак не можете скомпилировать код с замыканиями в Rust? Уже и все варианты Fn
-трейтов перебрали, и move
написали везде, где можно, а borrow checker все равно не унимается? И тут оказывается, что просто нужно внутри замыкания клонировать переданную переменную окружения! Сложно и непонятно. Дурацкий привереда Rust.
На самом деле довольно просто понять, почему так происходит и на что влияет move
, а на что — клонирование. Но отсутствие подобного понимания я наблюдаю не только у начинающих программистов, но и у вполне зрелых. Хуже того, есть статьи, в которых это объясняется неправильно.
Итак, ключ к пониманию — это представление, что замыкание на самом деле реализуется компилятором как структура. Причем захваченные переменные окружения становятся полями структуры, а тело замыкания становится телом метода для вызова (одного из трех возможных: Fn::call
, FnMut::call_mut
, FnOnce::call_once
).
Рассмотрим пример:
fn new_closure(a: i32) -> impl Fn(i32) -> i32 {
move |x| a * x
}
Заметьте, мы возвращаем замыкание, тип которого реализует Fn
, однако при этом должны написать move
перед определением замыкания. В некоторых статьях ошибочно утверждается, что move
необходим для FnOnce
-замыканий. Ошибка заключается в том, что move
относят не к самому объекту замыкания и способу хранения переменных окружения в нем, а к способу вызова функционального тела замыкания. То есть, move
влияет на то, захватит ли само замыкание (не его тело, а его структура!) переменные окружения во владение или будет заимствовать по ссылке.
Пример определения замыкания выше можно упрощенно представить таким псевдокодом:
// Для `move |x| a * x`
struct Closure {
a: i32
}
impl Fn<i32> for Closure {
type Output = i32;
fn call(&self, x: i32) -> F::Output {
self.a * x
}
}
// Для `|x| a * x`
struct Closure<'a> {
a: &'a i32
}
impl Fn<i32> for Closure<'_> {
type Output = i32;
fn call(&self, x: i32) -> F::Output {
self.a * x
}
}
Как видно, никаких изменений тело замыкания не претерпело. Поэтому и FnOnce
-замыкания могут не владеть своим окружением, а заимствовать его:
fn map(x: usize, fun: impl FnOnce(usize) -> usize) -> usize {
fun(x)
}
let msg = String::from("hello");
let product = |x| msg.len() * x; // Заимствует `msg`
let b = map(7, product);
println!("{msg} {b}");
hello 35
Но такое замыкание нельзя будет вернуть из функции:
fn new_closure(msg: String) -> impl FnOnce(usize) -> usize {
|x| msg.len() * x
}
При компиляции возникает ошибка:
error[E0373]: closure may outlive the current function, but it borrows `msg`, which is owned by the current function
--> src/main.rs:2:5
|
2 | |x| msg.len() * x
| ^^^ --- `msg` is borrowed here
| |
| may outlive borrowed value `msg`
|
note: closure is returned here
--> src/main.rs:2:5
|
2 | |x| msg.len() * x
| ^^^^^^^^^^^^^^^^^
help: to force the closure to take ownership of `msg` (and any other referenced variables), use the `move` keyword
|
2 | move |x| msg.len() * x
| ++++
И понятно почему так происходит. Структура
struct Closure<'a> {
msg: &'a String
}
Имеет лайфтайм области жизни внешней переменной msg
, которая уничтожается в конце тела функции. Значит само замыкание не может пережить вызов функции и не может быть возвращено из нее. Нужна структура замыкания такого вида:
struct Closure {
msg: String
}
Можно этого добиться с помощью слова move
, как советует компилятор, но можно сделать и иначе:
fn new_closure(msg: String) -> impl FnOnce(usize) -> usize {
|x| msg.into_bytes().len() * x
}
Такой код скомпилируется. Потому что в теле замыкания вызов into_bytes
завладевает переменной msg
, и компилятор сам догадывается её переместить в замыкание, а не заимствовать.
Итак, работа с move
сводится к следующим правилам:
- Если замыкание объявлено без ключевого слова
move
, то оно заимствует внешние переменные по ссылке, если это возможно, и захватывает во владение в противном случае. - Если замыкание объявлено с ключевым словом
move
, то оно безусловно захватывает внешние переменные во владение.
Теперь рассмотрим ситуацию, когда возникает необходимость клонировать переменную окружения внутри замыкания. С трейтом Fn
последний пример не работает:
fn new_closure(msg: String) -> impl Fn(usize) -> usize {
|x| msg.into_bytes().len() * x
}
Ошибка:
error[E0507]: cannot move out of `msg`, a captured variable in an `Fn` closure
--> src/main.rs:2:9
|
1 | fn new_closure(msg: String) -> impl Fn(usize) -> usize {
| --- captured outer variable
2 | |x| msg.into_bytes().len() * x
| --- ^^^ ------------ `msg` moved due to this method call
| | |
| | move occurs because `msg` has type `String`, which does not implement the `Copy` trait
| captured by this `Fn` closure
|
note: this function takes ownership of the receiver `self`, which moves `msg`
Потому что реализовано наше замыкание будет примерно так:
struct Closure {
msg: String
}
impl Fn<usize> for Closure {
type Output = usize;
fn call(&self, x: usize) -> F::Output {
self.msg.into_bytes().len() * x
}
}
self
в функцию вызова принимается по ссылке, поэтому невозможно переместить self.msg
внутрь into_bytes
. Если только его не склонировать:
fn new_closure(msg: String) -> impl Fn(usize) -> usize {
|x| msg.clone().into_bytes().len() * x
}
Однако, опять ошибка! Теперь уже потому, что msg
стал заимствоваться внутри тела замыкания, при вызове msg.clone()
, и компилятор сохранил его в структуре как ссылочное поле. Здесь мы обязаны написать move
руками, чтобы заставить компилятор сделать поле msg
в структуре замыкания владеющим:
fn new_closure(msg: String) -> impl Fn(usize) -> usize {
move |x| msg.clone().into_bytes().len() * x
}
На заметку: если необходимо переместить некоторую переменную в замыкание, а другую принять по ссылке, то можно присвоить новой переменной ссылку и перемещать в замыкание уже её:let msg = String::from("hello"); let c = 25; let product = { let msg = &msg; move |x| msg.len() * x * c };
Итак, что в итоге?
- Замыкание — это структура, в поля которой записываются переменные окружения, а тело становится телом метода
Fn::call
,FnMut::call_mut
илиFnOnce::call_once
. - Ключевое слово
move
управляет способом захвата переменных в сам объект замыкания, а не их использованием в теле замыкания при вызове. - Чтобы разрешить конфликты владения в самом теле, иногда приходится использовать клонирование.
Надеюсь теперь с замыканиями будет меньше мороки. Подобный же подход вы можете применить к пониманию того, как работают async
-функции и блоки. После этого, разруливание ссылок и перемещений для вас станет делом техники.
Комментарии (7)
Amareis
16.11.2022 15:04+2Кстати, теперь понятней стало и смысл FnOnce - оно поглощает self, как раз этот объект замыкания.
Хорошая статья, спасибо.
orekh
16.11.2022 20:02Странно, что замыкания создаются так неявно.
В других частях языка наоборот, упор на прозрачность: сигнатуры функций прописывающие правила владения, возврат result вместо бросания ошибок, option вместо null-ов или «-1» и тому подобного.
Замыкания же, будто пришли прямиком из яваскрипта. Нельзя указать тип владения, нельзя прописать лайфтаймы, загадочные типы, котрые друг другу не равны. Их лишь подпёрли костылём в виде слова move.
freecoder_xx Автор
16.11.2022 21:22+2Отчасти соглашусь с вами. Но все-таки пользоваться ими довольно просто. В Rust есть много подобных компромиссов. Скажем, в тех же сигнатурах функций есть специальный синтаксис для значений
self
и lifetime elision. Которые избавляют от синтаксического шума в наиболее популярных случаях использования.
Для замыканий, думаю, основная проблема в том, что они определяются в контексте выражения и из-за захвата неразрывно связаны с этим контекстом. Как пользователю в принципе записать подобный хитрый тип - совершенно непонятно.
AnthonyMikh
16.11.2022 20:27+4Мне кажется, стоило бы пояснить, по каким именно правилам выполняется рассахаривание замыканий, потому что сейчас псевдокод берётся из воздуха. А правила не такие уж и сложные:
- если замыкание объявлено без ключевого слова
move
, то оно захватывает внешние переменные по ссылке, если это возможно, и по значению в противном случае. - если замыкание объявлено с ключевым словом
move
, то оно безусловно захватывает переменные по значению.
Ошибка заключается в том, что
move
относят...Мне кажется, стоило бы выделить не ошибку, а правильное значение из следующего предложения. Сейчас внимание акцентируется не на том, что стоило бы запоминать.
- если замыкание объявлено без ключевого слова
mrbit
17.11.2022 21:43+1Хорошая статья, но мне кажется примеры получились слишком синтетические.
Было бы хорошо видеть либо замечание, что следующему коду:fn new_closure(msg: String) -> impl Fn(usize) -> usize { move |x| msg.clone().into_bytes().len() * x }
нет необходимости создавать копию строки на каждый вызов замыкания, т.к. можно получить
len
и с оригинальногоmsg
.И код, скорее всего, должен выглядеть так:
fn new_closure(msg: String) -> impl Fn(usize) -> usize { move |x| msg.len() * x }
Либо можно привести другой пример, где
clone
действительно имеет смысл, допустим:fn new_closure(msg: String) -> impl Fn(&str) -> String { move |msg2| msg.clone() + msg2 }
в данном случае просто удалить
clone
не получится, не переписав код наformat!("{msg}{msg2}")
или что-то другое.P.S. Статья полезная, просто хотелось напомнить, что примеры из статей, комментариев, обсуждений нужны не для для того, чтобы их использовать/копировать в неизменном виде
Levitanus
Доступно. Но это всё-таки не столько проблема «замыканий», сколько времени жизни. Ну и, учитывая, что не каждый день в английской речи используешь слово closure — я статью открыл с интересом узнать о чём-то, чего не знаю :) А оказалось, что это closures.
Я бы вопрос о времени жизни переменных в ... всё время хочется назвать их лямбда-функциями ... в замыканиях развил в вопрос о времени жизни (lifetime, лайфтайм) в общем. Потому что первое прочтение rust book оставило у меня только необходимость аннотации лайфтаймов в функциях, возвращающих референсы.
А вот лайфтайм полей стуктуры, особенно, когда время жизни
main()
не равно времени жизни программы... В общем, на равне с макросами, лайфтаймы — тот синтаксис, которым очень боязно пользоваться.