Tutorial: {rlang} para Filhotes

Este é um tutorial sobre o pacote {rlang}, um dos mais poderosos e menos conhecidos do R. Ele é vital para a notação compacta do {tidyverse}, conhecida como tidy eval, mas mesmo assim poucas pessoas sabem como ele funciona e como utilizá-lo para criar funções no estilo tidy.

O tutorial não é curto, mas fizemos o nosso melhor para começar com calma e terminar com colinhas para facilitar o uso deste material no dia a dia. Se for necessário, leia e releia uma mesma seção para ter certeza de que o conceito apresentado foi completamente absorvido. No caso de dúvidas, entre em contato conosco e com o resto da comunidade R no nosso Discourse.

Se estiver com preguiça, deixei uma colinha no final do post. Mas, para os corajosos, preparem-se para alguns novos conceitos de programação, vários filhotes de cachorro e muito {rlang}!

O melhor amigo do R

A analogia que vamos usar para explicar o {rlang} gira em torno de um parque repleto de filhotes fofinhos. Nós temos uma única missão nesse parque: fazer carinho nos cachorros. Para isso, temos uma função carinho() que recebe o nome de um filhote e imprime uma frase explicando em quem estamos fazendo carinho. O objeto que descreve o filhote se resume a uma string com a sua cor.

rex <- "laranja"
carinho(rex)
#> Fazendo carinho no filhote laranja

Para facilitar a compreensão do código, vamos também ilustrar esse cenário. Na figura abaixo, note que é criado um objeto rex que recebe a figura de um filhote laranja. A função carinho() é retratada como uma mão parada que, quando executada, retorna uma mão fazendo carinho no filhote de cor apropriada (essencialmente a string retornada pelo código acima).

Expressões

Agora que temos uma base sólida para a analogia, podemos introduzir o primeiro conceito importante do {rlang}: expressões. Uma expressão no R não passa do código antes antes que ele seja avaliado, ou seja, aquilo que você escreve e que, depois de executado no console do RStudio, se torna um resultado. Em quase 100% dos casos, o R não faz nenhuma distinção entre a expressão e o valor que ela retorna, de modo que executar carinho(rex) fica equivalente a executar carinho("laranja"). Esse comportamento é chamado de avaliação ansiosa, justamente porque o R avalia cada parte da expressão tão cedo quanto for possível.

Essa, entretanto, não é única forma de avaliação. Também é possível capturar uma expressão, “impedindo” o R de avaliá-la, em um processo denominado avaliação preguiçosa. A função do {rlang} que faz isso se chama expr() e ela retorna a expressão passada, vulgo o código escrito.

e <- expr(carinho(max))
e
#> carinho(max)

Veja que não importa que não existe ainda um filhote chamado max! Como estamos lidando apenas com uma expressão sem contexto, isso é perfeitamente possível.

Voltando para o nosso parque de cachorros, a avaliação preguiçosa se torna quase uma promessa de fazer carinho em um filhote. Temos toda a informação necessária (no caso, o nome do filhote), mas não transformamos isso na ação de fazer carinho: não “chamamos o filhote para perto” para acariciá-lo. Perceba que na figura abaixo não há as marcas de movimento da mão, pois estamos congelando a cena antes de o filhote vir até nós.

Ambientes

Na nossa analogia, o próximo conceito representa o parque em si, um lugar onde há uma correspondência entre nomes de cachorros. No R, um ambiente funciona como um dicionário que contém definições de objetos acompanhados pelos valores que eles carregam.

Abaixo, vamos “trazer” dois novos cachorros para o parque, ou seja, criar dois novos objetos. A função env_print() mostra todas as correspondências presentes no ambiente (incluindo a da função carinho()), além de algumas informações extras que não nos interessam agora.

max <- "marrom"
dex <- "bege"
env_print()
#> <environment: global>
#> parent: <environment: package:rlang>
#> bindings:
#>  * rex: <chr>
#>  * e: <language>
#>  * mtcars: <tibble[,11]>
#>  * carinho: <fn>
#>  * dex: <chr>
#>  * max: <chr>

Na analogia, estamos colocando os cachorros max e dex no parque, permitindo que possamos eventualmente fazer carinho neles. Vamos apenas ignorar a definição da função carinho() para que isso não atrapalhe o resto da explicação.

Perceba que o resultado da avaliação de uma expressão depende completamente do ambiente. Na hora de executar um código, o R procura as definições de todos os objetos no ambiente e os substitui dentro da expressão. Agora vamos ver o que aconteceria se tentássemos fazer carinho no filhote chamado max em outro parque…

Avaliando expressões

Avaliação nua (bare evaluation no original) é o processo pelo qual o {rlang} permite que forneçamos explicitamente um ambiente no qual avaliar uma expressão. É como se pudéssemos escolher o parque no qual vamos chamar um filhote para acariciá-lo.

No código abaixo vamos visitar um outro parque, isto é, criar uma função. O ambiente dentro de uma função herda as definições do ambiente global, mas podemos fazer alterações lá dentro que não são propagadas para fora. Vide a função abaixo: p() define um objeto max com a cor verde e avalia (ou seja, executa) uma expressão lá dentro.

p <- function(x) {
  max <- "verde"
  eval_tidy(x)
}

Seguindo a analogia dos filhotes, é como se visitássemos um outro parque onde há um cachorro chamado max cuja cor é verde (além dos outros dois cachorros que já havíamos visto no parque antigo).

Como a função eval_tidy(), por padrão, utiliza o ambiente corrente para avaliar expressões, então p(e) deve indicar carinho em um filhote verde e não mais em um filhote marrom. Note que, apesar de não estarmos passando um ambiente explicitamente para a eval_tidy(), ela está obtendo esse ambiente através de caller_env(), o valor padrão para seu argumento env.

p(e)
#> Fazendo carinho no filhote verde

Na ilustração a seguir vemos o que aconteceria no nosso parque. Apesar de estarmos chamando o nome do cachorro marrom, como estamos em outro parque (uma nova função), o cachorro que responderá ao chamado aqui é verde!

Quosures

Agora que você entende o que é uma expressão, o que é um ambiente e como podemos avaliar uma expressão dentro de um ambiente, chegou a hora de entender a estrutura mais importante do {rlang}: as quosures. Esse nome estranho vem de quote e closure, dois conceitos extremamente importantes da Ciência da Computação, mas explicar o que eles significam foge do escopo deste tutorial.

Uma quosure, apesar de parecer um conceito complexo, não passa de uma expressão que carrega um apontador para seu ambiente consigo. Isso não parece ser muito útil, mas é a quosure que permite que as funções do {tidyverse} sejam capazes de acessar as colunas de uma tabela e variáveis declaradas no ambiente global.

q <- quo(carinho(max))
q
#> <quosure>
#> expr: ^carinho(max)
#> env:  global

Pensando nos filhotes, uma quosure é a promessa de fazer carinho em um cachorro sabendo exatamente o endereço do parque em que ele estava Note que, na saída acima, o env se chama “global”, justamente porque estamos trabalhando diretamente na sessão base do R.

Na figura abaixo, juntamente da cena retratada na ilustração sobre expressões, vemos um qualificador de max, especificando onde devemos encontrar ele. Isso é significativamente diferente de simplesmente gritar pelo max mais próximo.

Avaliando quosures

Assim como utilizamos a avaliação nua para obter o resultado de uma expressão em um certo ambiente, podemos usar a avaliação tidy (de tidy evaluation) para obter o resultado de uma quosure no ambiente que ela carrega.

Aqui, depois de capturar a quosure, podemos fazer o que quisermos com o ambiente na qual avaliaremos ela, pois o único ambiente que importará na avaliação tidy é o de seu ambiente de origem. Sendo assim, perceba que o argumento env de eval_tidy() não foi levado em conta!

p(q)
#> Fazendo carinho no filhote marrom

É difícil traduzir esse processo para a analogia dos filhotes, mas seria algo como voltar para o endereço do parque original antes de fazer carinho no filhote cujo nome é max.

Bang-bang

A peça final do quebra-cabeça do {rlang} é o bang-bang, também conhecido como quasiquotation e expresso na forma de duas exclamações: !!. Essa funcionalidade, exclusiva ao {rlang}, permite que façamos uma “avaliação ansiosa seletiva” em uma expressão ou quosure. Em breve ficará mais claro onde isso pode ser útil, mas antes é necessário ver como usar o bang-bang na prática.

O bang-bang “força” a avaliação de uma parte da expressão, liberando o R para fazer parte do seu trabalho de avaliação ansiosa. Veja, no código abaixo, como funciona a captura de uma expressão que usa o bang-bang. Atente-se para o fato de que, na seção anterior, alteramos o valor de max.

expr(carinho(!!max))
#> carinho("marrom")

Na analogia dos filhotes, o bang-bang está essencialmente chamando um cachorro pelo nome antes que façamos carinho nele. Ao invés do contorno branco que vimos nas ilustrações sobre expressões e quosures, agora vemos a mão parada ao lado de um filhote específico.

De volta para casa

Apesar de termos visto um pouco de código R na sessão anterior, agora é necessário aprofundar um pouco os exemplos. Prometo que não será nada muito difícil, mas é impossível entender como aplicar o {rlang} no mundo real sem ver alguns casos de uso.

Na maior parte das ocasiões, não usaremos nenhuma das funções vistas até agora, salvo pelo bang-bang (que na verdade não é uma função, mas sim uma sintaxe). O principal uso do {rlang}, na verdade, é capturar código que o usuário escreve, então é necessário conhecer novas versões de expr() e quo() que são capazes de capturar expressões e quosures vindas de fora de uma função.

Enriquecimento

O conceito de enriquecimento vem de uma analogia meio ruim criada pelo autor do {rlang}; para simplificar, pense que as versões enriquecidas de expr() e quo() são mais “fortes” que as versões normais, sendo capazes de sair de dentro de uma função para capturar expressões do lado de fora.

Abaixo é possível ver uma função que tenta capturar o nome de um filhote, mas é incapaz de fazê-lo por causa da avaliação ansiosa do R. O correto seria capturar a expressão escrita pelo usuário e imprimí-la como uma string.

nome1 <- function(filhote) {
  cat("O nome do filhote é", filhote)
}
nome1(dex)
#> O nome do filhote é bege

Podemos ver a versão correta da função desejada em nome2(). Ela captura a expressão do usuário com enexpr() (a versão enriquecida de expr()) e converte esse objeto em uma string com expr_text(), permitindo que a função imprima o nome do filhote.

nome2 <- function(filhote) {
  nome <- enexpr(filhote)
  cat("O nome do filhote é", expr_text(nome))
}
nome2(dex)
#> O nome do filhote é dex

Como não havia necessidade nenhuma de capturar o ambiente do usuário nesse exemplo, usamos apenas enexpr(). Na maioria das situações, entretanto, é preciso usar enquo() para obter o comportamento correto. Já que quosures incluem expressões, expr() e enexpr() quase nunca são estritamente necessárias, então vamos simplificar tudo e seguir apenas com as quosures.

No código abaixo a função explica() precisa tanto da expressão quanto do ambiente da mesma, ou seja, da quosure escrita pelo usuário.

explica <- function(acao) {
  quosure <- enquo(acao)
  cat("`", quo_text(quosure), "` retorna:\n", sep = "")
  eval_tidy(quosure)
}
explica(carinho(dex))
#> `carinho(dex)` retorna:
#> Fazendo carinho no filhote bege

Preste bastante atenção em explica(), pois pode ser que não seja fácil entender como ela funciona. A primeira função utilizada é a enquo() (quo() enriquecida), que captura a expressão do usuário juntamente com o seu ambiente. A seguir, temos apenas que converter a quosure em string com quo_text() para poder imprimí-la. O último passo é avaliar a quosure para obter um resultado exatamente igual ao que o usuário obteria se decidisse executar a expressão passada como argumento.

Curly-curly

A combinação da enquo() com o bang-bang é justamente a forma correta de implementar funções que trabalham com o {tidyverse}. A função summarise(), por exemplo, não passa de um enquo() disfarçado, o que quer dizer que podemos usar o bang-bang para “injetar” o nome de uma variável dentro de um cálculo.

media1 <- function(df, var) {
  summarise(df, resultado = mean(var))
}
media1(mtcars, cyl)
#> Error: Problem with `summarise()` column `resultado`.
#> ℹ `resultado = mean(var)`.
#> x object 'cyl' not found

O código acima, que não usa bang-bang, retorna um erro. O problema é que a summarise() está tentando tirar a média de um objeto chamado var, que carrega um objeto chamado cyl, que simplesmente não existe no ambiente global. Abaixo, usando bang-bang e enquo(), o código funciona como esperado porque mean(!!var) se torna mean(cyl) dentro da summarise().

media2 <- function(df, var) {
  var <- enquo(var)
  summarise(df, resultado = mean(!!var))
}
media2(mtcars, cyl)
#> # A tibble: 1 × 1
#>   resultado
#>       <dbl>
#> 1      6.19

O {tidyverse} nos fornece um atalho para essa combinação poderosa de enquo() com !!: o {{ }}, mais conhecido como curly-curly. Agora que você já entende exatamente o que está acontecendo por trás dos panos, saber onde usar o curly-curly é mais fácil.

media3 <- function(df, var) {
  summarise(df, resultado = mean({{var}}))
}
media3(mtcars, cyl)
#> # A tibble: 1 × 1
#>   resultado
#>       <dbl>
#> 1      6.19

Splice

A penúltima funcionalidade do {rlang} a compreender é o conceito de splice (“emendar” em português), que se manifesta nas versões pluralizadas das funções apresentadas até agora. Essencialmente, expr()/enexpr e quo()/enquo() só conseguem lidar com uma única expressão ou quosure, então temos outras versões para trabalhar com múltiplas expressões ou quosures.

Na prática, a principal função que utilizaremos é enquos(). Ela captura todo o conteúdo de uma elipse e o transforma em uma lista de quosures como no exemplo abaixo. As versões plurais também acompanham o bang-bang-bang, o irmão com splice do bang-bang.

media4 <- function(df, ...) {
  vars <- enquos(...)
  summarise(df, across(c(!!!vars), mean))
}
media4(mtcars, cyl, disp, hp)
#> # A tibble: 1 × 3
#>     cyl  disp    hp
#>   <dbl> <dbl> <dbl>
#> 1  6.19  231.  147.

Se você não estiver familiarizado com a across(), basta saber apenas que o primeiro argumento é um vetor de colunas (similar ao que passaríamos para select()) e o segundo é uma função para utilizar no resumo das colunas especificadas. Aqui o !!! está apenas transformando a chamada em across(c(cyl, disp, hp), mean).

Símbolos

Existe ainda um conceito que não abordamos até agora: símbolos. Um símbolo não passa do nome de um objeto, ou seja, rex, max e dex na analogia dos filhotes; mais especificamente, um símbolo é uma expressão com algumas restrições sobre o seu conteúdo. A função sym(), especificamente, transforma uma string em um símbolo, permitindo que ela seja usada junto com outras expressões.

media6 <- function(df, var) {
  var <- ensym(var)
  summarise(df, resultado = mean(!!var))
}
media6(mtcars, "cyl")
#> # A tibble: 1 × 1
#>   resultado
#>       <dbl>
#> 1      6.19

Miscelânea

Fora os vários conceitos já apresentados, restam apenas duas breves considerações para praticamente zerar o {rlang}:

  1. O curly-curly funciona com strings, mas não com splice, então a família sym() torna-se quase desnecessária juntamente com o !! ao mesmo tempo em que o !!! permanece essencial.

  2. Há um operador específico (:=, chamado de morsa) para quando precisamos forçar a execução de algo no lado esquerdo de um cálculo, mesmo quando usando o curly-curly.

media7 <- function(df, col, ...) {
  args <- enquos(...)
  summarise(df, {{col}} := mean(!!!args))
}
media7(mtcars, "nova_coluna", drat, na.rm = TRUE)
#> # A tibble: 1 × 1
#>   nova_coluna
#>         <dbl>
#> 1        3.60

De volta para o trabalho

Depois de tanto conteúdo, agora você consegue entender as colinhas que apresentamos abaixo para facilitar o seu uso do {rlang} no dia-a-dia. Ao final também deixamos as referências deste tutorial para que você possa aprofundar ainda mais os seus conhecimentos de tidy eval.

Vocabulário

Vocábulo Tradução Significado Código
Ambiente Environment Dicionário de nomes e valores env()
Avaliação ansiosa Eager evaluation Avaliação de todo objeto o mais rápido possível
Avaliação nua Bare evaluation Avaliação que necessita de um ambiente passado explicitamente eval_tidy(e)
Avaliação preguiçosa Lazy evaluation Avaliação de cada objeto conforme a necessidade quo(), etc.
Avaliação tidy Tidy evaluation Avaliação que utiliza o ambiente da quosure eval_tidy(q)
Bang-bang Bang-bang Operador utilizado para forçar a avaliação de um objeto !!
Bang-bang-bang Bang-bang-bang Operador utilizado para forçar a avaliação de vários objetos !!!
Curly-curly Curly-curly Atalho para enquo() + !! {{ }}
Elipse Ellipsis Argumento de uma funcão que pode receber múltiplas entradas ...
Enriquecimento Enriching Processo que permite a captura de código do usuário enquo(), etc.
Expressão Expression Código R antes de avaliado expr()
Morsa Walrus Operador utilizado para permitir expressões do lado esquerdo de uma igualdade :=
Quosure Quosure Expressão que carrega seu ambiente consigo quo()
Símbolo Symbol Expressão que pode representar apenas o nome de um objeto sym()
Splice Splice Processo que permite a captura de múltiplas expressões, etc. quos(), etc.

Principais funções

Objeto Simples Enriquecido
Símbolo sym()/syms() ensym()/ensyms()
Expressão expr()/exprs() enexpr()/enexprs()
Quosure quo()/quos() enquo()/enquos()

Principais padrões

Os padrões incluem dois exemplos que não foram explicados durante o tutorial. Para saber mais, consulte as referências no final do texto.

Descrição Usuário Programador
Expressão do lado esquerdo media(df, col) {{col}} := mean(var)
Expressão do lado direito media(df, var) col = mean({{var}})
Expressões do lado direito media(df, var, arg1 = 0) col = mean(!!!var)
Expressões no meio agrupar(df, var1, var2) group_by(df, ...)
Símbolo do lado esquerdo media(df, &quot;col&quot;) {{col}} := mean(var)
Símbolo do lado direito media(df, &quot;var&quot;) col = mean(.data$var)
comments powered by Disqus