20  Evaluación

20.1 Introducción

El inverso de la cita de cara al usuario es la no cita: le da al usuario la capacidad de evaluar selectivamente partes de un argumento citado de otro modo. El complemento de cotización para el desarrollador es la evaluación: esto le da al desarrollador la capacidad de evaluar expresiones citadas en entornos personalizados para lograr objetivos específicos.

Este capítulo comienza con una discusión de la evaluación en su forma más pura. Aprenderá cómo eval() evalúa una expresión en un entorno, y luego cómo se puede usar para implementar una serie de importantes funciones básicas de R. Una vez que tenga los conceptos básicos bajo su cinturón, aprenderá las extensiones a la evaluación que se necesitan para la solidez. Hay dos grandes ideas nuevas:

  • El quosure: una estructura de datos que captura una expresión junto con su entorno asociado, como se encuentra en los argumentos de función.

  • La máscara de datos, que facilita la evaluación de una expresión en el contexto de un marco de datos. Esto introduce una posible ambigüedad de evaluación que luego resolveremos con pronombres de datos.

Juntos, la cuasicotización, las cuotas y las máscaras de datos forman lo que llamamos evaluación ordenada, o evaluación ordenada para abreviar. Tidy eval proporciona un enfoque basado en principios para la evaluación no estándar que hace posible el uso de dichas funciones de forma interactiva e integrada con otras funciones. La evaluación ordenada es la implicación práctica más importante de toda esta teoría, por lo que dedicaremos un poco de tiempo a explorar las implicaciones. El capítulo termina con una discusión de los enfoques relacionados más cercanos en base R, y cómo puede programar alrededor de sus inconvenientes.

Estructura

  • La Sección 20.2 analiza los aspectos básicos de la evaluación usando eval() y muestra cómo puede usarlo para implementar funciones clave como local() y source().

  • La Sección 20.3 introduce una nueva estructura de datos, el quosure, que combina una expresión con un entorno. Aprenderá cómo capturar cuotas de promesas y evaluarlas usando rlang::eval_tidy().

  • La Sección 20.4 extiende la evaluación con la máscara de datos, lo que hace que sea trivial entremezclar símbolos vinculados en un entorno con variables que se encuentran en un marco de datos.

  • La Sección 20.5 muestra cómo usar la evaluación ordenada en la práctica, centrándose en el patrón común de citar y quitar las comillas, y cómo manejar la ambigüedad con los pronombres.

  • La Sección 20.6 vuelve a la evaluación en base R, analiza algunas de las desventajas y muestra cómo usar la cuasicita y la evaluación para envolver funciones que usan NSE.

Requisitos previos

Deberá estar familiarizado con el contenido del Capítulo 18 y el Capítulo 19, así como con la estructura de datos del entorno (Sección 7.2) y el entorno de la persona que llama (Sección 7.5).

Seguiremos usando rlang y purrr.

library(rlang)
library(purrr)

20.2 Conceptos básicos de evaluación

Aquí exploraremos los detalles de eval() que mencionamos brevemente en el último capítulo. Tiene dos argumentos clave: expr y env. El primer argumento, expr, es el objeto a evaluar, típicamente un símbolo o expresión1. Ninguna de las funciones de evaluación cita sus entradas, por lo que normalmente las usará con expr() o similar:

x <- 10
eval(expr(x))
#> [1] 10

y <- 2
eval(expr(x + y))
#> [1] 12

El segundo argumento, env, proporciona el entorno en el que debe evaluarse la expresión, es decir, dónde buscar los valores de x, y y +. De forma predeterminada, este es el entorno actual, es decir, el entorno de llamada de eval(), pero puede anularlo si lo desea:

eval(expr(x + y), env(x = 1000))
#> [1] 1002

El primer argumento se evalúa, no se cita, lo que puede generar resultados confusos una vez si usa un entorno personalizado y se olvida de citar manualmente:

eval(print(x + 1), env(x = 1000))
#> [1] 11
#> [1] 11

eval(expr(print(x + 1)), env(x = 1000))
#> [1] 1001

Ahora que ha visto los conceptos básicos, exploremos algunas aplicaciones. Nos centraremos principalmente en las funciones básicas de R que podría haber usado antes, volviendo a implementar los principios subyacentes usando rlang.

20.2.1 Aplicar: local()

A veces, desea realizar una parte del cálculo que crea algunas variables intermedias. Las variables intermedias no tienen un uso a largo plazo y podrían ser bastante grandes, por lo que preferiría no mantenerlas. Un enfoque es limpiar lo que ensucia usando rm(); otra es envolver el código en una función y simplemente llamarlo una vez. Un enfoque más elegante es usar local():

# Limpiar variables creadas anteriormente
rm(x, y)

foo <- local({
  x <- 10
  y <- 200
  x + y
})

foo
#> [1] 210
x
#> Error in eval(expr, envir, enclos): object 'x' not found
y
#> Error in eval(expr, envir, enclos): object 'y' not found

La esencia de local() es bastante simple y se vuelve a implementar a continuación. Capturamos la expresión de entrada y creamos un nuevo entorno en el que evaluarla. Este es un nuevo entorno (por lo que la asignación no afecta el entorno existente) con el entorno de la persona que llama como padre (para que expr aún pueda acceder a las variables en ese entorno). Esto emula efectivamente la ejecución de expr como si estuviera dentro de una función (es decir, tiene un alcance léxico, Sección 6.4).

local2 <- function(expr) {
  env <- env(caller_env())
  eval(enexpr(expr), env)
}

foo <- local2({
  x <- 10
  y <- 200
  x + y
})

foo
#> [1] 210
x
#> Error in eval(expr, envir, enclos): object 'x' not found
y
#> Error in eval(expr, envir, enclos): object 'y' not found

Comprender cómo funciona base::local() es más difícil, ya que usa eval() y substitute() juntos de formas bastante complicadas. Descubrir exactamente lo que está pasando es una buena práctica si realmente quiere comprender las sutilezas de substitute() y las funciones base eval(), por lo que se incluyen en los ejercicios a continuación.

20.2.2 Aplicar: source()

Podemos crear una versión simple de source() combinando eval() con parse_expr() de la Sección 18.4.3. Leemos el archivo desde el disco, usamos parse_expr() para analizar la cadena en una lista de expresiones y luego usamos eval() para evaluar cada elemento a su vez. Esta versión evalúa el código en el entorno de la persona que llama e invisiblemente devuelve el resultado de la última expresión en el archivo como base::source().

source2 <- function(path, env = caller_env()) {
  file <- paste(readLines(path, warn = FALSE), collapse = "\n")
  exprs <- parse_exprs(file)

  res <- NULL
  for (i in seq_along(exprs)) {
    res <- eval(exprs[[i]], env)
  }

  invisible(res)
}

La ‘source()’ real es considerablemente más complicada porque puede hacer eco de entrada y salida, y tiene muchas otras configuraciones que controlan su comportamiento.

20.2.3 Gotcha: function()

Hay un pequeño inconveniente que debe tener en cuenta si está utilizando eval() y expr() para generar funciones:

x <- 10
y <- 20
f <- eval(expr(function(x, y) !!x + !!y))
f
#> function(x, y) !!x + !!y

Esta función no parece funcionar, pero lo hace:

f()
#> [1] 30

Esto se debe a que, si está disponible, las funciones imprimen su atributo srcref (Sección 6.2.1), y debido a que srcref es una característica base de R, no reconoce las cuasicita.

Para solucionar este problema, utilice new_function() (Sección 19.7.4) o elimine el atributo srcref:

attr(f, "srcref") <- NULL
f
#> function (x, y) 
#> 10 + 20

20.2.4 Ejercicios

  1. Lea atentamente la documentación de source(). ¿Qué entorno usa por defecto? ¿Qué sucede si proporciona local = TRUE? ¿Cómo se proporciona un entorno personalizado?

  2. Prediga los resultados de las siguientes líneas de código:

    eval(expr(eval(expr(eval(expr(2 + 2))))))
    eval(eval(expr(eval(expr(eval(expr(2 + 2)))))))
    expr(eval(expr(eval(expr(eval(expr(2 + 2)))))))
  3. Complete los cuerpos de función a continuación para volver a implementar get() usando sym() y eval(), y assign() usando sym(), expr() y eval (). No se preocupe por las múltiples formas de elegir un entorno que admiten get() y assign(); suponga que el usuario lo proporciona explícitamente.

    # el nombre es una cadena
    get2 <- function(name, env) {}
    assign2 <- function(name, value, env) {}
  4. Modifique source2() para que devuelva el resultado de todas las expresiones, no solo la última. ¿Puedes eliminar el bucle for?

  5. Podemos hacer que base::local() sea un poco más fácil de entender al distribuirlo en varias líneas:

    local3 <- function(expr, envir = new.env()) {
      call <- substitute(eval(quote(expr), envir))
      eval(call, envir = parent.frame())
    }

    Explique cómo funciona local() en palabras. (Sugerencia: es posible que desee print(call) para ayudar a comprender qué está haciendo substitute(), y lea la documentación para recordar de qué entorno se heredará new.env()).

20.3 Quosures

Casi todos los usos de eval() implican tanto una expresión como un entorno. Este acoplamiento es tan importante que necesitamos una estructura de datos que pueda contener ambas piezas. Base R no tiene tal estructura 2, por lo que rlang llena el espacio con quosure, un objeto que contiene una expresión y un entorno. El nombre es un acrónimo de cita y cierre, porque un quosure cita la expresión y encierra el entorno. Quosures cosifica el objeto de promesa interna (Sección 6.5.1) en algo con lo que puede programar.

En esta sección, aprenderá cómo crear y manipular quosures, y un poco sobre cómo se implementan.

20.3.1 Creando

Hay tres formas de crear quosures:

  • Use enquo() y enquos() para capturar expresiones proporcionadas por el usuario. La gran mayoría de las cuotas deben crearse de esta manera.

    foo <- function(x) enquo(x)
    foo(a + b)
    #> <quosure>
    #> expr: ^a + b
    #> env:  global

  • quo() y quos() existen para coincidir con expr() y exprs(), pero se incluyen solo para completar y se necesitan muy raramente. Si te encuentras usándolos, piensa cuidadosamente si expr() y quitar las comillas cuidadosamente pueden eliminar la necesidad de capturar el entorno.

    quo(x + y + z)
    #> <quosure>
    #> expr: ^x + y + z
    #> env:  global

  • new_quosure() crear un quosure a partir de sus componentes: una expresión y un entorno. Esto rara vez se necesita en la práctica, pero es útil para el aprendizaje, por lo que se usa mucho en este capítulo.

    new_quosure(expr(x + y), env(x = 1, y = 10))
    #> <quosure>
    #> expr: ^x + y
    #> env:  0x5597a825a918

20.3.2 Evaluando

Las cuotas se combinan con una nueva función de evaluación eval_tidy() que toma una única cuota en lugar de un par expresión-entorno. Es fácil de usar:

q1 <- new_quosure(expr(x + y), env(x = 1, y = 10))
eval_tidy(q1)
#> [1] 11

Para este caso simple, eval_tidy(q1) es básicamente un atajo para eval(get_expr(q1), get_env(q1)). Sin embargo, tiene dos características importantes de las que aprenderá más adelante en el capítulo: admite quósures anidados (Sección 20.3.5) y pronombres (Sección 20.4.2).

20.3.3 Puntos

Los quosures suelen ser solo una conveniencia: hacen que el código sea más limpio porque solo tiene un objeto para pasar, en lugar de dos. Sin embargo, son esenciales cuando se trata de trabajar con ... porque es posible que cada argumento pasado a ... se asocie con un entorno diferente. En el siguiente ejemplo, tenga en cuenta que ambos quosures tienen la misma expresión, x, pero un entorno diferente:

f <- function(...) {
  x <- 1
  g(..., f = x)
}
g <- function(...) {
  enquos(...)
}

x <- 0
qs <- f(global = x)
qs
#> <list_of<quosure>>
#> 
#> $global
#> <quosure>
#> expr: ^x
#> env:  global
#> 
#> $f
#> <quosure>
#> expr: ^x
#> env:  0x5597a59be9e0

Eso significa que cuando los evalúas, obtienes los resultados correctos:

map_dbl(qs, eval_tidy)
#> global      f 
#>      0      1

Evaluar correctamente los elementos de ... fue una de las motivaciones originales para el desarrollo de quosures.

20.3.4 Bajo el capó

Quosures se inspiró en las fórmulas de R, porque las fórmulas capturan una expresión y un entorno:

f <- ~runif(3)
str(f)
#> Class 'formula'  language ~runif(3)
#>   ..- attr(*, ".Environment")=<environment: R_GlobalEnv>

Una versión anterior de la evaluación ordenada usaba fórmulas en lugar de quosures, ya que una característica atractiva de ~ es que proporciona citas con una sola pulsación de tecla. Desafortunadamente, sin embargo, no hay una forma limpia de hacer que ~ sea una función de cuasicomillas.

Las cuotas son una subclase de fórmulas:

q4 <- new_quosure(expr(x + y + z))
class(q4)
#> [1] "quosure" "formula"

lo que significa que, bajo el capó, las coyunturas, como las fórmulas, son objetos de llamada:

is_call(q4)
#> [1] TRUE

q4[[1]]
#> Warning: Subsetting quosures with `[[` is deprecated as of rlang 0.4.0
#> Please use `quo_get_expr()` instead.
#> This warning is displayed once every 8 hours.
#> `~`
q4[[2]]
#> x + y + z

con un atributo que almacena el entorno:

attr(q4, ".Environment")
#> <environment: R_GlobalEnv>

Si necesita extraer la expresión o el entorno, no confíe en estos detalles de implementación. En su lugar, utilice get_expr() y get_env():

get_expr(q4)
#> x + y + z
get_env(q4)
#> <environment: R_GlobalEnv>

20.3.5 Cuosuras anidadas

Es posible usar cuasicomillas para incrustar una quosure en una expresión. Esta es una herramienta avanzada, y la mayoría de las veces no necesita pensar en ella porque simplemente funciona, pero hablo de ella aquí para que pueda detectar quosures anidados en la naturaleza y no confundirse. Tome este ejemplo, que alinea dos quosures en una expresión:

q2 <- new_quosure(expr(x), env(x = 1))
q3 <- new_quosure(expr(x), env(x = 10))

x <- expr(!!q2 + !!q3)

Se evalúa correctamente con eval_tidy():

eval_tidy(x)
#> [1] 11

Sin embargo, si lo imprime, solo verá las ‘x’, con la herencia de su fórmula filtrándose:

x
#> (~x) + ~x

Puede obtener una mejor visualización con rlang::expr_print() (Sección 19.4.7):

expr_print(x)
#> (^x) + (^x)

Cuando usa expr_print() en la consola, los quosures se colorean de acuerdo con su entorno, lo que facilita detectar cuándo los símbolos están vinculados a diferentes variables.

20.3.6 Ejercicios

  1. Predecir lo que devolverá cada uno de los siguientes quosures si se evalúan.

    q1 <- new_quosure(expr(x), env(x = 1))
    q1
    #> <quosure>
    #> expr: ^x
    #> env:  0x5597a6d5f4d0
    
    q2 <- new_quosure(expr(x + !!q1), env(x = 10))
    q2
    #> <quosure>
    #> expr: ^x + (^x)
    #> env:  0x5597a6ee92c8
    
    q3 <- new_quosure(expr(x + !!q2), env(x = 100))
    q3
    #> <quosure>
    #> expr: ^x + (^x + (^x))
    #> env:  0x5597a7248fc0
  2. Escriba una función enenv() que capture el entorno asociado con un argumento. (Sugerencia: esto solo debería requerir dos llamadas de función).

20.4 Máscaras de datos

En esta sección, aprenderá sobre la máscara de datos, un marco de datos donde el código evaluado buscará primero definiciones de variables. La máscara de datos es la idea clave que impulsa funciones básicas como with(), subset() y transform(), y se usa en todo el tidyverse en paquetes como dplyr y ggplot2.

20.4.1 Lo escencial

La máscara de datos le permite combinar variables de un entorno y un marco de datos en una sola expresión. Usted proporciona la máscara de datos como segundo argumento para eval_tidy():

q1 <- new_quosure(expr(x * y), env(x = 100))
df <- data.frame(y = 1:10)

eval_tidy(q1, df)
#>  [1]  100  200  300  400  500  600  700  800  900 1000

Este código es un poco difícil de seguir porque hay mucha sintaxis ya que estamos creando cada objeto desde cero. Es más fácil ver lo que está pasando si hacemos un pequeño envoltorio. Llamo a esto with2() porque es equivalente a base::with().

with2 <- function(data, expr) {
  expr <- enquo(expr)
  eval_tidy(expr, data)
}

Ahora podemos reescribir el código anterior de la siguiente manera:

x <- 100
with2(df, x * y)
#>  [1]  100  200  300  400  500  600  700  800  900 1000

base::eval() tiene una funcionalidad similar, aunque no lo llama máscara de datos. En su lugar, puede proporcionar un marco de datos al segundo argumento y un entorno al tercero. Eso da la siguiente implementación de with():

with3 <- function(data, expr) {
  expr <- substitute(expr)
  eval(expr, data, caller_env())
}

20.4.2 Pronombres

El uso de una máscara de datos introduce ambigüedad. Por ejemplo, en el siguiente código no puede saber si x vendrá de la máscara de datos o del entorno, a menos que sepa qué variables se encuentran en df.

with2(df, x)

Eso hace que el código sea más difícil de razonar (porque necesita conocer más contexto), lo que puede introducir errores. Para resolver ese problema, la máscara de datos proporciona dos pronombres: .data y .env.

  • .data$x siempre se refiere a x en la máscara de datos.
  • .env$x siempre se refiere a x en el entorno.
x <- 1
df <- data.frame(x = 2)

with2(df, .data$x)
#> [1] 2
with2(df, .env$x)
#> [1] 1

También puede crear subconjuntos de .data y .env usando [[, p. .data[["x"]]. De lo contrario, los pronombres son objetos especiales y no debe esperar que se comporten como marcos de datos o entornos. En particular, arrojan un error si no se encuentra el objeto:

with2(df, .data$y)
#> Error in `.data$y`:
#> ! Column `y` not found in `.data`.

20.4.3 Aplicar: subset()

Exploraremos la evaluación ordenada en el contexto de base::subset(), porque es una función simple pero poderosa que facilita un desafío común de manipulación de datos. Si no lo ha usado antes, subset(), como dplyr::filter(), proporciona una manera conveniente de seleccionar filas de un marco de datos. Le das algunos datos, junto con una expresión que se evalúa en el contexto de esos datos. Esto reduce considerablemente la cantidad de veces que necesita escribir el nombre del marco de datos:

sample_df <- data.frame(a = 1:5, b = 5:1, c = c(5, 3, 1, 4, 1))

# Abreviatura para sample_df[sample_df$a >= 4, ]
subset(sample_df, a >= 4)
#>   a b c
#> 4 4 2 4
#> 5 5 1 1

# Abreviatura para sample_df[sample_df$b == sample_df$c, ]
subset(sample_df, b == c)
#>   a b c
#> 1 1 5 5
#> 5 5 1 1

El núcleo de nuestra versión de subset(), subset2(), es bastante simple. Toma dos argumentos: un marco de datos, data, y una expresión, row. Evaluamos row usando df como una máscara de datos, luego usamos los resultados para dividir el marco de datos con [. He incluido una verificación muy simple para garantizar que el resultado sea un vector lógico; el código real haría más para crear un error informativo.

subset2 <- function(data, rows) {
  rows <- enquo(rows)
  rows_val <- eval_tidy(rows, data)
  stopifnot(is.logical(rows_val))

  data[rows_val, , drop = FALSE]
}

subset2(sample_df, b == c)
#>   a b c
#> 1 1 5 5
#> 5 5 1 1

20.4.4 Aplicar: transform

Una situación más complicada es base::transform(), que te permite agregar nuevas variables a un marco de datos, evaluando sus expresiones en el contexto de las variables existentes:

df <- data.frame(x = c(2, 3, 1), y = runif(3))
transform(df, x = -x, y2 = 2 * y)
#>    x      y    y2
#> 1 -2 0.0808 0.162
#> 2 -3 0.8343 1.669
#> 3 -1 0.6008 1.202

De nuevo, nuestro propio transform2() requiere poco código. Capturamos el ... no evaluado con enquos(...), y luego evaluamos cada expresión usando un bucle for. El código real haría más comprobaciones de errores para garantizar que cada entrada tenga un nombre y se evalúe como un vector de la misma longitud que data.

transform2 <- function(.data, ...) {
  dots <- enquos(...)

  for (i in seq_along(dots)) {
    name <- names(dots)[[i]]
    dot <- dots[[i]]

    .data[[name]] <- eval_tidy(dot, .data)
  }

  .data
}

transform2(df, x2 = x * 2, y = -y)
#>   x       y x2
#> 1 2 -0.0808  4
#> 2 3 -0.8343  6
#> 3 1 -0.6008  2

NB: Llamé al primer argumento .data para evitar problemas si los usuarios intentaran crear una variable llamada data. Todavía tendrán problemas si intentan crear una variable llamada .data, pero esto es mucho menos probable. Este es el mismo razonamiento que lleva a los argumentos .x y .f a map() (Sección 9.2.4).

20.4.5 Aplicar: select()

Una máscara de datos suele ser un marco de datos, pero a veces es útil proporcionar una lista llena de contenidos más exóticos. Básicamente, así es como funciona el argumento select en base::subset(). Te permite referirte a las variables como si fueran números:

df <- data.frame(a = 1, b = 2, c = 3, d = 4, e = 5)
subset(df, select = b:d)
#>   b c d
#> 1 2 3 4

La idea clave es crear una lista con nombre donde cada componente proporcione la posición de la variable correspondiente:

vars <- as.list(set_names(seq_along(df), names(df)))
str(vars)
#> List of 5
#>  $ a: int 1
#>  $ b: int 2
#>  $ c: int 3
#>  $ d: int 4
#>  $ e: int 5

Luego, la implementación es nuevamente solo unas pocas líneas de código:

select2 <- function(data, ...) {
  dots <- enquos(...)

  vars <- as.list(set_names(seq_along(data), names(data)))
  cols <- unlist(map(dots, eval_tidy, vars))

  data[, cols, drop = FALSE]
}
select2(df, b:d)
#>   b c d
#> 1 2 3 4

dplyr::select() toma esta idea y la ejecuta, proporcionando una serie de ayudantes que le permiten seleccionar variables en función de sus nombres (por ejemplo, starts_with("x") o ends_with("_a")).

20.4.6 Ejercicios

  1. ¿Por qué usé un bucle for en transform2() en lugar de map()? Considere transform2(df, x = x * 2, x = x * 2).

  2. Aquí hay una implementación alternativa de subset2():

    subset3 <- function(data, rows) {
      rows <- enquo(rows)
      eval_tidy(expr(data[!!rows, , drop = FALSE]), data = data)
    }
    
    df <- data.frame(x = 1:3)
    subset3(df, x == 1)

    Compara y contrasta subset3() con subset2(). ¿Cuáles son sus ventajas y desventajas?

  3. La siguiente función implementa los conceptos básicos de dplyr::arrange(). Anote cada línea con un comentario que explique lo que hace. ¿Puede explicar por qué !!.na.last es estrictamente correcto, pero es poco probable que omitir !! cause problemas?

    arrange2 <- function(.df, ..., .na.last = TRUE) {
      args <- enquos(...)
    
      order_call <- expr(order(!!!args, na.last = !!.na.last))
    
      ord <- eval_tidy(order_call, .df)
      stopifnot(length(ord) == nrow(.df))
    
      .df[ord, , drop = FALSE]
    }

20.5 Usando una evaluación ordenada

Si bien es importante comprender cómo funciona eval_tidy(), la mayoría de las veces no lo llamará directamente. En su lugar, normalmente lo usará indirectamente llamando a una función que usa eval_tidy(). Esta sección brindará algunos ejemplos prácticos de funciones de envoltura que utilizan una evaluación ordenada.

20.5.1 Citar y remover cita

Imagina que hemos escrito una función que vuelve a muestrear un conjunto de datos:

resample <- function(df, n) {
  idx <- sample(nrow(df), n, replace = TRUE)
  df[idx, , drop = FALSE]
}

Queremos crear una nueva función que nos permita volver a muestrear y crear subconjuntos en un solo paso. Nuestro enfoque ingenuo no funciona:

subsample <- function(df, cond, n = nrow(df)) {
  df <- subset2(df, cond)
  resample(df, n)
}

df <- data.frame(x = c(1, 1, 1, 2, 2), y = 1:5)
subsample(df, x == 1)
#> Error in eval(expr, envir, enclos): object 'x' not found

subsample() no cita ningún argumento, por lo que cond se evalúa normalmente (no en una máscara de datos), y obtenemos un error cuando intenta encontrar un enlace para x. Para solucionar este problema, necesitamos citar cond, y luego quitarlo cuando lo pasemos a subset2():

subsample <- function(df, cond, n = nrow(df)) {
  cond <- enquo(cond)

  df <- subset2(df, !!cond)
  resample(df, n)
}

subsample(df, x == 1)
#>   x y
#> 3 1 3
#> 1 1 1
#> 2 1 2

Este es un patrón muy común; cada vez que llame a una función de cotización con argumentos del usuario, debe citarlos y luego quitarlos.

20.5.2 Manejo de la ambigüedad

En el caso anterior, necesitábamos pensar en una evaluación ordenada debido a la cuasicita. También debemos pensar en una evaluación ordenada, incluso cuando el contenedor no necesita citar ningún argumento. Tome este envoltorio alrededor de subset2():

threshold_x <- function(df, val) {
  subset2(df, x >= val)
}

Esta función puede devolver silenciosamente un resultado incorrecto en dos situaciones:

  • Cuando x existe en el entorno de llamada, pero no en df:

    x <- 10
    no_x <- data.frame(y = 1:3)
    threshold_x(no_x, 2)
    #>   y
    #> 1 1
    #> 2 2
    #> 3 3
  • Cuando val existe en df:

    has_val <- data.frame(x = 1:3, val = 9:11)
    threshold_x(has_val, 2)
    #> [1] x   val
    #> <0 rows> (or 0-length row.names)

Estos modos de falla surgen porque la evaluación ordenada es ambigua: cada variable se puede encontrar en ya sea la máscara de datos o el entorno. Para que esta función sea segura, necesitamos eliminar la ambigüedad usando los pronombres .data y .env:

threshold_x <- function(df, val) {
  subset2(df, .data$x >= .env$val)
}

x <- 10
threshold_x(no_x, 2)
#> Error in `.data$x`:
#> ! Column `x` not found in `.data`.
threshold_x(has_val, 2)
#>   x val
#> 2 2  10
#> 3 3  11

Generalmente, cada vez que usa el pronombre .env, puede usar la eliminación de comillas en su lugar:

threshold_x <- function(df, val) {
  subset2(df, .data$x >= !!val)
}

Hay diferencias sutiles en cuando se evalúa val. Si elimina las comillas, val será evaluado antes por enquo(); si usa un pronombre, val será evaluado perezosamente por eval_tidy(). Estas diferencias generalmente no son importantes, así que elija la forma que se vea más natural.

20.5.3 Citas y ambigüedad

Para finalizar nuestra discusión, consideremos el caso en el que tenemos tanto citas como ambigüedad potencial. Generalizaré threshold_x() ligeramente para que el usuario pueda elegir la variable utilizada para el umbral. Aquí usé .data[[var]] porque hace que el código sea un poco más simple; en los ejercicios tendrá la oportunidad de explorar cómo podría usar $ en su lugar.

threshold_var <- function(df, var, val) {
  var <- as_string(ensym(var))
  subset2(df, .data[[var]] >= !!val)
}

df <- data.frame(x = 1:10)
threshold_var(df, x, 8)
#>     x
#> 8   8
#> 9   9
#> 10 10

No siempre es responsabilidad del autor de la función evitar la ambigüedad. Imagine que generalizamos aún más para permitir el umbral basado en cualquier expresión:

threshold_expr <- function(df, expr, val) {
  expr <- enquo(expr)
  subset2(df, !!expr >= !!val)
}

No es posible evaluar expr solo en la máscara de datos, porque la máscara de datos no incluye ninguna función como + o ==. Aquí, es responsabilidad del usuario evitar ambigüedades. Como regla general, como autor de una función, es su responsabilidad evitar la ambigüedad con cualquier expresión que cree; es responsabilidad del usuario evitar la ambigüedad en las expresiones que crea.

20.5.4 Ejercicios

  1. He incluido una implementación alternativa de threshold_var() a continuación. ¿Qué lo hace diferente al enfoque que usé anteriormente? ¿Qué lo hace más difícil?

    threshold_var <- function(df, var, val) {
      var <- ensym(var)
      subset2(df, `$`(.data, !!var) >= !!val)
    }

20.6 Evaluación base

Ahora que comprende la evaluación ordenada, es hora de volver a los enfoques alternativos tomados por la base R. Aquí exploraré los dos usos más comunes en la base R:

  • substitute() y evaluación en el entorno de la persona que llama, tal como lo utiliza subset(). Usaré esta técnica para demostrar por qué esta técnica no es fácil de programar, como se advierte en la documentación subset().

  • match.call(), manipulación de llamadas y evaluación en el entorno de la persona que llama, como lo usan write.csv() y lm(). Usaré esta técnica para demostrar cómo la cuasicita y la evaluación (regular) pueden ayudarlo a escribir envolturas alrededor de tales funciones.

Estos dos enfoques son formas comunes de evaluación no estándar (NSE).

20.6.1 substitute()

La forma más común de NSE en base R es substitute() + eval(). El siguiente código muestra cómo puedes escribir el núcleo de subset() en este estilo usando substitute() y eval() en lugar de enquo() y eval_tidy(). Repito el código introducido en la Sección 20.4.3 para que puedas comparar fácilmente. La principal diferencia es el entorno de evaluación: en subset_base(), el argumento se evalúa en el entorno de la persona que llama, mientras que en subset_tidy(), se evalúa en el entorno en el que se definió.

subset_base <- function(data, rows) {
  rows <- substitute(rows)
  rows_val <- eval(rows, data, caller_env())
  stopifnot(is.logical(rows_val))

  data[rows_val, , drop = FALSE]
}

subset_tidy <- function(data, rows) {
  rows <- enquo(rows)
  rows_val <- eval_tidy(rows, data)
  stopifnot(is.logical(rows_val))

  data[rows_val, , drop = FALSE]
}

20.6.1.1 Programación con subset()

La documentación de subset() incluye la siguiente advertencia:

Esta es una función de conveniencia diseñada para uso interactivo. Para la programación, es mejor usar las funciones estándar de creación de subconjuntos como [ y, en particular, la evaluación no estándar del argumento subconjunto puede tener consecuencias imprevistas.

Hay tres problemas principales:

  • base::subset() siempre evalúa rows en el entorno de llamada, pero si se ha utilizado ..., es posible que la expresión deba evaluarse en otro lugar:

    f1 <- function(df, ...) {
      xval <- 3
      subset_base(df, ...)
    }
    
    my_df <- data.frame(x = 1:3, y = 3:1)
    xval <- 1
    f1(my_df, x == xval)
    #>   x y
    #> 3 3 1

    Esto puede parecer una preocupación esotérica, pero significa que subset_base() no puede funcionar de manera confiable con funciones como map() o lapply():

    local({
      zzz <- 2
      dfs <- list(data.frame(x = 1:3), data.frame(x = 4:6))
      lapply(dfs, subset_base, x == zzz)
    })
    #> Error in eval(rows, data, caller_env()): object 'zzz' not found
  • Llamar a subset() desde otra función requiere cierto cuidado: debe usar substitute() para capturar una llamada a la expresión completa de subset() y luego evaluar. Creo que este código es difícil de entender porque substitute() no usa un marcador sintáctico para quitar las comillas. Aquí imprimo la llamada generada para que sea un poco más fácil ver lo que está pasando.

    f2 <- function(df1, expr) {
      call <- substitute(subset_base(df1, expr))
      expr_print(call)
      eval(call, caller_env())
    }
    
    my_df <- data.frame(x = 1:3, y = 3:1)
    f2(my_df, x == 1)
    #> subset_base(my_df, x == 1)
    #>   x y
    #> 1 1 3
  • eval() no proporciona ningún pronombre, por lo que no hay forma de exigir que parte de la expresión provenga de los datos. Por lo que puedo decir, no hay forma de hacer que la siguiente función sea segura, excepto comprobando manualmente la presencia de la variable z en df.

    f3 <- function(df) {
      call <- substitute(subset_base(df, z > 0))
      expr_print(call)
      eval(call, caller_env())
    }
    
    my_df <- data.frame(x = 1:3, y = 3:1)
    z <- -1
    f3(my_df)
    #> subset_base(my_df, z > 0)
    #> [1] x y
    #> <0 rows> (or 0-length row.names)

20.6.1.2 ¿Qué pasa con [?

Dado que la evaluación ordenada es bastante compleja, ¿por qué no usar simplemente [ como recomienda ?subset? Principalmente, me parece poco atractivo tener funciones que solo se pueden usar de forma interactiva y nunca dentro de otra función.

Además, incluso la función simple subset() proporciona dos características útiles en comparación con [:

  • Establece drop = FALSE de forma predeterminada, por lo que se garantiza que devolverá un marco de datos.

  • Elimina las filas donde la condición se evalúa como NA.

Eso significa que subset(df, x == y) no es equivalente a df[x == y,] como cabría esperar. En cambio, es equivalente a df[x == y & !is.na(x == y), , drop = FALSE]: ¡eso es mucho más tipeo! Las alternativas de la vida real a subset(), como dplyr::filter(), hacen aún más. Por ejemplo, dplyr::filter() puede traducir expresiones R a SQL para que puedan ejecutarse en una base de datos. Esto hace que programar con filter() sea relativamente más importante.

20.6.2 match.call()

Otra forma común de NSE es capturar la llamada completa con match.call(), modificarla y evaluar el resultado. match.call() es similar a substitute(), pero en lugar de capturar un solo argumento, captura la llamada completa. No tiene un equivalente en rlang.

g <- function(x, y, z) {
  match.call()
}
g(1, 2, z = 3)
#> g(x = 1, y = 2, z = 3)

Un usuario destacado de match.call() es write.csv(), que básicamente funciona transformando la llamada en una llamada a write.table() con los argumentos adecuados establecidos. El siguiente código muestra el corazón de write.csv():

write.csv <- function(...) {
  call <- match.call(write.table, expand.dots = TRUE)

  call[[1]] <- quote(write.table)
  call$sep <- ","
  call$dec <- "."

  eval(call, parent.frame())
}

No creo que esta técnica sea una buena idea porque puedes lograr el mismo resultado sin NSE:

write.csv <- function(...) {
  write.table(..., sep = ",", dec = ".")
}

Sin embargo, es importante comprender esta técnica porque se usa comúnmente en el modelado de funciones. Estas funciones también imprimen de forma destacada la llamada capturada, lo que plantea algunos desafíos especiales, como verá a continuación.

20.6.2.1 Envolviendo funciones de modelado

Para comenzar, considere el envoltorio más simple posible alrededor de lm():

lm2 <- function(formula, data) {
  lm(formula, data)
}

Este contenedor funciona, pero no es óptimo porque lm() captura su llamada y la muestra al imprimir.

lm2(mpg ~ disp, mtcars)
#> 
#> Call:
#> lm(formula = formula, data = data)
#> 
#> Coefficients:
#> (Intercept)         disp  
#>     29.5999      -0.0412

Arreglar esto es importante porque esta llamada es la forma principal en que ve la especificación del modelo al imprimir el modelo. Para superar este problema, necesitamos capturar los argumentos, crear la llamada a lm() sin comillas y luego evaluar esa llamada. Para que sea más fácil ver lo que está pasando, también imprimiré la expresión que generamos. Esto será más útil a medida que las llamadas se vuelvan más complicadas.

lm3 <- function(formula, data, env = caller_env()) {
  formula <- enexpr(formula)
  data <- enexpr(data)

  lm_call <- expr(lm(!!formula, data = !!data))
  expr_print(lm_call)
  eval(lm_call, env)
}

lm3(mpg ~ disp, mtcars)
#> lm(mpg ~ disp, data = mtcars)
#> 
#> Call:
#> lm(formula = mpg ~ disp, data = mtcars)
#> 
#> Coefficients:
#> (Intercept)         disp  
#>     29.5999      -0.0412

Hay tres piezas que usará cada vez que envuelva una función NSE base de esta manera:

  • Captura los argumentos no evaluados usando enexpr(), y captura el entorno de la persona que llama usando caller_env().

  • Generas una nueva expresión usando expr() y quitando las comillas.

  • Evalúa esa expresión en el entorno de la persona que llama. Debe aceptar que la función no funcionará correctamente si los argumentos no están definidos en el entorno de la persona que llama. Proporcionar el argumento env al menos proporciona un gancho que los expertos pueden usar si el entorno predeterminado no es correcto.

El uso de enexpr() tiene un buen efecto secundario: podemos usar la eliminación de comillas para generar fórmulas dinámicamente:

resp <- expr(mpg)
disp1 <- expr(vs)
disp2 <- expr(wt)
lm3(!!resp ~ !!disp1 + !!disp2, mtcars)
#> lm(mpg ~ vs + wt, data = mtcars)
#> 
#> Call:
#> lm(formula = mpg ~ vs + wt, data = mtcars)
#> 
#> Coefficients:
#> (Intercept)           vs           wt  
#>       33.00         3.15        -4.44

20.6.2.2 Entorno de evaluación

¿Qué sucede si desea mezclar objetos proporcionados por el usuario con objetos que crea en la función? Por ejemplo, imagina que quieres hacer una versión de remuestreo automático de lm(). Podrías escribirlo así:

resample_lm0 <- function(formula, data, env = caller_env()) {
  formula <- enexpr(formula)
  resample_data <- resample(data, n = nrow(data))

  lm_call <- expr(lm(!!formula, data = resample_data))
  expr_print(lm_call)
  eval(lm_call, env)
}

df <- data.frame(x = 1:10, y = 5 + 3 * (1:10) + round(rnorm(10), 2))
resample_lm0(y ~ x, data = df)
#> lm(y ~ x, data = resample_data)
#> Error in eval(mf, parent.frame()): object 'resample_data' not found

¿Por qué no funciona este código? Estamos evaluando lm_call en el entorno de la persona que llama, pero resample_data existe en el entorno de ejecución. En cambio, podríamos evaluar en el entorno de ejecución de resample_lm0(), pero no hay garantía de que formula pueda evaluarse en ese entorno.

Hay dos formas básicas de superar este desafío:

  1. Elimine las comillas del marco de datos en la llamada. Esto significa que no tiene que ocurrir ninguna búsqueda, pero tiene todos los problemas de las expresiones en línea (Sección 19.4.7). Para las funciones de modelado, esto significa que la llamada capturada no es óptima:

    resample_lm1 <- function(formula, data, env = caller_env()) {
      formula <- enexpr(formula)
      resample_data <- resample(data, n = nrow(data))
    
      lm_call <- expr(lm(!!formula, data = !!resample_data))
      expr_print(lm_call)
      eval(lm_call, env)
    }
    resample_lm1(y ~ x, data = df)$call
    #> lm(y ~ x, data = <data.frame>)
    #> lm(formula = y ~ x, data = list(x = c(3L, 7L, 4L, 4L, 
    #> 2L, 7L, 2L, 1L, 8L, 9L), y = c(13.21, 27.04, 18.63, 
    #> 18.63, 10.99, 27.04, 10.99, 7.83, 28.14, 32.72)))
  2. Alternativamente, puede crear un nuevo entorno que herede de la persona que llama y vincular las variables que ha creado dentro de la función a ese entorno.

    resample_lm2 <- function(formula, data, env = caller_env()) {
      formula <- enexpr(formula)
      resample_data <- resample(data, n = nrow(data))
    
      lm_env <- env(env, resample_data = resample_data)
      lm_call <- expr(lm(!!formula, data = resample_data))
      expr_print(lm_call)
      eval(lm_call, lm_env)
    }
    resample_lm2(y ~ x, data = df)
    #> lm(y ~ x, data = resample_data)
    #> 
    #> Call:
    #> lm(formula = y ~ x, data = resample_data)
    #> 
    #> Coefficients:
    #> (Intercept)            x  
    #>        4.42         3.11

    Esto es más trabajo, pero da la especificación más limpia.

20.6.3 Ejercicios

  1. ¿Por qué falla esta función?

    lm3a <- function(formula, data) {
      formula <- enexpr(formula)
    
      lm_call <- expr(lm(!!formula, data = data))
      eval(lm_call, caller_env())
    }
    lm3a(mpg ~ disp, mtcars)$call
    #> Error en as.data.frame.default(data, optional = TRUE): 
    #> no puede obligar a la clase '"function"' a un data.frame
  2. Cuando se crea un modelo, normalmente la respuesta y los datos son relativamente constantes mientras experimenta rápidamente con diferentes predictores. Escriba un pequeño contenedor que le permita reducir la duplicación en el código a continuación.

    lm(mpg ~ disp, data = mtcars)
    lm(mpg ~ I(1 / disp), data = mtcars)
    lm(mpg ~ disp * cyl, data = mtcars)
  3. Otra forma de escribir resample_lm() sería incluir la expresión de remuestreo (data[sample(nrow(data), replace = TRUE), , drop = FALSE]) en el argumento de datos. Implemente ese enfoque. ¿Cuáles son las ventajas? ¿Cuales son las desventajas?


  1. Todos los demás objetos se rinden cuando se evalúan; es decir, eval(x) produce x, excepto cuando x es un símbolo o una expresión.↩︎

  2. Técnicamente, una fórmula combina una expresión y un entorno, pero las fórmulas están estrechamente vinculadas al modelado, por lo que una nueva estructura de datos tiene sentido.↩︎