Skip to content

Instantly share code, notes, and snippets.

@marlalain
Created October 13, 2022 22:19
Show Gist options
  • Save marlalain/8650a2566901f86b453edf04d0de2251 to your computer and use it in GitHub Desktop.
Save marlalain/8650a2566901f86b453edf04d0de2251 to your computer and use it in GitHub Desktop.

Baseado no capítulo de mesmo título do livro "The Rust Programming Language".

Closures: Funções anônimas que capturam seu ambiente

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.

Capturando o ambiente com closures

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 Strings, 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.

Inferência de tipos e anotações de tipo em closures

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

Capturando referências ou movendo ownership

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:

  1. Borrow imutável
  2. Borrow mutável
  3. 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 closure move, 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.

As traits Fn

Anteriormente, vimos que closures podem tomar os valores de seu environment e utilizar esses valores de algumas formas diferentes:

  1. Borrow imutável
  2. Borrow mutável
  3. 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.

FnOnce

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);

FnMut

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.

Fn

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 eles ou usa alguma forma de sincronização para escritas).

Quando FnOnce, FnMut e Fn são úteis?

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. :)

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment