Callbacks, closures и модель памяти Rust

Реализуемая Rust модель памяти оставляет свой отпечаток на всем, включая такие вещи как замыкания и функции обратного вызова. Привычные по другим языкам концепции в случае с Rust начинают вести себя иначе и далеко не с первого взгляда очевидно почему такое происходит.

В Rust имеются два вида замыканий: стековые и уникальные и указатели на функции. В некоторых случаях замыкания взаимозаменяемы и совместимы с указатели на функции, в некоторых нет. Поведение данных замыканий идентично поведению стековых данных и данных адресуемых посредствам уникальных указателей. Так как весь этот набор выглядит довольно обширным, то мне кажется что лучше всего разбираться на примерах.

При описании уникальных замыканих используется ключевое слово proc. Такие замыкания инкапсулируют в себе копии захватываемых переменных и могут безболезненно передаваться между задачами. Функция, которая в качестве одного из аргументов принимает замыкание, может выглядеть так:

fn call_proc(l: int, r: int, func: proc(int, int) -> int) {
    func(l, r);                     // (1)
    func(l, r);                     // (2)
}

Уникальное замыкание передается в качестве третьего параметра и на первый вгляд не сильно отличается от обычной функции 1 по своему поведению. К основным отиличиям, кроме возможности захвата переменных из изначального контекста добавляется невозможность повторного исполнения кода замыкания 2 за чем пристально следит компилятор. Диагностическое сообщение на такую попытку:

error: use of moved value: `func`
`func` moved here because it has type `proc:Send(int, int) -> int`, which is non-copyable (perhaps you meant to use clone()?)

Вопрос того, что с чем совместимо и почему лучше всего рассматривать на живых примерах, поэтому, для эксперементов понадобится немного вспомогательного кода:

fn test_fn(a: int, b: int) -> int {  // (1)
    a + b
}

fn main() {
    let cl = |a: int, b: int| {      // (2)
        a + b
    };
...

А именно: внешняя функция 1 и стековое замыкание 2.

call_proc(1, 2, cl);            // (1)
do call_proc(1, 2) |a, b| {     // (2)
    a + b
}
call_proc(1, 2, test_fn);       // (3)

Так как все три имеющихся в Rust пула памяти (стек, куча обмена и локальная куча) обладают принципиально разным поведением, каких-либо конверсий между ними не предусмотренно. Данная особенность отражается и на поведении замыканий. Так, компилятор не допустит использования стекового замыкания 1 там, где ожидается уникальное замыкание:

mismatched types: expected `proc:Send(int, int) -> int` but found `once |int, int| -> int:Send`
(expected ~ closure, found & closure)

Это вызванно тем, что стековый объект копируется, а не реализаует семантику владения и не может быть отправлен в другую задачу. А вот различий между внешней фукнцией и уникальным замыканием куда меньше. Внешняя функция, так же как и уникальное замыкание, самодостаточна, не имеет собсвенного состояния, не требует себя копировать куда-либо и может быть вызвана из любой задачи. Как следствие, внешняя функция может 3 использоваться наряду с уникальным замыканием 2.

Функция принимающая в качестве аргумента другую функцию будет выглядеть следующим образом, где fn ключевое слово для декларации переменной типа функция.

fn call_fn(l: int, r: int, func: fn(int, int) -> int) {
    func(l, r);
}

На функции, в отличие от уникальных замыканий, каких-либо ограничений по количеству вызовов не налагается и ничто не мешает сделать больше чем один вызов одной и той же функции.

call_fn(1, 2, cl);            // (1)
do call_fn(1, 2) |a, b| {     // (2)
    a + b
}
call_fn(1, 2, test_fn);       // тут и так все очевидно, правда?

Стековое замыкание 1 не может быть использованно вместо внешней функции:

mismatched types: expected `fn(int, int) -> int` but found `|int, int| -> int` (expected extern fn but found fn)

Хотя внешняя функция может использоваться вместо уникального замыкания, обратной совместимости между ними нет 2. Функция и внешнее замыкание имеют много общего, но функцию можно вызвать больше чем один раз, что делает очевидным причину появления ограничения на замену внешней функции уникальным замыканием.

error: last argument in `do` call has non-procedure type: fn(int, int) -> int

Объявление стекового замыканя сильно отличается от предыдущих примеров не только синтаксисом, но и необходимостью явно указывать время жизни, при этом каких-либо ограничений на количество вызовов в стековых замыканиях нет. call_closure из примера ниже принимает на вход стековое замыкание со временем жизни не меньшим чем сама функция.

fn call_closure<'a>(l: int, r: int, func: 'a |int, int| -> int) {
    func(l, r);
}

call_closure(1, 2, test_fn);     // (1)
do call_closure(1, 2) |a, b| {   // (2)
    a + b
}

Наиболее универсальная конструкция, внешняя функция, вполне 1 может использоваться вместо стекового замыкания по тем же причинам, почему она могла использоваться вместо уникального замыкания. Стековое и уникальное замыкания 2 представляют собой не совместимые концепции ни по количеству допустимых вызовов ни по принципу хранения и копирования памяти.

error: last argument in `do` call has non-procedure type: |int, int| -> int

Так что, несмотря на всю логичность модели памяти Rust, ей необходимо уделять пристальное внимание при изучении это великолепного языка. Данная заметка – вторая и не последняя заметка, из серии посвященной модели памяти Rust.

Leave a Reply