C. Lente

Entendendo o lambda

Uma das funcionalidades mais interessantes do R é a possibilidade de estender a linguagem para domínios específicos. A non-standard evaluation garante que até modificações à estrutura fundamental do R podem ser realizadas sem problema. Hoje vamos falar de um assunto que muita gente quer aprender, mas que pouca gente entende de verdade: funções anônimas.

Se você não souber o que é uma função anônima, pode ser que você conheça esse conceito por outro nome. Também chamada de “função lambda”, “notação de fórmula” ou “notação de til”, ela aparece principalmente em programas que usam o {purrr} apesar de não serem exclusividade desse pacote. Resumindo, se você já viu algo do tipo ~.x e não entendeu do que se tratava, este post é para você.

Introdução

Funções anônimas são, essencialmente, um jeito de simplificar a criação de funções pequenas. Em poucas palavras, o nosso objetivo é não ter que declarar uma função nova inteira com function() { ... } para poder usá-la dentro de um programa.

O exemplo que será utilizado na explicação a seguir será a função conta_na() que (não surpreendentemente) conta o número de NAs em um vetor. Vamos usá-la dentro de um map() para que ela seja aplicada a todas as colunas de um data frame. Sendo assim, partiremos da forma mais verborrágica possível desse código e tentaremos chegar, intuitivamente, nas funções anônimas.

Uma ressalva importante é que a notação explicada aqui só funciona dentro do {tidyverse}! No final do texto será apresentada uma alternativa que funciona fora desse contexto, mas, por enquanto, a notação abaixo só pode aparecer nos argumentos .f, .fn e .fns utilizados dentro do {tidyverse}.

Conceito

Vamos imaginar uma função conta_na() que conta o número de NAs em uma coluna de um data frame. Para aplicá-la a todas as colunas do data frame, podemos, por exemplo, utilizar a função map() do pacote {purrr} como no exemplo abaixo:

conta_na <- function(vetor) {
  sum(is.na(vetor))
}

map_dbl(starwars, conta_na)
#>       name     height       mass hair_color skin_color  eye_color birth_year 
#>          0          6         28          5          0          0         44 
#>        sex     gender  homeworld    species      films   vehicles  starships 
#>          4          4         10          4          0          0          0

No R, quando temos uma função simples de uma linha, é perfeitamente possível não colocar o seu corpo em uma linha separada. Veja como o código já fica um pouco mais compacto (saída omitida daqui em diante):

conta_na <- function(vetor) { sum(is.na(vetor)) }

map_dbl(starwars, conta_na)

Agora, se temos uma função que cabe inteira em uma linha, o R nos permite também omitir as chaves: por exemplo, m if-else pode ser escrito if (cond) resp1 else resp2 se as respostas não tiverem mais de uma linha. No nosso caso, vamos reduzir ainda mais a conta_na():

conta_na <- function(vetor) sum(is.na(vetor))

map_dbl(starwars, conta_na)

O próximo passo seria encurtar o nome do argumento da função, utilizando algo mais genérico. Poucas pessoas sabem, mas o R permite nomes começando com .! Por motivos que ficarão claros a seguir, vamos escolher .x para ser o nome do nosso argumento:

conta_na <- function(.x) sum(is.na(.x))

map_dbl(starwars, conta_na)

Agora vem a grande sacada. Tudo que fizemos até agora funciona no R como um todo, mas, se atendermos algumas condições extras, podemos reduzir ainda mais essa função.

Vamos lá: se a função i) tiver apenas uma linha, ii) tiver apenas 1 argumento, iii) tiver .x como seu único argumento .x e iv) estiver sendo utilizada como argumento de uma função do {tidyverse}, então podemos omitir o function(.x) (já que isso é informação redundante dadas as condições) e trocá-lo por um singelo ~:

conta_na <- ~ sum(is.na(.x))

map_dbl(starwars, conta_na)

O último passo é o motivo de chamarmos essa notação de “função anônima”. Dado que nossa função já é tão pequena e utilizamos ela em apenas um lugar, por que precisamos dar um nome para ela? É mais fácil declará-la diretamente dentro do map() sem um nome, ou seja, “anonimamente”:

map_dbl(starwars, ~ sum(is.na(.x)))
#>       name     height       mass hair_color skin_color  eye_color birth_year 
#>          0          6         28          5          0          0         44 
#>        sex     gender  homeworld    species      films   vehicles  starships 
#>          4          4         10          4          0          0          0

Pronto, agora você sabe o que significa uma função do tipo ~.x. Para treinar, tente fazer o processo reverso como no caso abaixo:

# Fração de valores distintos dentre todos
map_dbl(starwars, ~ length(unique(.x)) / length(.x))

# Desanonimizar
frac_distintos <- ~ length(unique(.x)) / length(.x)
map_dbl(starwars, frac_distintos)

# Remover a notação de til (não é mais necessário mexer no map())
frac_distintos <- function(.x) length(unique(.x)) / length(.x)

# Utilizar um nome melhor para o argumento
frac_distintos <- function(vec) length(unique(vec)) / length(vec)

# Recolocar as chaves
frac_distintos <- function(vec) { length(unique(vec)) / length(vec) }

# Identar o corpo da função
frac_distintos <- function(vec) {
  length(unique(vec)) / length(vec)
}

Agora fica bem mais fácil de entender o que faz o map() lá do começo.

Futuro

A pergunta óbvia agora é: existe um jeito de fazer algo assim fora do {tidyverse}? A resposta é sim e não.

Desde o R 4.1, o R introduziu a sua própria notação anônima. Ela funciona de maneira muito similar ao ~, com a diferença de que você precisa dizer o nome do seu argumento. Abaixo deixo vocês com o processo de simplificação da função conta_na() para a sua versão anônima que pode ser utilizada em qualquer lugar e não só no {tidyverse}:

# Conta o número de NAs em um vetor
conta_na <- function(vetor) {
  sum(is.na(vetor))
}

# Usar uma linha só
conta_na <- function(vetor) { sum(is.na(vetor)) }

# Sem necessidade de usar chaves
conta_na <- function(vetor) sum(is.na(vetor))

# Se a função tem uma linha, podemos usar a nova notação
conta_na <- \(vetor) sum(is.na(vetor))

# O nome do argumento pode ser qualquer coisa, não importa
conta_na <- \(v) sum(is.na(v))

# Anonimizar
map_dbl(starwars, \(v) sum(is.na(v)))
#>       name     height       mass hair_color skin_color  eye_color birth_year 
#>          0          6         28          5          0          0         44 
#>        sex     gender  homeworld    species      films   vehicles  starships 
#>          4          4         10          4          0          0          0

Quase tão bom quanto a notação de til!