Programação funcional em Rust: Closures e Iteradores

Clube do Livro #13

Closures

Clube do Livro #13

Projeto-exemplo

  • Backend em Rust gera planos de exercícios personalizados.
  • O algoritmo que gera o plano de treino leva em consideração muitos fatores...
  • ... mas o algoritmo em si não é importante neste exemplo; para nós interessa apenas que ele é complexo e demorado. Então simularemos isso.
  • Problema: queremos chamar este algoritmo apenas quando precisarmos, nosso objetivo é fazer o usuário do app esperar o mínimo possível.

Clube do Livro #13

O que são Closures?

  • Closures são funções anônimas que podem capturar valores do escopo na qual foram definidas
  • Elas podem ser salvas em variáveis e passadas para outras funções para que estas a utilizem.
  • Funções que recebem outras funções como parâmetro são chamadas de funções de alta ordem (High Order Functions)
  • Com closures, temos a habilidade de definir o código num lugar e executá-lo em outro. Isso permite melhor reutilização de código além de customização de comportamentos.

Clube do Livro #13

Sintaxe da Closure

let expensive_closure = |num| {
  println!("Calculando lentamente...");
  thread::sleep(Duration::from_secs(2));
  num
};

Clube do Livro #13

Closure: Inferência de tipos

  • Não é necessário anotar os tipos dos parâmetros, porque:
    • Closures não são tão expostas como as funções
    • Elas geralmente são concisas e tem relevância apenas para uma pequena parte bem definida de nosso código, ao invés de um contexto genérico como no caso das funções.
  • Por causa deste contexto bem limitado, o compilador do Rust consegue inferir com segurança os tipos dos parâmetros e o tipo do retorno de uma closure, da mesma forma como consegue inferir os tipos das variáveis.

Clube do Livro #13

Mas se você quiser, pode anotar sim!

let expensive_closure = |num: u32| -> u32 {
  println!("Calculando lentamente...");
  thread::sleep(Duration::from_secs(2));
  num
};

Clube do Livro #13

Aproximação com sintaxe de fn

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  ;

Clube do Livro #13

  • Observe que invocar as versões add_one_v3 e add_one_v4 pelo menos uma vez é obrigatório para que o código compile, pois somente assim o compilador poderá inferir os tipos e gerar as versões concretas.

  • Para cada closure, apenas uma versão concreta, com tipos definidos para os argumentos, será criada

Memoization e lazy evaluation

  • Retornando ao projeto, ainda temos que resolver o problema da chamada dupla

  • Poderíamos usar uma variável, mas existe uma solução mais elegante que o uso de closures nos permite

  • O que nós iremos fazer é criar uma estrutura que guarde a nossa closure e o resultado de sua invocação: um sistema de cache bem simples. Dessa forma, o resultado será calculado apenas uma vez.

  • Com isso utilizaremos as técnicas memoization (memoização) e lazy evaluation/call-by-need (avaliação preguiçosa/chamada de acordo com necessidade)

Clube do Livro #13

Criando a estrutura

  • Para fazer essa estrutura, precisaremos especificar o tipo da closure, pois os tipos dos campos de uma struct precisam ser explicitamente definidos

  • Cada closure tem seu próprio tipo anônimo, ou seja, mesmo que duas closures tenham a mesma assinatura, seus tipos são individuais

  • Dessa forma, para definir a nossa estrutura, nós teremos que lançar mão de genéricos e trait bounds (capítulo 10)

  • A biblioteca-padrão do Rust provê três tipos de traits para closures: Fn, FnMut ou FnOnce. Nós vamos discutir as diferenças entre elas depois.

Clube do Livro #13

Limitações de nossa estrutura

  • Sempre o mesmo valor, mesmo com invocações diferentes.

    • Podemos usar um hashmap para resolver isso.

  • Só aceita closures com a assinatura Fn(u32) -> u32

    • Podemos usar mais tipos genéricos para dar a solução

Clube do Livro #13

Closures capturam o ambiente

  • Diferente de funções, closures podem capturar o ambiente e ter acesso às variáveis do contexto em que foram definidas:

fn main() {
  let x = 4;
  let equal_to_x = |z| z == x;
  let y = 4;
  assert!(equal_to_x(y));
}

Clube do Livro #13

Closures capturam o ambiente

  • A captura de valores resulta numa sobrecarga para o app, pois a closure tem que usar acesso à memória.

  • Se você não precisa, não use.

  • Três formas de captura de valores para uma closure:

    • pegando a propriedade (FnOnce);

    • pegando emprestado como uma referência mutável (FnMut); ou

    • pegando emprestado como uma referência imutável (Fn).

  • O compilador automaticamente escolhe qual trait usar de acordo com o uso que a nossa closure faz das variáveis

Clube do Livro #13

Closures capturam o ambiente

  • Palavra-chave move: forçando tomada de propriedade

fn main() {
  let x = vec![1, 2, 3];
  let equal_to_x = move |z| z == x;
  println!("ERRO: x foi movido: {:?}", x);
  let y = vec![1, 2, 3];
  assert!(equal_to_x(y));
}
  • Na maior parte das vezes, você pode começar com a trait Fn; o compilador te avisará se você precisar de outra trait.

Clube do Livro #13

Iteradores

Clube do Livro #13

Iteradores

  • É um design pattern (padrão de código)

  • Consiste na execução algumas tarefas em uma sequência de itens por vez

  • Um iterador é responsável pela lógica de iterar sobre cada item e determinar quando a sequência termina

  • Ao usar iteradores já prontos do Rust, você não precisa reimplementar essa lógica sozinho;

  • Iteradores no Rust são preguiçosos (lazy-evaluation): eles não têm efeito até que você chame algum método que os consuma;

Clube do Livro #13

Adaptadores consumidores

 

  • São métodos que consomem iteradores, por exemplo:
#[test]
fn iterator_sum() {
  let v1 = vec![1, 2, 3];
  let v1_iter = v1.iter();
  let total: i32 = v1_iter.sum();
  assert_eq!(total, 6);
}
  • Collect() é um muito usado: transforma um iterador numa coleção

Clube do Livro #13

Adaptadores de iterador

  • Métodos que produzem outros iteradores, portanto permitem transformar um iterador em outro
  • Você pode interligar múltiplas chamadas a esses métodos para fazer transformações complexas com dados:

#[test]
fn iterator_sum() {
  let v1: Vec<i32> = vec![1, 2, 3, 5, 6];
  let v1_plus_even: Vec<i32> = v1.iter()
    .map(|x| x + 1)
    .filter(|x| x % 2 == 0)
    .collect();
  assert_eq!(v1_plus_even, vec![2,4,6]);
}

Clube do Livro #13

Criando  iteradores de coleções

#[test]
fn iterator_demonstration() {
  let v1 = vec![1, 2, 3];

  let mut v1_iter = v1.iter();

  assert_eq!(v1_iter.next(), Some(&1));
  assert_eq!(v1_iter.next(), Some(&2));
  assert_eq!(v1_iter.next(), Some(&3));
  assert_eq!(v1_iter.next(), None);
}

Clube do Livro #13

Formas de criar um iterador

  • Existem três formas de criar um iterador de uma sequência:

    • iter() produz um iterador sobre referências imutáveis

    • into_iter() produz um iterador que se apropria da coleção e retorna valores com propriedade

    • iter_mut() produz um iterador com referências mutáveis

Clube do Livro #13

Anatomia de um iterador

  • Todos os iteradores implementam a trait Iterator
pub trait Iterator {
  type Item; // capítulo 19
  fn next(&mut self) -> Option<Self::Item>;
  // métodos com implementação padrão
  // omitidos
}

Clube do Livro #13

  • Basicamente o que precisamos implementar é o método next e o tipo associado Item.

Criando nossos iteradores

  • Você pode criar iteradores para os tipos embutidos de Rust, como, por-exemplo, um hashmap ou uma struct.

  • Você também pode criar iteradores para os seus tipos customizados ao implementar a trait Iterator

Clube do Livro #13

Aplicando o conhecimento

Clube do Livro #13

Melhorando nosso programa

  • Construímos um programa de linha de comando no capítulo anterior

  • Com o conhecimento adquirido sobre closures e iteradores, nós vamos fazer algumas refatorações:

    • Utilizaremos o iterador retornado por args diretamente, removendo a necessidade do clone() e de slices de strings

    • Refatoraremos search() para um estilo funcional com iteradores

Clube do Livro #13

Performance de iteradores e closures

Clube do Livro #13

Conclusão: abstrações custo-zero

  • Embora sejam uma abstração de alto nível, iteradores são compilados quase no mesmo código de baixo-nível que você geraria

  • Iteradores são uma das abstrações de custo zero de Rust: o uso da abstração não impõe sobrecarga de tempo de execução adicional.

  • Otimização unrolling: remove a sobrecarga do código de controle do loop e, em vez disso, gera código repetitivo para cada iteração do loop. Todos os coeficientes são armazenados em registradores, o que significa que o acesso aos valores é muito rápido.

Clube do Livro #13

Obrigado, pessoal!

@felubra

twitter, github, telegram:

Referências

Clube do Livro #13

Made with Slides.com