Baseado no capítulo de mesmo título do livro "The Rust Programming Language".
Closures em Rust são funções anônimas declaradas inline como um valor, simplificando certos patterns (como iteradores, que veremos no próximo capítulo). Você pode criar a closure em uma posição do código e depois chamá-la em outro lugar, executando-a num contexto diferente. Ao contrário de funções, closures podem capturar valores do ambiente nas quais foram definidas.
Vamos demonstrar como essas features de closures permitem abstrair o seu código e evitar duplicação.
Warning
Embora closures possam ser consideradas "funções", neste texto, trataremos "closures" e "funções" como termos diferentes. Utilizaremos o termo "função" para fazer referência a funções declaradas usando
fn
. Essa é uma diferenciação muito comum no dia a dia.
Warning
Neste texto, preste muita atenção para não confundir o ato de definir uma closure com o ato de chamar (isto é, executar) a closure.
Para começar, vejamos um exemplo básico que mostra uma closure capturando o seu
ambiente. O cenário é simples: digamos que tenhamos uma struct que contém uma série de
String
s, cada uma das quais representa o título de um TODO.
Precisamos de um método que permite o utilizador contar a quantidade de TODOs que contém certo texto em seu respectivo nome. Opcionalmente, o usuário pode omitir o filtro, de modo a retornar a contagem total.
Evidentemente, existem várias formas para implementar esse código. Uma delas, que utiliza closures, seria:
struct Todos {
list: Vec<String>,
}
impl Todos {
fn count(&self, filter: Option<&str>) -> usize {
if let Some(pattern) = filter {
self.list
.iter()
.filter(|todo| todo.contains(pattern)) // <---- Closure
.count()
} else {
// Se não tiver fornecido um filtro, retornamos a quantidade total
self.list.len()
}
}
fn new() -> Self {
Self { list: Vec::new() }
}
fn add(&mut self, todo: String) {
self.list.push(todo);
}
}
fn main() {
let mut todos = Todos::new();
todos.add("Comprar maçãs".into());
todos.add("Comprar abacaxi".into());
todos.add("Estudar Rust".into());
let count = todos.count(Some("Comprar"));
println!("Queremos comprar {count} coisas");
}
A parte que nos interessa é esta:
fn count(&self, filter: Option<&str>) -> usize {
if let Some(pattern) = filter {
self.list
.iter()
.filter(|todo| todo.contains(pattern)) // <---- Closure
.count()
} else {
// Se não tiver fornecido um filtro, retornamos a quantidade total
self.list.len()
}
}
O método Iterator::filter
é definido pela biblioteca padrão e aceita uma closure para
determinar se o elemento da iteração atual satisfaz um determinado predicado. Nesse
caso, a closure é:
|todo| todo.contains(pattern)
Veja que a closure toma, como parâmetro, a &String
(a qual demos o nome todo
)
correspondente ao TODO da iteração atual. Nesse aspecto, uma função faria algo bem
similar, já que funções também têm parâmetros. Como você pode ter reparado, parâmetros
de closure são declarados entre dois pipes. Se a nossa closure não tivesse nenhum
parâmetro, utilizaríamos || ...
. O corpo da closure é uma expressão que vai após o
segundo pipe.
A diferença de closures para funções é que closures são "mais poderosas" pois
conseguem capturar variáveis do escopo em que foram definidas (também chamado de
environment). Nesse caso, a closure está capturando a variável pattern
, que foi
definida na função count
.
Para que closures sejam capazes de capturar valores de seus environments, elas precisam armazenar esse valor internamente. Como veremos a seguir, funções não são capazes de armazenar nada e, por isso, não conseguem capturar valores de escopos superiores.
A vantagem de ser capaz de capturar algo está justamente na possibilidade de abstração
trazida por closures. No caso do método Iterator::filter
, a implementação da
biblioteca padrão não precisa saber nada sobre a lógica que utilizaremos para filtrar
o nosso elemento. Nesse caso, a implementação de filter
só nos precisa passar o
elemento que está sendo filtrado e a nossa closure, sendo capaz de "ler valores"
disponíveis naquele contexto do código, pode determinar sua própria lógica de filtragem.
Você já pensou como implementaria um filter
sem poder usar closures? Certamente teria
uma API menos amigável.
Uma outra diferença é que, em closures, geralmente não se é necessário anotar tipos de parametros e retorno explicitamente.
Em funções, as anotações de tipo são requeridas porque elas fazem parte de um contrato explicitamente exposto aos seus usuários. Definir essa interface de modo rígido é importante para garantir certo grau de estabilidade. Por outro lado, closures são usadas em situações onde essa rigidez não é importante, como em variáveis e parâmetros (que não fazem parte, obviamente, de interfaces públicas).
Closures são tipicamente pequenas e limitadas a um contexto onde o compilador consegue facilmente inferir os tipos de parâmetro e retorno. Por conta disso (e pelo fato de que closures, de modo geral, não estabelecem interfaces públicas), você não precisa anotar os tipos.
Ou seja, podemos fazer isto:
|todo| todo.contains(pattern)
Ao invés disto (que também é válido):
|todo: String| -> bool { todo.contains(pattern) }
Repare que, para evitar ambiguidade, quando anotamos o retorno explicitamente, um bloco é obrigatório.
Em alguns casos, quando a complexidade do código é maior, o compilador não será capaz de inferir o tipo sem ambiguidades. Nesses casos, um erro de compilação será emitido e você terá que colocar anotações explícitas.
Com anotações de tipo, percebe-se que a sintaxe de closures se assemelha à sintaxe de funções. Aqui temos uma lista completa para comparativo:
fn add_one_v1 (x: u32) -> u32 { x + 1 } // Definição de função
let add_one_v2 = |x: u32| -> u32 { x + 1 }; // Closure
let add_one_v3 = |x| { x + 1 }; // Closure
let add_one_v4 = |x| x + 1 ; // Closure
Closures podem capturar (ou tomar) valores de seus ambientes de três formas diferentes. Essas formas mapeiam diretamente para os três jeitos que uma função pode receber parâmetros:
- Borrow imutável
- Borrow mutável
- Tomando ownership
A closure irá decidir qual desses três métodos utilizar de acordo com o que o corpo da requisição faz com os valores capturados.
No código abaixo, definimos uma closure que captura uma referência imutável para o vetor
list
pois o corpo dessa closure, para imprimir o valor, precisa apenas de uma
referência imutável:
fn main() {
let list = vec![1, 2, 3];
println!("Before defining closure: {:?}", list);
// `println!` exige um borrow imutável para imprimir
// Nesse caso, a closure irá "capturar" uma referência imutável à `list`
// ↓↓↓↓
let only_borrows = || println!("From closure: {:?}", list);
println!("Before calling closure: {:?}", list);
only_borrows(); // <-- A sintaxe para chamar uma closure é a mesma.
println!("After calling closure: {:?}", list);
}
Como podemos ter várias referências imutáveis à list
ao mesmo tempo, list
é
normalmente acessível antes e depois de definirmos a closure.
No exemplo a seguir, temos uma closure que modifica o vetor adicionando um elemento ao final:
fn main() {
// ↓↓↓
let mut list = vec![1, 2, 3];
println!("Before defining closure: {:?}", list);
// Exige borrow mutável à `list`
// ↓↓↓ ↓↓↓↓
let mut borrows_mutably = || list.push(7); // <--- Definição da closure
// Borrow mutável está "vivo" aqui no meio.
// Nenhum outro borrow à `list` é permitido aqui.
borrows_mutably(); // <--- Chamada à closure
println!("After calling closure: {:?}", list);
}
Como a closure borrows_mutably
utiliza a função push
(que exige um borrow mutável),
ela irá capturar uma referência mutável à list
.
Lembre-se que, enquanto um borrow mutável está "vivo", nenhum outro borrow é
permitido. Por isso, entre a definição da closure e a sua chamada, não podemos realizar
nenhum outro borrow
à list
.
Como não utilizamos borrows_mutably
novamente após chamá-la, o compilador entende que
o borrow mutável que existia entre a definição e a chamada deixou de ser usado. Nesse
caso, podemos realizar outros borrows após a chamada. No exemplo, utilizamos um borrow
imutável para imprimir a lista após a sua modificação.
Como falamos acima, a função irá decidir automaticamente como irá tomar os valores de seu ambiente a partir da forma como o seu corpo usa os valores. Então:
- Se o corpo consome o valor, a função irá tomar a ownership do valor;
- Se o corpo modifica o valor (via borrow mutável), a função irá tomar uma referência mutável ao valor;
- Se a função apenas lê o valor (via borrow imutável), a função irá tomar uma referência imutável ao valor.
Todavia, se você quer fazer com que a closure tome ownership dos valores que capturou
(mesmo que o corpo não precise consumir os valores tomados), você pode utilizar a
palavra-chave move
na definição.
Utilizar move
, de modo geral, é necessário quando você quer garantir que a sua closure
não criará nenhuma referência. Em vez disso, ela deve de fato tomar a posse dos
valores. Isso é importante quando estamos criando uma outra thread, por exemplo.
Note
Repare que aqui temos uma distinção importante que pode acontecer em closures definidas com
move
. Elas podem capturar os valores de um jeito (tomando ownership), mas utilizar esses valores de outro jeito. Nesse caso, uma closuremove
, embora sempre capture valores de modo a tomar ownership, não necessariamente irá consumir esses valores. Isso irá depender do que o corpo da closure faz com os valores capturados.
Anteriormente, vimos que closures podem tomar os valores de seu environment e utilizar esses valores de algumas formas diferentes:
- Borrow imutável
- Borrow mutável
- Tomando ownership
Isso significa que existem três "categorias" de closures. Sempre que uma closure é definida, ela é classificada em pelo menos uma dessas "categorias" de acordo com o que o corpo da closure faz com os valores capturados do seu environment.
Para fazer essa categorização, a linguagem define três traits, FnOnce
, FnMut
e Fn
.
A categorização é feita tendo em vista:
- Os valores capturados e como eles são capturados (e.g. borrow imutável, borrow mutável ou tomada de ownership).
- O que o corpo da closure faz com os valores capturados.
A trait FnOnce
descreve closures que podem ser chamadas apenas uma vez. Qualquer
função pode ser chamada pelo menos uma vez, então toda closure implementa essa trait.
Closures cujo corpo consome algum valor (isto é, captura tomando ownership e
depois utiliza o valor capturado em alguma função que toma ownership) irão implementar
apenas a trait FnOnce
.
Para entender isso, lembre-se que a função captura os valores na sua definição (ou seja, apenas uma vez). Logo, assumindo que o corpo da função também utilize esses valores de modo a consumi-los, não haverá mais um valor no caso dessa função ser chamada mais de uma vez. Ele já foi usado logo na primeira chamada.
Abaixo temos um exemplo de uma closure que apenas implementa FnOnce
, pois a String
capturada terá sua posse tomada pela closure, que, quando executada, consumirá a string
x
pela função drop
.
let x = String::from("good bye");
let only_fn_once_closure = || drop(x);
A título de exemplo, abaixo temos uma closure Fn
(e portanto FnMut
e FnOnce
), já
que, embora x
seja tomado via ownership (haja vista a keyword move
), o corpo da
função não consome, de fato, a string. Portanto, podemos chamar closure
várias vezes.
let x = String::from("see you again");
let closure = move || println!("{}", x);
A trait FnMut
descreve closures que podem ser chamadas várias vezes e podem
modificar valores capturados.
Ela é implementada para qualquer closure cujo corpo não seja executado de modo a consumir os valores capturados do ambiente.
Como os valores capturados por uma FnMut
não são consumidos durante a sua execução,
elas podem ser chamadas várias vezes.
A trait Fn
descreve closures que não modificam e não consomem, durante a sua
execução, os valores capturados de seu environment. Ela também representa as closures
que não capturam nenhum valor do ambiente.
Closures Fn
são bastante úteis para descrever interfaces de programas concorrentes,
pois teremos certeza que o código definido no corpo dessa closure não modifica os
valores capturados (isto é, apenas lê eles ou usa alguma forma de sincronização para
escritas).
Antes de discutimos sobre o uso dessas traits, uma observação.
- Repare que
FnOnce
é a trait mais geral, se aplicando a todas as closures. - E
Fn
é a mais específica.
+-------------------------+
| (FnOnce) |
| |
| +------------------+ |
| | (FnMut) | |
| | | |
| | +-----------+ | |
| | | (Fn) | | |
| | | | | |
| | +-----------+ | |
| +------------------+ |
+-------------------------+
De modo geral, se você só está definindo closures (por exemplo, para passar como
argumento para métodos como o Iterator::filter
), essas diferenças não serão muito
necessárias para você.
Essas traits são mais usadas por quem está implementando uma função que irá receber uma closure. Como todas as closures são "únicas" e diferentes umas das outras, sempre que uma nova closure é definida, o compilador cria um tipo único para ela. Desse modo, para especificar o tipo do argumento (que será uma closure) na função, é necessário utilizar uma das três traits que discutimos acima. A ideia é a mesma que tratamos no capítulo de traits: criar uma função genérica (isto é, que possa receber qualquer closure) que satisfaça uma das traits que escolhemos.
Por exemplo, a assinatura do método Iterator::filter
é:
fn filter<P>(self, predicate: P) -> Filter<Self, P>
where
P: FnMut(&Self::Item) -> bool;
O parâmetro genérico P
está sendo restringido pela trait FnMut
. Pelo que descrevemos
acima, isso significa que poderemos passar como argumento qualquer closure que não
consuma, durante a execução, algum dos valores que capturou.
Você deve estar se perguntando o porquê de não termos utilizado uma Fn
como trait
bound. A resposta é que poderíamos, mas nesse caso estaríamos restringindo o número
de closures que poderiam ser passadas (lembre-se que FnOnce
é mais geral que FnMut
,
que por sua vez é mais geral que Fn
).
A implementação de filter
precisa apenas poder chamar a closure passada várias vezes
(uma para cada elemento do iterator). Desse modo, FnMut
é suficiente. :)