Замыкания в Rust
Замыкания в Rust — это функции, которые используют переменные в своей области видимости, пример:
let mut call_count = 0;
let mut sum = | x, y | {
call_count += 1;
x + y
};
dbg!(sum(2, 2));
dbg!(sum(3, 3));
dbg!(sum(4, 4));
dbg!(call_count);
Тема непростая, и не только для Rust. Казалось бы, что может быть проще JavaScript? Но ведь и там: What is a closure in JavaScript and why most people have the wrong idea?.
В другой статье рассматривается вот такой код:
let fns =[]
for(var i = 0; i < 5; i++) {
var c = i * 2;
fns.push( _ => console.log(c))
}
fns.forEach( f => f() )
Как бы, что будет? Код, с точки зрения автора, проблематичен и проблема названа «The For Loop little problem».
Схожий по неочевидности сценарий для Go (подробное обсуждение здесь):
fns := make([]func(),0)
for i := 0; i < 5; i++ {
f := func(){fmt.Println(i)}
fns = append(fns, f)
}
for _, f := range(fns){
f()
}
Для Rust все это многократно усложняется заимствованиями, временами жизни, контролем за изменяемостью (mut), дополнительным способом определения замыканий через move
и необходимостью в некоторых случаях использовать Box
для возврата замыканий. Количество вариантов огромно, ранее полученные по Rust знания помогают плохо (что особенно досадно и внезапно). В общем, краткая и емкая статья по теме замыканий долго не получалась, норовя обернуться многостраничным (а то и многостатейным) монстром.
В конечном итоге вроде бы нашел подход, он заключается в следующем. Компиляторы для замыканий делают много неявной работы, если эту работу смоделировать и показать в условно эквивалентном коде, все станет намного яснее. Наверное. Попробуем.
- Использование значения non-Copy переменной
- Использование значения Copy-переменной
- Использование ссылки на Copy или non-Copy
- move + использование значения Copy-переменной
- move + использование ссылки на Copy или non-Copy
В реальности, конечно, могут встретиться разнообразные комбинации указанных сценариев (добро пожаловать в ад), но это уже оставляю читателю на самостоятельный разбор.
Пример:
{
let s = String::from("hello");
let closure = || {
dbg!(s);
};
// dbg!(s); // error[E0382]: use of moved value: `s`
closure();
// closure(); // error[E0382]: use of moved value: `closure`
}
Такое замыкание безвозвратно «кушает» переменную s
, ее больше нельзя использовать, а само замыкание нельзя вызвать более одного раза.
Замыкание такого типа моделируется структурой Closure {s: String,}
которая реализует FnOnce:
trait FnOnce {
fn call_once(self);
}
Наш FnOnce
является упрощенной моделью std: ops: FnOnce, через которую замыкание используется в реальности:
pub trait FnOnce {
...
extern "rust-call" fn call_once(self, args: Args) -> Self::Output;
...
Тут надо заметить, что такое определение не есть «настоящий сварщик», это как бы intrinsic, который особым образом обрабатывается компилятором.
Данные условно-эквивалентного замыкания инициализируются следующим образом:
let closure = Closure{s: s};
Ясно, что дальнейшее использование s
исключено, так как Copy
для строк не реализован и происходит передача владения значением в поле Closure.s
.
Вызов происходит так:
closure.call_once(1);
// ClosureFnOnce::call_once(closure, 1);
Во второй строке показан эквивалент вызова и по нему видно, что владение переменной closure
переходит в метод call_once()
, и closure
далее использовать нельзя.
Такое замыкание без проблем можно вернуть из функции или передать в поток, тип возвращаемого значения описывается как impl std::ops::FnOnce() -> ()
:
fn new_closure() -> impl std::ops::FnOnce() -> () {
let s = String::from("new_closure");
|| {
dbg!(s);
}
}
fn new_equivalent_closure() -> Closure {
let s = String::from("new_equivalent_closure");
Closure{s: s}
}
Итого:
- Захваченные non-Copy переменные далее использовать вне тела замыкания нельзя
- Замыкание FnOnce можно использовать только один раз
- При таком способе захвата замыкание можно возвращать из функций и передавать в потоки
Теперь рассмотрим такой пример:
// Capturing a variable whose type implements Copy
{
let mut p = Point{x: 10, y: 20};
{
let closure = || {
dbg!(p);
};
// p.x = 11; // error[E0506]: cannot assign to `p.x` because it is borrowed
dbg!(p);
closure();
closure();
}
p.x = 11;
dbg!(p);
}
В качестве типа захватываемой переменной будем использовать:
#[derive(Copy, Clone, Debug)]
struct Point {
x: i32,
y: i32,
}
Для такого случая компилятор «приготовит» Fn:
pub trait Fn: FnMut {
...
extern "rust-call" fn call(&self, args: Args) -> Self::Output;
}
Обратим внимание, что теперь self
передается по ссылке, а определение «эквивалента» содержит ссылку на переменную и пестрит временами жизни:
struct Closure<'a> {
p: &'a Point,
}
impl<'a> Fn for Closure<'a> {
fn call(&self) {
dbg!(*self.p);
}
}
Иными словами, использование в замыкании переменной такого типа по значению компилируется в использование по ссылке.
Это кардинально меняет свойства замыкания. Теперь его можно вызвать несколько раз, но нельзя возвратить из функции, так как в Rust нельзя возвращать ссылки на локальные переменные:
fn new_equivalent_closure<'a>() -> Closure<'a> {
let p = Point{x: 10, y: 20};
Closure{p: &p}
}
error[E0515]: cannot return value referencing local variable `p`
Кроме того, такой захват переменных блокирует их на чтение, так что менять их в области видимости замыкания нельзя.
Замыкание может менять данные, при этом замыкание будет иметь форму FnMut:
pub trait FnMut: FnOnce {
...
extern "rust-call" fn call_mut(&mut self, args: Args) -> Self::Output;
}
Из «эквивалента» видно, что надо везде тщательно проставить mut
, плюс замыкание блокирует переменную на запись, так что читать ее в области видимости замыкания нельзя.
Итого:
- Захваченные на чтение Copy-переменные нельзя изменять в области видимости замыкания
- Захваченные на запись Copy-переменные нельзя читать в области видимости замыкания
- Замыкания Fn/FnMut можно использовать несколько раз
- При таком способе захвата Fn/FnMut нельзя передавать в потоки и возвращать из функций
Получается то же, что и для случая использования Copy-переменной по значению, между Copy и non-Copy разницы нет.
Если требуется возвратить замыкание, которое захватывает локальную Copy-переменную, или передать его в поток, то на помощь придет move
. При этом переменная будет скопирована в данные замыкания и использована по значению:
let mut p = Point{x: 10, y: 20};
{
let mut closure = move || {
p.y += 1;
dbg!(p);
};
}
«Эквивалент» теперь выглядит так:
struct Closure {
p: Point,
}
impl FnMut for Closure {
fn call(&mut self) {
self.p.x += 1;
dbg!(self.p);
}
}
Никаких ссылок, прекрасно! Теперь замыкание можно возвращать из функций и передавать в потоки:
fn new_closure() -> impl std::ops::FnMut() -> () {
let mut p = Point{x: 300, y: 400};
move || {
p.y += 1;
dbg!(p);
}
}
fn new_equivalent_closure() -> Closure {
let p = Point{x: 500, y: 660};
Closure{p: p}
}
Изменения данных внутри замыкания никак не сказываются на «захваченной» переменной:
// move + capturing a variable whose type implements Copy
{
let mut p = Point{x: 10, y: 20};
let mut closure = move || {
p.x += 1;
dbg!(p);
};
dbg!(p); // 10, 20
closure(); // 11, 20
closure(); // 12, 20
dbg!(p); // 10, 20
}
Интересный нюанс — компилятор требует изменяемости p
, хотя она не меняется. Здесь налицо костыль — через mut внешней переменной мы объявляем изменяемость ее копии в теле замыкания. Фу так делать. В «эквиваленте» все работает без mut
:
// Equivalent
{
let p = Point{x: 100, y: 200};
let mut closure = Closure{p: p};
dbg!(p);
closure.call();
closure.call();
dbg!(p);
}
Итого:
move
разрывает связь между замыканием и «захваченной» переменной, переменная используется по значению- При таком способе захвата получается замыкание типа Fn/FnMut, его можно возвратить из функций или передать в поток
- Внутри
move
-замыканий нельзя использовать non-Copy переменные по значению
Формально, кстати, такая конструкция не является замыканием, так как в ней отсутствуют ссылки на переменные, объявленные вне тела этой функции:
Замыкание (англ. closure) в программировании — функция первого класса, в теле которой присутствуют ссылки на переменные, объявленные вне тела этой функции в окружающем коде и не являющиеся её параметрами. Говоря другим языком, замыкание — функция, которая ссылается на свободные переменные в своей области видимости.Замыкание (программирование)
При использовании ссылки на внешнюю переменную эта переменная будет скопирована в данные замыкания и ссылка будет уже на локальную копию.
При помощи этой штуки легко «наступить на грабли»:
fn move_by_x(p: &mut Point, delta: i32) {
p.x += delta;
}
fn main() {
let mut p = Point{x: 10, y: 20};
let mut closure = move || {
move_by_x(&mut p, 1);
dbg!(&p);
};
...
Т.е. передаем в move_by_x()
ссылку, компилятор требует изменяемости p
(mut p
), можно наивно ожидать, что move_by_x()
изменит оригинальное значение, но нет.
Понятно, что вариантом использования по ссылке является вызов метода, у которого получателем (receiver) является &self
. Пример:
impl Point {
fn move_by_x(&mut self, delta: i32) {
self.x += delta;
}
}
fn main() {
let mut p = Point{x: 10, y: 20};
let mut closure = move || {
p.move_by_x(1);
dbg!(&p);
};
...
Свойства замыкания такие же, как и для «move + значение», т.е. получается Fn/FnMut, его можно вызывать много раз, возвращать из функций и передавать в потоки.
Но один интересный нюанс таки есть. Обращаясь к non-Copy переменной по ссылке при помощи move можно сделать возвращаемый (или «передаваемый в потоки») Fn/FnMut:
fn new_closure() -> impl FnMut(){
let mut s = String::from("Back");
move || {
s.push_str(" in the");
s.push_str(" U.S.S.R");
dbg!(&s);
}
}
fn main() {
let mut closure = new_closure();
closure();
closure();
closure();
}
Напротив, вариант без move позволяет вернуть для такой переменной только одноразовый FnOnce:
fn new_closure() -> impl FnOnce(){
let mut s = String::from("Back");
|| {
s.push_str(" in the");
s.push_str(" U.S.S.R");
dbg!(s);
}
}
fn main() {
let closure = new_closure();
closure();
// closure(); // error[E0382]: use of moved value: `closure`
}
Здесь «возращаемость» обеспечивается использованием по значению (dbg!(s)), одновременно это превращает замыкание в FnOnce.
С использованием внешних переменных внутри замыканий разобрались, теперь посмотрим, как замыкания передавать в функции.
В Rust есть три встроенных типа, которые соответствуют замыканиям, иерархия такова: Fn
→ FnMut
→ FnOnce
. Т.е. если функция требует FnOnce
, вместо него можно подать Fn
или FnMut
и так далее.
Допустим, у нас есть замыкание:
let mut call_count = 0;
let sum = |x, y| {
call_count += 1;
x + y
};
Есть ажно три способа принять его при помощи параметров типа.
Классика:
fn call_sum_way1 i32>(mut sum: F) {
Фастфуд:
fn call_sum_way2(mut sum: impl FnMut(i32, i32) -> i32) {
Стильно, модно, молодежно, рекомендовано:
fn call_sum_way3(mut sum: F)
where
EXISTS (SELECT FROM Items WITH (NOLOCK) WHERE Name = F.Name AND Type = "FnMut" AND ParamsCount = 2 AND ResultsCount = 1)
AND EXISTS (SELECT FROM Params WHERE FuncName = F.Name AND Idx = 0 and Type = "i32")
AND EXISTS (SELECT FROM Params WHERE FuncName = F.Name AND Idx = 1 and Type = "i32")
AND EXISTS (SELECT FROM Results WHERE FuncName = F.Name AND Idx = 0 and Type = "i32")
Шутка, вот так на самом деле:
fn call_sum_way3(mut sum: F)
where
F: FnMut(i32, i32) -> i32,
Все три способа в песочнице.
Когда замыкания передаются при помощи параметров типа имеет место т.н. «параметрический полиморфизм», т.е. на каждый вариант используемых при вызове параметров типа генерируется мономорфный вариант принимающей функции. Пример:
fn main() {
let sum = |x, y| {
x + y
};
call_sum(sum);
let sum = |x, y| {
x + y + 20
};
call_sum(sum);
}
fn call_sum i32>(mut sum: F) {
sum(2, 2);
sum(3, 3);
sum(4, 4);
}
Каждое замыкание имеет свой собственный неявный тип, так что компилятор сгенерирует два варианта функции call_sum()
под каждый из них, несмотря на то, что сигнатуры замыканий идентичны. Чтобы в этом убедиться опять посмотрим в ассемблер, выключив в настройках Symbol Demangling
:
_ZN10playground8call_sum17h27c8971da28680efE:
sub rsp, 56
mov dword ptr [rsp + 16], 2
mov dword ptr [rsp + 20], 2
mov esi, dword ptr [rsp + 16]
mov edx, dword ptr [rsp + 20]
lea rdi, [rsp + 8]
call _ZN10playground4main28_$u7b$$u7b$closure$u7d$$u7d$17h1ec7382e3c1e9c39E
jmp .LBB17_1
...
_ZN10playground8call_sum17h6c6a460f1c9cf47aE:
sub rsp, 56
mov dword ptr [rsp + 16], 2
mov dword ptr [rsp + 20], 2
mov esi, dword ptr [rsp + 16]
mov edx, dword ptr [rsp + 20]
lea rdi, [rsp + 8]
call _ZN10playground4main28_$u7b$$u7b$closure$u7d$$u7d$17h9220f028b950d2f4E
jmp .LBB18_1
...
Принимающая функция может быть скомпилирована в одном варианте («динамический полиморфизм» или «полиморфизм подтипов»), для этого нужно передавать замыкание через «кучу»:
fn main() {
let sum = |x, y| {
x + y
};
call_sum(Box::new(sum));
let sum = |x, y| {
x + y + 20
};
call_sum(Box::new(sum));
}
fn call_sum(mut sum: Box i32>) {
sum(2, 2);
sum(3, 3);
sum(4, 4);
}
- Данные перемещаются в кучу при помощи
Box::new(sum)
- Таким образом в кучу можно помещать много чего, не только замыкания
- В описании параметров нужно использовать волшебное слово
dyn
(динамический же полиморфизм) call_sum()
при этом компилируется в одном экземпляре
Кому-то покажется более изящным такой способ определения параметров:
type MyClosure = dyn FnMut(i32, i32) -> i32;
fn call_sum(mut sum: Box) {
sum(2, 2);
sum(3, 3);
sum(4, 4);
}
Из функций и методов замыкание можно возвращать таким образом:
fn main() {
dbg!(get_sum(1)(10, 20));
dbg!(get_sum(2)(10, 20));
}
fn get_sum(mult: i32) -> impl FnMut(i32, i32) -> i32 {
return move |x, y| {
mult * (x + y)
};
}
Без move
тут не получится, так как иначе мы вернем замыкание, которое ссылается на локальную переменную — параметр mult
. Так дело не пойдет, опасно, нужно «замести» все переменные в данные замыкания, что и делает move
.
Надо заметить, что вернуть замыкание можно только из одного места, например, вот такой пример не компилируется:
fn get_sum2(mult: i32, minus: bool) -> impl FnMut(i32, i32) -> i32 {
if minus {
return move |x, y| {
mult * (x + y)
};
} else {
return move |x, y| {
mult * (x - y)
};
}
}
Сообщения компилятора шикарны:
= note: to return `impl Trait`, all returned values must be of the same type
= note: no two closures, even if identical, have the same type
= help: consider boxing your closure and/or using it as a trait object
Возврат через impl
не работает для интерфейсов:
trait Summer {
fn get_sum(mult: i32) -> impl FnMut(i32, i32) -> i32;
}
error[E0562]: `impl Trait` not allowed outside of function and method return types
--> src/lib.rs:2:30
|
2 | fn get_sum(mult: i32) -> impl FnMut(i32, i32) -> i32;
| ^^^^^^^^^^^^^^^^^^^^^^^^^^^
Интересно, почему? Рассмотрим вот такой пример:
fn call_closure() {
get_closure()();
}
fn call_closure2() {
get_closure2()();
}
fn get_closure() -> impl Fn() {
let array: [i32; 50] = [0; 50];
return move || {
dbg!(array);
};
}
fn get_closure2() -> impl Fn() {
let array: [i32; 100] = [0; 100];
return move || {
dbg!(array);
};
}
В этом примере мы возвращаем два замыкания с данными разного размера. Включим для ассемблера Symbol Demangling
и посмотрим, что получается:
playground::call_closure:
sub rsp, 200
mov rdi, rsp
call playground::get_closure
mov rdi, rsp
call playground::get_closure::{{closure}}
add rsp, 200
ret
playground::call_closure2:
sub rsp, 408
lea rdi, [rsp + 8]
call playground::get_closure2
lea rdi, [rsp + 8]
call playground::get_closure2::{{closure}}
add rsp, 408
ret
Вон оно что, данные замыкания возвращается через стек и вызывающая сторона должна подготовить место для этого (sub rsp, ...
). Размер данных замыкания известен компилятору только если он имеет возможность «увидеть» что происходит внутри вызываемой функции. В случае, когда замыкание возвращается из интерфейса, «внутрь» не посмотреть, размер данных неизвестен, может возвращаться что угодно, так что в кучу, товарищи:
trait Summer {
fn get_sum(mult: i32) -> Box i32>;
}
Пример:
use std::thread;
fn main() {
let s = String::from("Hello");
let handle = thread::spawn(|| {
dbg!(s);
});
handle.join().unwrap();
}
В данном случае все просто — мы готовим FnOnce, thread::spawn()
принимает именно такой тип, все работает (unwrap()
требует отдельного рассмотрения, не в этой статье).
Определенные «тонкости» в процессе передачи замыканий в потоки, конечно, есть. Чтобы в них разобраться, рассмотрим сигнатуру thread: spawn:
pub fn spawn(f: F) -> JoinHandle
where
F: FnOnce() -> T,
F: Send + 'static,
T: Send + 'static,
F: Send + 'static
означает, что данные замыкания должны уметь безопасно передаваться между потоками (Send
), и все ссылки в данных замыкания (если они есть) должны иметь время жизни static
, т.е. ссылаться на локальные переменные внутри замыкания нельзя. Рассмотрим Send
и 'static
более подробно.
Send
это такой маркерный интерфейс типаж, который указывает, что значение может передаваться между потоками. Согласно Книге:
Почти каждый тип Rust является типом Send, но есть некоторые исключения, вродеRc
: он не может быть Send, потому что если вы клонировали значениеRc
и попытались передать владение клоном в другой поток, оба потока могут обновить счётчик ссылок одновременно.Разрешение передачи во владение между потоками с помощью Send
Проверим. Возьмем Box
, его цель — просто хранить значение в куче. Работает:
let p = Box::new(Point { x: 10, y: 20 });
let handle = thread::spawn(|| {
dbg!(p);
});
handle.join().unwrap();
Мы сохранили значение в куче и передали его через данные замыкания в поток. Теперь очередь Rc
. Его задача — хранить значение в куче и вести счетчик ссылок на него, когда все держатели ссылок выйдут из области видимости, память освобождается:
let p1 = Rc::new(Point { x: 10, y: 20 });
let p2 = p1.clone();
let p3 = p1.clone();
dbg!(p1);
dbg!(p2);
dbg!(p3);
- p1, p2 и p3 ссылаются на единственное значение в куче
- p1, p2 и p3 можно передать в разные функции и там использовать
- Менять значение за p1, p2 и p3 нельзя.
Rc
быстр, но потокоопасен, поэтому замыкание, которое его использует, лишается почетного значка Send
:
let p = Rc::new(Point { x: 10, y: 20 });
let handle = thread::spawn(|| {
dbg!(p);
});
handle.join().unwrap();
error[E0277]: `Rc` cannot be sent between threads safely
--> src/main.rs:13:18
|
13 | let handle = thread::spawn(|| {
| __________________^^^^^^^^^^^^^_-
| | |
| | `Rc` cannot be sent between threads safely
Значения, на которые ссылаются замыкания, передаваемые в потоки, должны жить долго, гарантированно не меньше, чем поток — т.е. то самое 'static
. Вот такое не пройдет:
use std::thread;
fn main() {
let x = 10;
let handle = thread::spawn(|| {
dbg!(x);
});
handle.join().unwrap();
}
6 | let handle = thread::spawn(|| {
| ^^ may outlive borrowed value `x`
7 | dbg!(x);
| - `x` is borrowed here
|
note: function requires argument type to outlive `'static`
Тут-то и пригодится move ||
:
use std::thread;
fn main() {
let x = 10;
let handle = thread::spawn(move || {
dbg!(x);
});
handle.join().unwrap();
}
В данных замыкания теперь хранится не ссылка, а копия значения, так что требование 'static
удовлетворяется — ссылок-то вообще нет. Вроде просто, но есть нюансы. Например, вот так — можно:
use std::thread;
fn main() {
let s = String::from("hello");
let handle = thread::spawn(|| {
dbg!(s);
});
handle.join().unwrap();
}
Отличие тут в том, что String
не реализует интерфейс Copy
, выражение dbg!(s)
«съедает» переменную s
по значению, поэтому для компилятора нет нужды в данных замыкания хранить ссылку на значение, можно это значение сразу переместить в данные замыкания — вне замыкания изменить s
, в отличие от x
, нельзя.
Иными словами, ключевое слово move
незаменимо, если замыкание передается в поток (или возвращается из функции) и использует локальное значение типа, который умеет в Copy
.
Как-то так, пора заканчивать.
В прекрасном языке программирования будущего я бы кардинально упростил замыкания путем их усложнения. Указывать способ «захвата» и изменяемость следует явно, напрямую использовать переменные из окружающей среды нельзя:
let x = 10;
// Используем копию x
let captureByValue = {closurex: x} || {
dbg!(closurex)
// dbg!(x) // error[E0425]: cannot find value `x` in this scope
});
// Используем изменяемую копию x
let mutCaptureByValue = {mut closurex: x} || {
// Изменяем локальную копию
closurex = 20;
});
// Используем ссылку на x
let captureByReference = {closurex: &x} || {
...
// Изменяем оригинальное значение
*closurex += 1;
});
И все, не нужно было бы писать добрую половину этой статьи, а «проблематичные» конструкции, приведенные в начале, в таком синтаксисе мигом потеряли бы всю проблематичность.
Конечно, будут определенные сложности с определением, приведенным ранее:
«Замыкание (англ. closure) в программировании — функция первого класса, в теле которой присутствуют ссылки на переменные, объявленные вне тела этой функции в окружающем коде и не являющиеся её параметрами»
Тут непросто, имеем дело с традициями, корни которых уходят в прошлое тысячелетие. Замечу, что «замыкания» в Rust, определенные с ключевым словом move
, также не являются «замыканиями» в приведенном выше смысле, так у них в теле нет ссылок на внешние переменные.
На этом все по замыканиям.