Percursos
/
R
R
/
Programa
/
Programação funcional
Pr

Programação funcional em R

1 exercício

Sobre Programação funcional

O que é a programação funcional?

É uma pergunta complexa, que merece uma resposta longa (a Wikipédia dá uma, o Advanced R dá outra).

Para o que aqui nos interessa, estas são algumas características fundamentais:

  • Funções puras, que mapeiam entradas em saídas de forma consistente e sem efeitos secundários.
  • Dados imutáveis.
  • Funções de primeira classe, que podem ser tratadas como valores tal como qualquer outro tipo.
  • Funções de ordem superior, que recebem outras funções como argumentos ou como valores devolvidos.

No entanto, o R não é uma linguagem funcional pura (ao contrário, por exemplo, de Haskell).

Vê o R como uma linguagem pragmática que (cada vez mais) oferece capacidades funcionais sempre que estas são uma forma limpa e simples de fazer o trabalho. Em geral, o objetivo é a análise de dados, e a programação é apenas um meio para atingir esse fim.

Pipes

A utilização de pipes |> para ligar funções é uma técnica cada vez mais comum em várias linguagens.

Em resumo:

  • Um resultado à esquerda do pipe passa a ser o primeiro argumento da função à direita (em que "direita" ignora as quebras de linha opcionais).
  • Quaisquer outros argumentos podem ser incluídos na chamada à função.
  • No R, temos de encadear o pipe para uma chamada de função, não apenas para um nome, por isso os parênteses são sempre obrigatórios (ao contrário do que acontece noutras linguagens).

Alguns programadores acham útil ler o pipe como "e depois" ao rever código: faz o passo 1 E DEPOIS o passo 2...

O exemplo abaixo ilustra a sintaxe.

library(stringr)

"Monday-25-February" |> 
  str_split("-") |>     
  unlist() |>               # [1] "Monday"   "25"       "February"
  str_sub(1, 3) |>          # [1] "Mon" "25"  "Feb"
  str_to_upper() |>         # [1] "MON" "25"  "FEB"
  str_flatten(collapse = ", ")
#> [1] "MON, 25, FEB"

Este tipo de pipe foi adicionado ao R base na versão 4.1.0, e podes vê-lo referido como o "pipe nativo".

Antes, o encadeamento fazia-se com %>% do pacote magrittr (o nome é um trocadilho para os amantes de arte). O "pipe do magrittr" continua disponível, mas os pipes nativos evitam a necessidade de um pacote externo e a implementação é mais simples nalguns detalhes.

O editor RStudio (entre outros) usa Ctrl-Shift-M como atalho de teclado para adicionar um pipe com os espaços adequados.

Funções de aplicação

Vimos anteriormente que muitas funções no R são "vetorizadas": atuam sobre vetores inteiros, sem necessidade de escrever ciclos explícitos.

As funções que usam apenas funções vetorizadas são, elas próprias, provavelmente vetorizadas automaticamente. Num exemplo trivial:

triple <- function(x) return(3 * x)
triple(1:10)
 [1]  3  6  9 12 15 18 21 24 27 30

No entanto, funções mais complexas podem não aceitar entradas vetoriais, por exemplo se contiverem ciclos ou blocos if-else.

Precisamos de uma forma mais geral de aplicar funções a vetores (semelhante a map() noutras linguagens).

Como o R tem uma grande variedade de estruturas de dados na linguagem base, também tem toda uma família de funções *apply() para operar sobre elas. Mais tecnicamente, os programadores chamam-lhes muitas vezes "funções de ordem superior" e os matemáticos "funcionais", porque recebem funções como argumentos.

Por agora, considera o lapply() e o sapply().

lapply()

O "list-apply" foi concebido para funcionar com entradas do tipo lista, mas os vetores serão convertidos silenciosamente em listas sempre que necessário.

A saída é uma lista, por isso pode ser necessário desempacotá-la para obter um vetor:

h <- function(size) {
    switch(size,
           "small" = 6,
           "medium" = 8,
           "large" = 10
    )
}

v <- c("small", "large", "medium")

h(v) # bad use of an unvectorized function
#> Error in switch(size, small = 6, medium = 8, large = 10) : 
#>   EXPR must be a length 1 vector

lapply(v, h)  # output is a list
#> [[1]]
#> [1] 6

#> [[2]]
#> [1] 10

#> [[3]]
#> [1] 8

lapply(v, h) |> unlist() # output is a vector
#> [1]  6 10  8

Os estudantes familiarizados com o Conceito Switch podem reparar que podíamos, em alternativa, substituir o switch escalar pela função vetorizada recode_values() da biblioteca dplyr. A vetorização é boa, e o R moderno oferece várias formas de a conseguir.

library(dplyr)

h <- function(size) {
  recode_values(size,
         "small" ~ 6,
         "medium" ~ 8,
         "large" ~ 10
  )
}

v <- c("small", "large", "medium")
h(v)
#> [1]  6 10  8

sapply()

O "simplified-apply" faz algo semelhante ao lapply, mas também tenta simplificar o valor devolvido: por exemplo, um vetor com nomes em vez de uma lista. Consoante o contexto, pode ser necessário usar unname():

sapply(v, h)
 small  large medium 
     6     10      8 

sapply(v, h) |> unname()
#> [1]  6 10  8

A biblioteca purrr

O lapply e o sapply são simples, familiares à maioria dos utilizadores de R e ainda bastante populares para vetores e listas simples.

O R base também tem várias outras funções de aplicação:

  • vapply() é semelhante a sapply(), mas também tem um parâmetro obrigatório para especificar o formato da saída.
  • apply() para matrizes e arrays de dimensões superiores.
  • mapply() para vários argumentos de lista ou de vetor em paralelo.
  • tapply() é mais difícil de explicar, mas funciona com operações de agrupamento e arrays irregulares.
  • vectorize() é uma função bastante misteriosa, que parece ser um invólucro para mapply().

Já chega!

Proporcionar uma alternativa mais simples e mais consistente a este tipo de confusão é um dos principais objetivos do Tidyverse.

Existe agora o pacote purrr, que oferece várias opções adicionais, como map(), ao mesmo tempo que melhora a consistência do design e a clareza das mensagens de erro.

Menos limitado pela compatibilidade com versões anteriores do que o R base, este pacote aproxima significativamente o R da programação funcional moderna que encontramos noutras linguagens.

Há muitas funções diferentes no purrr, que não se resumem facilmente. Os recursos que ajudam a navegar na biblioteca incluem:

Note

A biblioteca purrr opera quase exclusivamente sobre dados unidimensionais: vetores e listas.

Veremos num conceito posterior que a biblioteca dplyr oferece funcionalidades equivalentes para dataframes bidimensionais.

As muitas funções map

A função map() aplica uma função a cada elemento da entrada e devolve sempre uma lista.

Até aqui, isto soa a lapply() apenas com outro nome, e talvez com melhores mensagens de erro.

No entanto, map() é apenas um membro de uma família numerosa.

Algumas perguntas a ter em conta ao escolher uma função:

  • O que queres devolver? O map() devolve sempre uma lista, mas se quiseres um tipo específico de vetor atómico usa o map_chr() para strings, o map_int() para inteiros, etc.
  • Precisas de cada índice posicional da entrada? Antepõe um i ao nome da função, como em imap().
  • Queres processar várias entradas em paralelo?
    • Substitui map por map2 no nome, como em map2_dbl() para duas entradas (por exemplo, valores e pesos, para calcular uma média ponderada)
    • Substitui por pmap para uma lista de entradas paralelas, como em pmap_chr(). E sim, o paralelismo ao nível do hardware é possível.
  • Queres processar todos os elementos da entrada? Se não, considera o map_if() para aplicar um filtro com um predicado (booleano), o map_at() para especificar critérios de índice posicional, ou o head_while() para processar a entrada até um elemento falhar um predicado.
  • Os dados de entrada estão profundamente aninhados? Existem funções para essa situação.
  • Queres que o valor devolvido seja sempre do mesmo tipo que a entrada? Usa as funções modify() em vez do map, ou o modify_tree() para aplicação recursiva.

Estas funções têm os dados de entrada como primeiro argumento sempre que possível, para garantir que funcionam bem com pipes.

Além disso, o purrr é (deliberadamente) mais rigoroso do que o R base. Se pedires algo específico, as funções devolvem exatamente isso ou param com uma mensagem de erro. É duro, mas muito eficaz a reduzir bugs difíceis de encontrar.

Funções reduce

As funções map têm geralmente um valor devolvido com o mesmo comprimento da entrada.

Outras funções reduzem a dimensionalidade dos dados, por exemplo sum().

sum(1:100)
#> [1] 5050

No caso acima, convertemos um vetor de comprimento 100 num inteiro de comprimento 1. Veremos num Conceito futuro sobre matrizes e arrays que o conceito de redução de dimensionalidade é mais geral.

A função sum() é incorporada (tal como muitas outras funções estatísticas). No entanto, precisamos de uma forma de aplicar funções arbitrárias de redução de dimensionalidade a uma estrutura de dados, usando alguma função de ordem superior equivalente ao map().

Noutras linguagens, usam-se nomes de funções como fold, foldl e foldr. No R, a função relevante é o reduce(), que corresponde à conhecida (para os entusiastas de algoritmos) framework MapReduce.

O primeiro argumento é, como habitualmente, os dados de entrada. O segundo argumento é uma função de 2 argumentos: muitas vezes uma função anónima, mas possivelmente tão simples como um operador aritmético, como +.

# surround an infix operator with backticks, to use as a function
reduce(1:100, `+`)
#> [1] 5050  # same as sum(1:100)

# anonymous function, alternately add and subtract
reduce(1:10, \(accum, nextval) ifelse(nextval %% 2 == 0, accum + nextval, accum - nextval))
#> [1] 7

Importa se aplicamos a função a partir do início ou do fim da entrada?

Não, com operadores associativos como + ou *. Claramente, (1 + 2) + 3 == 1 + (2 + 3).

Em contraste, - e / não são associativos:

(1 - 2) - 3
#> [1] -4
1 - (2 - 3)
#> [1] 2

Para funções em que a direção importa, existe um argumento opcional .dir.

# default is .dir = "forward"
reduce(1:10, `-`)
#> [1] -53
reduce(1:10, `-`, .dir = "backward")
#> [1] -5

Tal como com map() e map2(), existe também uma função reduce2() para operar sobre dois vetores de entrada em paralelo.

No R, gostamos de trabalhar com vetores. Suponhamos que, em vez de um único valor final, queres ver todos os passos intermédios.

Para isso, as funções correspondentes são accumulate() e accumulate2(). A sintaxe é semelhante ao reduce(), com alguns argumentos opcionais extra para especificar o tipo de saída.

accumulate(1:10, `+`)
#> [1]  1  3  6 10 15 21 28 36 45 55

Alguns tipos comuns de accumulate têm as suas próprias funções. O Conceito Funções de Vetor abordou cumsum() e cumprod() no R base, e a biblioteca dplyr tem cummean() para médias numéricas, cumall() e cumany() para vetores lógicos.

Funções de filtragem

Frequentemente, queremos incluir (ou excluir) elementos da entrada que correspondam a um determinado predicado.

No Conceito Filtragem de Vetores vimos uma forma idiomática de o fazer.

v <- 1:3
v[v >= 2]
#> [1] 2 3

Isto é muito usado no R tradicional, mas encaixa mal nos pipelines funcionais modernos.

Por isso, o purrr oferece um conjunto de funções de predicado, que seguem a convenção habitual de argumentos f(input, func, ...) para funcionarem com pipes.

Os nomes são um pouco diferentes da maioria das outras linguagens (nomes como filter e drop já significam algo completamente diferente no R).

As funções purrr correspondentes incluem keep() e discard(), entre várias outras.

Temos também every(), some() e none(), para substituir all() e any() ao devolver um único valor lógico.

Recursão

A maioria das linguagens funcionais usa a recursão como alternativa preferida aos ciclos.

O R tem outras opções, muitas vezes melhores: funções vetorizadas e funções de ordem superior.

No entanto, a recursão é suportada no R e pode ser útil em contextos como percorrer estruturas em árvore.

A profundidade máxima de recursão é, por omissão, 5000, o que é relativamente generoso (no Python, o valor por omissão é 1000). É possível substituir o valor por omissão.

A otimização de chamadas em cauda não é padrão no R, embora tenham sido desenvolvidas algumas soluções alternativas.

A tradição exige que mostremos aqui um exemplo de fatorial, como o exemplo clássico de uma definição recursiva.

fact <- function(n) ifelse(n == 0, 1, n * fact(n - 1))
fact(5)
#> [1] 120

# some more idiomatic R:
factorial(5)
#> [1] 120
prod(1:5)
#> [1] 120

Podes usar recursão no R, e por vezes é útil, mas há muitas vezes abordagens mais simples.

Editar via GitHub A ligação abre numa nova janela ou separador

Aprende Programação funcional