5.3 purrr

Ao usar programação funcional (PF) podemos criar códigos concisos e “pipeáveis”, que tornam o código mais legível e o processo de debug mais simples. Além disso, códigos funcionais geralmente são paralelizáveis, permitindo que tratemos problemas grandes com poucas modificações.

Apesar de o R base já ter funções que podem ser consideradas elementos de PF, a implementação destas não é elegante. Este tutorial abordará a implementação de PF realizada pelo pacote purrr.

Cuidado: material altamente viciante.

5.3.1 Iterações básicas

As funções map() são quase como substitutas para laços for: elas abstraem a iteração em apenas uma linha. Veja esse exemplo de laço usando for:

soma_um <- function(x) x + 1
obj <- 10:15

for (i in seq_along(obj)) {
  obj[i] <- soma_um(obj[i])
}
obj
#> [1] 11 12 13 14 15 16

O que de fato estamos tentando fazer com o laço acima? Temos um vetor (obj) e queremos aplicar uma função (soma_um()) em cada elemento dele. A função map() remove a necessidade de declaramos um objeto iterador auxiliar (i) e simplesmente aplica a função desejada em cada elemento do objeto dado.

soma_um <- function(x) x + 1
obj <- 10:15

obj <- map(obj, soma_um)
obj
#> [[1]]
#> [1] 11
#> 
#> [[2]]
#> [1] 12
#> 
#> [[3]]
#> [1] 13
#> 
#> [[4]]
#> [1] 14
#> 
#> [[5]]
#> [1] 15
#> 
#> [[6]]
#> [1] 16

5.3.1.1 Achatando resultados

Se quisermos “achatar” o resultado, devemos informar qual será o seu tipo. Isso pode ser feito com as irmãs da map(): map_chr() (para strings), map_dbl() (para números reais), map_int() (para números inteiros) e map_lgl() (para booleanos).

obj <- 10:15

map_dbl(obj, soma_um)
#> [1] 11 12 13 14 15 16

O purrr também nos fornece outra ferramenta interessante para achatar listas: a família flatten(). map_chr() é um atalho para map() %>% flatten_chr().

O achatamento não precisa necessariamente virar um atômico. Duas funções muito utilizadas da família map() são map_dfc() e map_dfr(), que equivalem a um map() seguido de um dplyr::bind_cols() ou de um dplyr::bind_rows() respectivamente.

walk() é uma versão do map() que não retorna nada nas iterações. Ela serve para fazer efeitos colaterais, como salvar arquivos e imprimir coisas na tela.

map(1:2, print)
#> [1] 1
#> [1] 2
#> [[1]]
#> [1] 1
#> 
#> [[2]]
#> [1] 2
walk(1:2, print)
#> [1] 1
#> [1] 2

5.3.1.2 Fórmulas e reticências

Algo bastante útil da família map() é a possibilidade de passar argumentos fixos para a função que será aplicada. A primeira forma de fazer isso envolve fórmulas:

soma_n <- function(x, n = 1) x + n
obj <- 10:15

map_dbl(obj, ~soma_n(.x, 2))
#> [1] 12 13 14 15 16 17

Precisamos colocar um til (~) antes da função que será chamada. Feito isso, podemos utilizar o placeholder .x para indicar onde deve ser colocado cada elemento de obj.

A outra forma de passar argumentos para a função é através das reticências de map(). Desta maneira precisamos apenas dar o nome do argumento e seu valor logo após a função soma_n().

soma_n <- function(x, n = 1) x + n
obj <- 10:15

map_dbl(obj, soma_n, n = 2)
#> [1] 12 13 14 15 16 17

5.3.2 Iterações intermediárias

Agora que já exploramos os básicos da família map() podemos partir para iterações um pouco mais complexas. Observe o laço a seguir:

soma_ambos <- function(x, y) x + y
obj_1 <- 10:15
obj_2 <- 20:25

for (i in seq_along(obj_1)) {
  obj_1[i] <- soma_ambos(obj_1[i], obj_2[i])
}
obj_1
#> [1] 30 32 34 36 38 40

Com a função map2() podemos reproduzir o laço acima em apenas uma linha. Ela abstrai a iteração em paralelo, aplica a função em cada par de elementos das entradas e, assim como sua prima map(), pode achatar o objeto retornado com os sufixos _chr, _dbl, _int e _lgl.

soma_ambos <- function(x, y) x + y
obj_1 <- 10:15
obj_2 <- 20:25

obj_1 <- map2_dbl(obj_1, obj_2, soma_ambos)
obj_1
#> [1] 30 32 34 36 38 40

Como o pacote purrr é extremamente consistente, a map2() também funciona com reticências e fórmulas. Poderíamos, por exemplo, transformar soma_ambos() em uma função anônima:

obj_1 <- 10:15
obj_2 <- 20:25

map2_dbl(obj_1, obj_2, ~.x + .y)
#> [1] 30 32 34 36 38 40

Desta vez também temos acesso ao placeholder .y para indicar onde os elementos de do segundo vetor devem ir.

5.3.2.1 Generalização

Para não precisar oferecer uma função para cada número de argumentos, o pacote purrr fornece a pmap(). Para essa função devemos passar uma lista em que cada elemento é um dos objetos a ser iterado:

soma_varios <- function(x, y, z) x + y + z
obj_1 <- 10:15
obj_2 <- 20:25
obj_3 <- 30:35

obj_1 <- pmap_dbl(list(obj_1, obj_2, obj_3), soma_varios)
obj_1
#> [1] 60 63 66 69 72 75

Com a pmap() não podemos usar fórmulas. Se quisermos usar uma função anônima com ela, precisamos declará-la a função no seu corpo:

obj_1 <- 10:15
obj_2 <- 20:25
obj_3 <- 30:35

pmap_dbl(list(obj_1, obj_2, obj_3), function(x, y, z) { x + y + z })
#> [1] 60 63 66 69 72 75

5.3.2.2 Iterando em índices

imap() é um atalho para map2(x, names(x), ...) quando x tem nomes e para map2(x, seq_along(x), ...) caso contrário:

obj <- 10:15

imap_chr(obj, ~paste(.x, .y, sep = "/"))
#> [1] "10/1" "11/2" "12/3" "13/4" "14/5" "15/6"

Naturalmente, assim como toda a família map(), a imap() também funciona com os sufixos de achatamento.

5.3.3 Iterações avançadas

Agora vamos passar para os tipos mais obscuros de laços. Cada item desta seção será mais denso do que os das passadas, por isso encorajamos todos os leitores para que também leiam a documentação de cada função aqui abordada.

5.3.3.1 Iterações com condicionais

Imagine que precisamos aplicar uma função somente em alguns elementos de um vetor. Veja o trecho de código a seguir por exemplo:

dobra <- function(x) x * 2
obj <- 10:15

for (i in seq_along(obj)) {
  if (obj[i] %% 2 == 1) { 
    obj[i] <- dobra(obj[i]) 
  } else { 
    obj[i] <- obj[i] 
  }
}
obj
#> [1] 10 22 12 26 14 30

Aplicamos a função dobra() apenas nos elementos ímpares do vetor obj. Com o pacote purrr temos duas maneiras de fazer isso: com map_if() ou map_at().

A primeira dessas funções aplica a função dada apenas quando um predicado é TRUE. Esse predicado pode ser uma função ou uma fórmula.

eh_impar <- function(x) x %% 2 == 1
dobra <- function(x) x * 2
obj <- 10:15

map_if(obj, eh_impar, dobra) %>% 
  flatten_dbl()
#> [1] 10 22 12 26 14 30

Com fórmulas poderíamos eliminar completamente a necessidade de funções declaradas:

obj <- 10:15

map_if(obj, ~.x %% 2 == 1, ~.x * 2) %>% 
  flatten_dbl()
#> [1] 10 22 12 26 14 30

Para map_at() devemos passar um vetor de nomes ou índices onde a função deve ser aplicada:

obj <- 10:15

map_at(obj, c(2, 4, 6), ~.x * 2) %>% 
  flatten_dbl()
#> [1] 10 22 12 26 14 30

5.3.4 Redução e acúmulo

Outras funções simbólicas de programação funcional além da map() são reduce() e accumulate(), que aplicam transformações em valores acumulados. Observe o laço a seguir:

soma_ambos <- function(x, y) x + y
obj <- 10:15

for (i in 2:length(obj)) {
  obj[i] <- soma_ambos(obj[i-1], obj[i])
}
obj
#> [1] 10 21 33 46 60 75

Observe como isso ficaria usando accumulate()

soma_ambos <- function(x, y) { x + y }
obj <- 10:15

accumulate(obj, soma_ambos)
#> [1] 10 21 33 46 60 75
accumulate(obj, ~.x + .y)
#> [1] 10 21 33 46 60 75

Obs.: Aqui, .x é o valor acumulado e .y é o valor “atual” do objeto sendo iterado.

Se não quisermos o valor acumulado em cada passo da iteração, podemos usar reduce():

obj <- 10:15
reduce(obj, ~.x+.y)
#> [1] 75

Para a nossa comodidade, essas duas funções também têm variedades paralelas (accumulate2() e reduce2()), assim como variedades invertidas accumulate_right() e reduce_right()).

5.3.5 Miscelânea

5.3.5.1 Transposição e indexação profunda

Quando precisarmos lidar com listas complexas e profundas, o purrr nos fornece duas funções extremamente úteis: transpose() e pluck(). A primeira transpõe uma lista, enquanto a segunda é capaz de acessar elementos profundos de uma lista sem a necessidade de colchetes.

obj <- list(
  list(a = 1, b = 2, c = 3), 
  list(a = 4, b = 5, c = 6)
)
pluck(obj, 2, "b")
#> [1] 5
str(transpose(obj))
#> List of 3
#>  $ a:List of 2
#>   ..$ : num 1
#>   ..$ : num 4
#>  $ b:List of 2
#>   ..$ : num 2
#>   ..$ : num 5
#>  $ c:List of 2
#>   ..$ : num 3
#>   ..$ : num 6

Obs.: Se você estiver com muitos problemas com listas profundas, dê uma olhada nas funções relacionadas a depth() pois elas podem ser muito úteis.

5.3.5.2 Aplicação parcial

Se quisermos pré-preencher os argumentos de uma função (seja para usá-la em uma pipeline ou com alguma função do próprio purrr), temos partial().

soma_varios <- function(x, y, z) x + y + z
nova_soma <- partial(soma_varios, x = 1, y = 2)
nova_soma(3)
#> [1] 6

5.3.5.3 Execução segura

Usar tryCatch() e try() no R sempre foi uma dor de cabeça enorme. O purrr resolve esse problema de maneira elegante e eficaz.

quietly() retorna uma lista com resultado, saída, mensagem e alertas, safely() retorna uma lista com resultado e erro (um destes sempre é NULL), e possibly() silencia o erro e retorna um valor dado pelo usuário.

soma_um <- function(x) { x + 1 }
s_soma_um <- safely(soma_um, 0)
obj <- c(10, 11, "a", 13, 14, 15)

s_soma_um(obj)
#> $result
#> [1] 0
#> 
#> $error
#> <simpleError in x + 1: non-numeric argument to binary operator>

É interessante notar que essas funções são advérbios, pois modificam as funções principais, que geralmente são verbos.

Se quiser ler mais sobre chamadas seguras, veja o texto desse blog