- Предыдущая часть: Заменяем глобальный аллокатор
- Начало и содержание: Владение
Трудности замыкания
Замыкания в 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 знания помогают плохо (что особенно досадно и внезапно). В общем, краткая и емкая статья по теме замыканий долго не получалась, норовя обернуться многостраничным (а то и многостатейным) монстром.
В конечном итоге вроде бы нашел подход, он заключается в следующем. Компиляторы для замыканий делают много неявной работы, если эту работу смоделировать и показать в условно эквивалентном коде, все станет намного яснее. Наверное. Попробуем.
Базовые случаи использования внешних переменных в замыканиях Rust
- Использование значения non-Copy переменной
- Использование значения Copy-переменной
- Использование ссылки на Copy или non-Copy
- move + использование значения Copy-переменной
- move + использование ссылки на Copy или non-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<Args> { ... 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 можно использовать только один раз
- При таком способе захвата замыкание можно возвращать из функций и передавать в потоки
Использование значения Copy-переменной
Теперь рассмотрим такой пример:
// 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<Args>: FnMut<Args> { ... 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<Args>: FnOnce<Args> { ... extern "rust-call" fn call_mut(&mut self, args: Args) -> Self::Output; }
Из "эквивалента" видно, что надо везде тщательно проставить mut, плюс замыкание блокирует переменную на запись, так что читать ее в области видимости замыкания нельзя.
Итого:
- Захваченные на чтение Copy-переменные нельзя изменять в области видимости замыкания
- Захваченные на запись Copy-переменные нельзя читать в области видимости замыкания
- Замыкания Fn/FnMut можно использовать несколько раз
- При таком способе захвата Fn/FnMut нельзя передавать в потоки и возвращать из функций
Использование ссылки на Copy или non-Copy
Получается то же, что и для случая использования Copy-переменной по значению, между Copy и non-Copy разницы нет.
move + использование значения 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 p:
// 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 переменные по значению
Формально, кстати (PS: только согласно приводимому ниже определению, есть и другие), такая конструкция не является замыканием, так как в ней отсутствуют ссылки на переменные, объявленные вне тела этой функции:
Замыкание (англ. closure) в программировании — функция первого класса, в теле которой присутствуют ссылки на переменные, объявленные вне тела этой функции в окружающем коде и не являющиеся её параметрами. Говоря другим языком, замыкание — функция, которая ссылается на свободные переменные в своей области видимости.
Замыкание (программирование)
move + использование ссылки на Copy или non-Copy
При использовании ссылки на внешнюю переменную эта переменная будет скопирована в данные замыкания и ссылка будет уже на локальную копию.
При помощи этой штуки легко "наступить на грабли":
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<F: FnMut(i32, i32) -> i32>(mut sum: F) {
Фастфуд:
fn call_sum_way2(mut sum: impl FnMut(i32, i32) -> i32) {
Стильно, модно, молодежно, рекомендовано:
fn call_sum_way3<F>(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<F>(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<F: FnMut(i32, i32) -> 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<dyn FnMut(i32, i32) -> 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<MyClosure>) { 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<dyn FnMut(i32, i32) -> 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, T>(f: F) -> JoinHandle<T> where F: FnOnce() -> T, F: Send + 'static, T: Send + 'static,
F: Send + 'static означает, что данные замыкания должны уметь безопасно передаваться между потоками (Send), и все ссылки в данных замыкания (если они есть) должны иметь время жизни static, т.е. ссылаться на локальные переменные внутри замыкания нельзя. Рассмотрим Send и 'static более подробно.
Передача замыканий в потоки::Send
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<Point>` cannot be sent between threads safely --> src/main.rs:13:18 | 13 | let handle = thread::spawn(|| { | __________________^^^^^^^^^^^^^_- | | | | | `Rc<Point>` cannot be sent between threads safely
Передача замыканий в потоки::'static
Значения, на которые ссылаются замыкания, передаваемые в потоки, должны жить долго, гарантированно не меньше, чем поток — т.е. то самое '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, также не являются "замыканиями" в приведенном выше смысле, так у них в теле нет ссылок на внешние переменные.
На этом все? по замыканиям.
