Introdução
Bem-vindo a Closures: Funções Anônimas que Capturam seu Ambiente. Este laboratório faz parte do Livro do Rust. Você pode praticar suas habilidades em Rust no LabEx.
Neste laboratório, você explorará closures em Rust, que são funções anônimas que podem ser salvas em variáveis ou passadas como argumentos, permitindo a reutilização de código e a personalização de comportamento através da captura de valores de seu escopo de definição.
Closures: Funções Anônimas que Capturam seu Ambiente
As closures do Rust são funções anônimas que você pode salvar em uma variável ou passar como argumentos para outras funções. Você pode criar a closure em um lugar e, em seguida, chamar a closure em outro lugar para avaliá-la em um contexto diferente. Ao contrário das funções, as closures podem capturar valores do escopo em que são definidas. Demonstraremos como esses recursos de closure permitem a reutilização de código e a personalização de comportamento.
Capturando o Ambiente com Closures
Primeiramente, examinaremos como podemos usar closures para capturar valores do ambiente em que são definidas para uso posterior. Aqui está o cenário: de vez em quando, nossa empresa de camisetas oferece uma camiseta exclusiva de edição limitada para alguém em nossa lista de e-mail como uma promoção. As pessoas na lista de e-mail podem opcionalmente adicionar sua cor favorita ao seu perfil. Se a pessoa escolhida para uma camiseta grátis tiver sua cor favorita definida, ela receberá a camiseta dessa cor. Se a pessoa não especificou uma cor favorita, ela receberá a cor que a empresa tem em maior quantidade no momento.
Existem muitas maneiras de implementar isso. Para este exemplo, vamos usar um enum chamado ShirtColor que tem as variantes Red e Blue (limitando o número de cores disponíveis para simplificar). Representamos o inventário da empresa com uma struct Inventory que tem um campo chamado shirts que contém um Vec<ShirtColor> representando as cores das camisetas atualmente em estoque. O método giveaway definido em Inventory obtém a preferência opcional de cor da camiseta do vencedor e retorna a cor da camiseta que a pessoa receberá. Essa configuração é mostrada na Listagem 13-1.
Nome do arquivo: src/main.rs
#[derive(Debug, PartialEq, Copy, Clone)]
enum ShirtColor {
Red,
Blue,
}
struct Inventory {
shirts: Vec<ShirtColor>,
}
impl Inventory {
fn giveaway(
&self,
user_preference: Option<ShirtColor>,
) -> ShirtColor {
1 user_preference.unwrap_or_else(|| self.most_stocked())
}
fn most_stocked(&self) -> ShirtColor {
let mut num_red = 0;
let mut num_blue = 0;
for color in &self.shirts {
match color {
ShirtColor::Red => num_red += 1,
ShirtColor::Blue => num_blue += 1,
}
}
if num_red > num_blue {
ShirtColor::Red
} else {
ShirtColor::Blue
}
}
}
fn main() {
let store = Inventory {
2 shirts: vec![
ShirtColor::Blue,
ShirtColor::Red,
ShirtColor::Blue,
],
};
let user_pref1 = Some(ShirtColor::Red);
3 let giveaway1 = store.giveaway(user_pref1);
println!(
"The user with preference {:?} gets {:?}",
user_pref1, giveaway1
);
let user_pref2 = None;
4 let giveaway2 = store.giveaway(user_pref2);
println!(
"The user with preference {:?} gets {:?}",
user_pref2, giveaway2
);
}
Listagem 13-1: Situação de sorteio da empresa de camisetas
A store definida em main tem duas camisetas azuis e uma camiseta vermelha restantes para distribuir para esta promoção de edição limitada [2]. Chamamos o método giveaway para um usuário com preferência por uma camiseta vermelha [3] e um usuário sem nenhuma preferência [4].
Novamente, este código poderia ser implementado de muitas maneiras e, aqui, para focar em closures, nos apegamos a conceitos que você já aprendeu, exceto pelo corpo do método giveaway que usa uma closure. No método giveaway, obtemos a preferência do usuário como um parâmetro do tipo Option<ShirtColor> e chamamos o método unwrap_or_else em user_preference [1]. O método unwrap_or_else em Option<T> é definido pela biblioteca padrão. Ele recebe um argumento: uma closure sem nenhum argumento que retorna um valor T (o mesmo tipo armazenado na variante Some do Option<T>, neste caso ShirtColor). Se o Option<T> for a variante Some, unwrap_or_else retorna o valor de dentro do Some. Se o Option<T> for a variante None, unwrap_or_else chama a closure e retorna o valor retornado pela closure.
Especificamos a expressão de closure || self.most_stocked() como o argumento para unwrap_or_else. Esta é uma closure que não recebe nenhum parâmetro (se a closure tivesse parâmetros, eles apareceriam entre as duas barras verticais). O corpo da closure chama self.most_stocked(). Estamos definindo a closure aqui, e a implementação de unwrap_or_else avaliará a closure mais tarde, se o resultado for necessário.
A execução deste código imprime o seguinte:
The user with preference Some(Red) gets Red
The user with preference None gets Blue
Um aspecto interessante aqui é que passamos uma closure que chama self.most_stocked() na instância Inventory atual. A biblioteca padrão não precisou saber nada sobre os tipos Inventory ou ShirtColor que definimos, ou a lógica que queremos usar neste cenário. A closure captura uma referência imutável para a instância self Inventory e a passa com o código que especificamos para o método unwrap_or_else. Funções, por outro lado, não são capazes de capturar seu ambiente dessa maneira.
Inferência e Anotação de Tipos de Closure
Existem mais diferenças entre funções e closures. Closures geralmente não exigem que você anote os tipos dos parâmetros ou o valor de retorno como as funções fn fazem. Anotações de tipo são necessárias em funções porque os tipos fazem parte de uma interface explícita exposta aos seus usuários. Definir essa interface rigidamente é importante para garantir que todos concordem sobre quais tipos de valores uma função usa e retorna. Closures, por outro lado, não são usadas em uma interface exposta como esta: elas são armazenadas em variáveis e usadas sem nomeá-las e expô-las aos usuários de nossa biblioteca.
Closures são tipicamente curtas e relevantes apenas dentro de um contexto restrito, em vez de em qualquer cenário arbitrário. Dentro desses contextos limitados, o compilador pode inferir os tipos dos parâmetros e o tipo de retorno, de forma semelhante a como ele é capaz de inferir os tipos da maioria das variáveis (existem casos raros em que o compilador também precisa de anotações de tipo de closure).
Assim como com variáveis, podemos adicionar anotações de tipo se quisermos aumentar a explicitude e a clareza, à custa de sermos mais verbosos do que o estritamente necessário. Anotar os tipos para uma closure seria semelhante à definição mostrada na Listagem 13-2. Neste exemplo, estamos definindo uma closure e armazenando-a em uma variável, em vez de definir a closure no local onde a passamos como um argumento, como fizemos na Listagem 13-1.
Nome do arquivo: src/main.rs
let expensive_closure = |num: u32| -> u32 {
println!("calculating slowly...");
thread::sleep(Duration::from_secs(2));
num
};
Listagem 13-2: Adicionando anotações de tipo opcionais dos tipos de parâmetro e valor de retorno na closure
Com as anotações de tipo adicionadas, a sintaxe de closures se assemelha mais à sintaxe de funções. Aqui, definimos uma função que adiciona 1 ao seu parâmetro e uma closure que tem o mesmo comportamento, para comparação. Adicionamos alguns espaços para alinhar as partes relevantes. Isso ilustra como a sintaxe de closure é semelhante à sintaxe de função, exceto pelo uso de pipes e a quantidade de sintaxe que é opcional:
fn add_one_v1 (x: u32) -> u32 { x + 1 }
let add_one_v2 = |x: u32| -> u32 { x + 1 };
let add_one_v3 = |x| { x + 1 };
let add_one_v4 = |x| x + 1 ;
A primeira linha mostra uma definição de função e a segunda linha mostra uma definição de closure totalmente anotada. Na terceira linha, removemos as anotações de tipo da definição da closure. Na quarta linha, removemos as chaves, que são opcionais porque o corpo da closure tem apenas uma expressão. Estas são todas as definições válidas que produzirão o mesmo comportamento quando forem chamadas. As linhas add_one_v3 e add_one_v4 exigem que as closures sejam avaliadas para poder compilar, porque os tipos serão inferidos de seu uso. Isso é semelhante a let v = Vec::new(); precisando de anotações de tipo ou valores de algum tipo para serem inseridos no Vec para que o Rust possa inferir o tipo.
Para definições de closure, o compilador inferirá um tipo concreto para cada um de seus parâmetros e para seu valor de retorno. Por exemplo, a Listagem 13-3 mostra a definição de uma closure curta que apenas retorna o valor que recebe como um parâmetro. Essa closure não é muito útil, exceto para os propósitos deste exemplo. Observe que não adicionamos nenhuma anotação de tipo à definição. Como não há anotações de tipo, podemos chamar a closure com qualquer tipo, o que fizemos aqui com String na primeira vez. Se tentarmos chamar example_closure com um inteiro, receberemos um erro.
Nome do arquivo: src/main.rs
let example_closure = |x| x;
let s = example_closure(String::from("hello"));
let n = example_closure(5);
Listagem 13-3: Tentando chamar uma closure cujos tipos são inferidos com dois tipos diferentes
O compilador nos dá este erro:
error[E0308]: mismatched types
--> src/main.rs:5:29
|
5 | let n = example_closure(5);
| ^- help: try using a conversion method:
`.to_string()`
| |
| expected struct `String`, found integer
Na primeira vez que chamamos example_closure com o valor String, o compilador infere que o tipo de x e o tipo de retorno da closure são String. Esses tipos são então bloqueados na closure em example_closure, e recebemos um erro de tipo quando tentamos usar um tipo diferente com a mesma closure.
Capturando Referências ou Movendo a Propriedade
Closures podem capturar valores de seu ambiente de três maneiras, que mapeiam diretamente para as três maneiras que uma função pode receber um parâmetro: emprestando imutavelmente, emprestando mutavelmente e tomando posse. A closure decidirá qual delas usar com base no que o corpo da função faz com os valores capturados.
Na Listagem 13-4, definimos uma closure que captura uma referência imutável ao vetor chamado list porque ela só precisa de uma referência imutável para imprimir o valor.
Nome do arquivo: src/main.rs
fn main() {
let list = vec![1, 2, 3];
println!("Before defining closure: {:?}", list);
1 let only_borrows = || println!("From closure: {:?}", list);
println!("Before calling closure: {:?}", list);
2 only_borrows();
println!("After calling closure: {:?}", list);
}
Listagem 13-4: Definindo e chamando uma closure que captura uma referência imutável
Este exemplo também ilustra que uma variável pode se vincular a uma definição de closure [1], e podemos mais tarde chamar a closure usando o nome da variável e parênteses como se o nome da variável fosse o nome de uma função [2].
Como podemos ter várias referências imutáveis a list ao mesmo tempo, list ainda é acessível a partir do código antes da definição da closure, após a definição da closure, mas antes da closure ser chamada, e após a closure ser chamada. Este código compila, executa e imprime:
Before defining closure: [1, 2, 3]
Before calling closure: [1, 2, 3]
From closure: [1, 2, 3]
After calling closure: [1, 2, 3]
Em seguida, na Listagem 13-5, alteramos o corpo da closure para que ele adicione um elemento ao vetor list. A closure agora captura uma referência mutável.
Nome do arquivo: src/main.rs
fn main() {
let mut list = vec![1, 2, 3];
println!("Before defining closure: {:?}", list);
let mut borrows_mutably = || list.push(7);
borrows_mutably();
println!("After calling closure: {:?}", list);
}
Listagem 13-5: Definindo e chamando uma closure que captura uma referência mutável
Este código compila, executa e imprime:
Before defining closure: [1, 2, 3]
After calling closure: [1, 2, 3, 7]
Observe que não há mais um println! entre a definição e a chamada da closure borrows_mutably: quando borrows_mutably é definida, ela captura uma referência mutável a list. Não usamos a closure novamente após a closure ser chamada, então o empréstimo mutável termina. Entre a definição da closure e a chamada da closure, um empréstimo imutável para imprimir não é permitido porque nenhum outro empréstimo é permitido quando há um empréstimo mutável. Tente adicionar um println! lá para ver qual mensagem de erro você recebe!
Se você quiser forçar a closure a tomar posse dos valores que ela usa no ambiente, mesmo que o corpo da closure não precise estritamente da posse, você pode usar a palavra-chave move antes da lista de parâmetros.
Essa técnica é principalmente útil ao passar uma closure para uma nova thread para mover os dados para que eles sejam de propriedade da nova thread. Discutiremos threads e por que você gostaria de usá-los em detalhes no Capítulo 16, quando falarmos sobre concorrência, mas por enquanto, vamos explorar brevemente a criação de uma nova thread usando uma closure que precisa da palavra-chave move. A Listagem 13-6 mostra a Listagem 13-4 modificada para imprimir o vetor em uma nova thread, em vez de na thread principal.
Nome do arquivo: src/main.rs
use std::thread;
fn main() {
let list = vec![1, 2, 3];
println!("Before defining closure: {:?}", list);
1 thread::spawn(move || {
2 println!("From thread: {:?}", list)
}).join().unwrap();
}
Listagem 13-6: Usando move para forçar a closure para a thread a tomar posse de list
Geramos uma nova thread, dando à thread uma closure para executar como um argumento. O corpo da closure imprime a lista. Na Listagem 13-4, a closure só capturou list usando uma referência imutável porque essa é a menor quantidade de acesso a list necessária para imprimi-la. Neste exemplo, embora o corpo da closure ainda precise apenas de uma referência imutável [2], precisamos especificar que list deve ser movido para a closure colocando a palavra-chave move [1] no início da definição da closure. A nova thread pode terminar antes que o restante da thread principal termine, ou a thread principal pode terminar primeiro. Se a thread principal mantiver a posse de list, mas terminar antes da nova thread e descartar list, a referência imutável na thread seria inválida. Portanto, o compilador exige que list seja movido para a closure fornecida à nova thread para que a referência seja válida. Tente remover a palavra-chave move ou usar list na thread principal após a closure ser definida para ver quais erros do compilador você recebe!
Movendo Valores Capturados de Closures e os Traits Fn
Uma vez que uma closure capturou uma referência ou capturou a propriedade de um valor do ambiente onde a closure é definida (afetando, portanto, o que, se algo, é movido para a closure), o código no corpo da closure define o que acontece com as referências ou valores quando a closure é avaliada posteriormente (afetando, portanto, o que, se algo, é movido para fora da closure).
Um corpo de closure pode fazer qualquer uma das seguintes coisas: mover um valor capturado para fora da closure, mutar o valor capturado, nem mover nem mutar o valor, ou não capturar nada do ambiente para começar.
A maneira como uma closure captura e lida com valores do ambiente afeta quais traits a closure implementa, e os traits são como funções e structs podem especificar que tipos de closures podem usar. Closures implementarão automaticamente um, dois ou todos os três desses traits Fn, de forma aditiva, dependendo de como o corpo da closure lida com os valores:
FnOncese aplica a closures que podem ser chamadas uma vez. Todas as closures implementam pelo menos este trait porque todas as closures podem ser chamadas. Uma closure que move valores capturados para fora de seu corpo implementará apenasFnOncee nenhum dos outros traitsFnporque só pode ser chamada uma vez.FnMutse aplica a closures que não movem valores capturados para fora de seu corpo, mas que podem mutar os valores capturados. Essas closures podem ser chamadas mais de uma vez.Fnse aplica a closures que não movem valores capturados para fora de seu corpo e que não mutam valores capturados, bem como closures que não capturam nada de seu ambiente. Essas closures podem ser chamadas mais de uma vez sem mutar seu ambiente, o que é importante em casos como chamar uma closure várias vezes simultaneamente.
Vamos analisar a definição do método unwrap_or_else em Option<T> que usamos na Listagem 13-1:
impl<T> Option<T> {
pub fn unwrap_or_else<F>(self, f: F) -> T
where
F: FnOnce() -> T
{
match self {
Some(x) => x,
None => f(),
}
}
}
Lembre-se que T é o tipo genérico que representa o tipo do valor na variante Some de um Option. Esse tipo T também é o tipo de retorno da função unwrap_or_else: o código que chama unwrap_or_else em um Option<String>, por exemplo, obterá um String.
Em seguida, observe que a função unwrap_or_else tem o parâmetro de tipo genérico adicional F. O tipo F é o tipo do parâmetro chamado f, que é a closure que fornecemos ao chamar unwrap_or_else.
A restrição de trait especificada no tipo genérico F é FnOnce() -> T, o que significa que F deve ser capaz de ser chamada uma vez, não receber argumentos e retornar um T. Usar FnOnce na restrição de trait expressa a restrição de que unwrap_or_else só vai chamar f uma vez, no máximo. No corpo de unwrap_or_else, podemos ver que se o Option for Some, f não será chamado. Se o Option for None, f será chamado uma vez. Como todas as closures implementam FnOnce, unwrap_or_else aceita a maior variedade de closures e é tão flexível quanto pode ser.
Nota: Funções também podem implementar todos os três traits
Fn. Se o que queremos fazer não exigir a captura de um valor do ambiente, podemos usar o nome de uma função em vez de uma closure onde precisamos de algo que implemente um dos traitsFn. Por exemplo, em um valorOption<Vec<T>>, poderíamos chamarunwrap_or_else(Vec::new)para obter um novo vetor vazio se o valor forNone.
Agora, vamos analisar o método da biblioteca padrão sort_by_key, definido em fatias, para ver como ele difere de unwrap_or_else e por que sort_by_key usa FnMut em vez de FnOnce para a restrição de trait. A closure recebe um argumento na forma de uma referência ao item atual na fatia que está sendo considerada e retorna um valor do tipo K que pode ser ordenado. Essa função é útil quando você deseja classificar uma fatia por um atributo específico de cada item. Na Listagem 13-7, temos uma lista de instâncias Rectangle e usamos sort_by_key para ordená-las por seu atributo width de baixo para cima.
Nome do arquivo: src/main.rs
#[derive(Debug)]
struct Rectangle {
width: u32,
height: u32,
}
fn main() {
let mut list = [
Rectangle { width: 10, height: 1 },
Rectangle { width: 3, height: 5 },
Rectangle { width: 7, height: 12 },
];
list.sort_by_key(|r| r.width);
println!("{:#?}", list);
}
Listagem 13-7: Usando sort_by_key para ordenar retângulos por largura
Este código imprime:
[
Rectangle {
width: 3,
height: 5,
},
Rectangle {
width: 7,
height: 12,
},
Rectangle {
width: 10,
height: 1,
},
]
A razão pela qual sort_by_key é definido para receber uma closure FnMut é que ele chama a closure várias vezes: uma vez para cada item na fatia. A closure |r| r.width não captura, muta ou move nada de seu ambiente, então ela atende aos requisitos da restrição de trait.
Em contraste, a Listagem 13-8 mostra um exemplo de uma closure que implementa apenas o trait FnOnce, porque ela move um valor para fora do ambiente. O compilador não nos permitirá usar essa closure com sort_by_key.
Nome do arquivo: src/main.rs
--snip--
fn main() {
let mut list = [
Rectangle { width: 10, height: 1 },
Rectangle { width: 3, height: 5 },
Rectangle { width: 7, height: 12 },
];
let mut sort_operations = vec![];
let value = String::from("by key called");
list.sort_by_key(|r| {
sort_operations.push(value);
r.width
});
println!("{:#?}", list);
}
Listagem 13-8: Tentando usar uma closure FnOnce com sort_by_key
Esta é uma maneira forçada e convoluta (que não funciona) de tentar contar o número de vezes que sort_by_key é chamado ao classificar list. Este código tenta fazer essa contagem empurrando value---um String do ambiente da closure---para o vetor sort_operations. A closure captura value e, em seguida, move value para fora da closure transferindo a propriedade de value para o vetor sort_operations. Essa closure pode ser chamada uma vez; tentar chamá-la uma segunda vez não funcionaria porque value não estaria mais no ambiente para ser empurrado para sort_operations novamente! Portanto, essa closure implementa apenas FnOnce. Quando tentamos compilar este código, recebemos este erro de que value não pode ser movido para fora da closure porque a closure deve implementar FnMut:
error[E0507]: cannot move out of `value`, a captured variable in an `FnMut`
closure
--> src/main.rs:18:30
|
15 | let value = String::from("by key called");
| ----- captured outer variable
16 |
17 | list.sort_by_key(|r| {
| ______________________-
18 | | sort_operations.push(value);
| | ^^^^^ move occurs because `value` has
type `String`, which does not implement the `Copy` trait
19 | | r.width
20 | | });
| |_____- captured by this `FnMut` closure
O erro aponta para a linha no corpo da closure que move value para fora do ambiente. Para corrigir isso, precisamos alterar o corpo da closure para que ele não mova valores para fora do ambiente. Manter um contador no ambiente e incrementar seu valor no corpo da closure é uma maneira mais direta de contar o número de vezes que sort_by_key é chamado. A closure na Listagem 13-9 funciona com sort_by_key porque está apenas capturando uma referência mutável ao contador num_sort_operations e, portanto, pode ser chamada mais de uma vez.
Nome do arquivo: src/main.rs
--snip--
fn main() {
--snip--
let mut num_sort_operations = 0;
list.sort_by_key(|r| {
num_sort_operations += 1;
r.width
});
println!(
"{:#?}, sorted in {num_sort_operations} operations",
list
);
}
Listagem 13-9: Usar uma closure FnMut com sort_by_key é permitido.
Os traits Fn são importantes ao definir ou usar funções ou tipos que fazem uso de closures. Na próxima seção, discutiremos iteradores. Muitos métodos de iterador recebem argumentos de closure, então tenha esses detalhes de closure em mente enquanto continuamos!
Resumo
Parabéns! Você concluiu o laboratório Closures: Funções Anônimas que Capturam seu Ambiente. Você pode praticar mais laboratórios no LabEx para aprimorar suas habilidades.