11  Operadores de funciones

11.1 Introducción

En este capítulo, aprenderá acerca de los operadores de funciones. Un operador de función es una función que toma una (o más) funciones como entrada y devuelve una función como salida. El siguiente código muestra un operador de función simple, chatty(). Envuelve una función, creando una nueva función que imprime su primer argumento. Puede crear una función como esta porque le da una ventana para ver cómo funcionan las funciones, como map_int().

chatty <- function(f) {
  force(f)
  
  function(x, ...) {
    message("Processing ", x)
    f(x, ...)
  }
}
f <- function(x) x ^ 2
s <- c(3, 2, 1)

purrr::map_dbl(s, chatty(f))
#> Processing 3
#> Processing 2
#> Processing 1
#> [1] 9 4 1

Los operadores de funciones están estrechamente relacionados con las fábricas de funciones; de hecho, son solo una fábrica de funciones que toma una función como entrada. Al igual que las fábricas, no hay nada que no puedas hacer sin ellas, pero a menudo te permiten eliminar la complejidad para que tu código sea más legible y reutilizable.

Los operadores de función suelen estar emparejados con funcionales. Si está utilizando un bucle for, rara vez hay una razón para usar un operador de función, ya que hará que su código sea más complejo con poca ganancia.

Si está familiarizado con Python, los decoradores son solo otro nombre para los operadores de funciones.

Estructura

  • La Sección 11.2 le presenta dos operadores de funciones existentes extremadamente útiles y le muestra cómo usarlos para resolver problemas reales.

  • La Sección 11.3 funciona a través de un problema susceptible de solución con operadores de función: descargar muchas páginas web.

Requisitos previos

Los operadores de funciones son un tipo de fábrica de funciones, así que asegúrese de estar familiarizado al menos con la Sección 6.2 antes de continuar.

Usaremos purrr para un par de funciones que aprendiste en el Capítulo 9, y algunos operadores de funciones que aprenderás a continuación. También usaremos el paquete memoise (Wickham et al. 2018) para el operador memoise().

library(purrr)
library(memoise)

11.2 Operadores de funciones existentes

Hay dos operadores de funciones muy útiles que lo ayudarán a resolver problemas recurrentes comunes y le darán una idea de lo que pueden hacer los operadores de funciones: purrr::safely() y memoise::memoise().

11.2.1 Captura de errores con purrr::safely()

Una ventaja de los bucles for es que si una de las iteraciones falla, aún puede acceder a todos los resultados hasta la falla:

x <- list(
  c(0.512, 0.165, 0.717),
  c(0.064, 0.781, 0.427),
  c(0.890, 0.785, 0.495),
  "oops"
)

out <- rep(NA_real_, length(x))
for (i in seq_along(x)) {
  out[[i]] <- sum(x[[i]])
}
#> Error in sum(x[[i]]): invalid 'type' (character) of argument
out
#> [1] 1.39 1.27 2.17   NA

Si hace lo mismo con un funcional, no obtiene ningún resultado, lo que dificulta descubrir dónde radica el problema:

map_dbl(x, sum)
#> Error in `map_dbl()`:
#> ℹ In index: 4.
#> Caused by error:
#> ! invalid 'type' (character) of argument

purrr::safely() proporciona una herramienta para ayudar con este problema. safely() es un operador de función que transforma una función para convertir errores en datos. (Puede aprender la idea básica que hace que funcione en la Sección 8.6.2.) Comencemos echándole un vistazo fuera de map_dbl():

safe_sum <- safely(sum)
safe_sum
#> function (...) 
#> capture_error(.f(...), otherwise, quiet)
#> <bytecode: 0x562dbf8a9c48>
#> <environment: 0x562dbf8a97b0>

Como todos los operadores de funciones, safely() toma una función y devuelve una función envuelta a la que podemos llamar como de costumbre:

str(safe_sum(x[[1]]))
#> List of 2
#>  $ result: num 1.39
#>  $ error : NULL
str(safe_sum(x[[4]]))
#> List of 2
#>  $ result: NULL
#>  $ error :List of 2
#>   ..$ message: chr "invalid 'type' (character) of argument"
#>   ..$ call   : language .Primitive("sum")(..., na.rm = na.rm)
#>   ..- attr(*, "class")= chr [1:3] "simpleError" "error" "condition"

Puedes ver que una función transformada por safely() siempre devuelve una lista con dos elementos, result y error. Si la función se ejecuta correctamente, error es NULL y result contiene el resultado; si la función falla, result es NULL y error contiene el error.

Ahora usemos safely() con un funcional:

out <- map(x, safely(sum))
str(out)
#> List of 4
#>  $ :List of 2
#>   ..$ result: num 1.39
#>   ..$ error : NULL
#>  $ :List of 2
#>   ..$ result: num 1.27
#>   ..$ error : NULL
#>  $ :List of 2
#>   ..$ result: num 2.17
#>   ..$ error : NULL
#>  $ :List of 2
#>   ..$ result: NULL
#>   ..$ error :List of 2
#>   .. ..$ message: chr "invalid 'type' (character) of argument"
#>   .. ..$ call   : language .Primitive("sum")(..., na.rm = na.rm)
#>   .. ..- attr(*, "class")= chr [1:3] "simpleError" "error" "condition"

La salida tiene una forma un poco inconveniente, ya que tenemos cuatro listas, cada una de las cuales es una lista que contiene el resultado y el error. Podemos hacer que la salida sea más fácil de usar girándola “al revés” con purrr::transpose(), de modo que obtengamos una lista de resultados y una lista de errores:

out <- transpose(map(x, safely(sum)))
str(out)
#> List of 2
#>  $ result:List of 4
#>   ..$ : num 1.39
#>   ..$ : num 1.27
#>   ..$ : num 2.17
#>   ..$ : NULL
#>  $ error :List of 4
#>   ..$ : NULL
#>   ..$ : NULL
#>   ..$ : NULL
#>   ..$ :List of 2
#>   .. ..$ message: chr "invalid 'type' (character) of argument"
#>   .. ..$ call   : language .Primitive("sum")(..., na.rm = na.rm)
#>   .. ..- attr(*, "class")= chr [1:3] "simpleError" "error" "condition"

Ahora podemos encontrar fácilmente los resultados que funcionaron o las entradas que fallaron:

ok <- map_lgl(out$error, is.null)
ok
#> [1]  TRUE  TRUE  TRUE FALSE

x[!ok]
#> [[1]]
#> [1] "oops"

out$result[ok]
#> [[1]]
#> [1] 1.39
#> 
#> [[2]]
#> [1] 1.27
#> 
#> [[3]]
#> [1] 2.17

Puedes usar esta misma técnica en muchas situaciones diferentes. Por ejemplo, imagine que está ajustando un modelo lineal generalizado (GLM) a una lista de data frames. Los GLM a veces pueden fallar debido a problemas de optimización, pero aún desea poder intentar ajustar todos los modelos y luego mirar hacia atrás a los que fallaron:

fit_model <- function(df) {
  glm(y ~ x1 + x2 * x3, data = df)
}

models <- transpose(map(datasets, safely(fit_model)))
ok <- map_lgl(models$error, is.null)

# ¿Qué datos no lograron converger?
datasets[!ok]

# ¿Qué modelos tuvieron éxito?
models[ok]

Creo que este es un gran ejemplo del poder de combinar funcionales y operadores de funciones: safely() te permite expresar de manera sucinta lo que necesitas para resolver un problema común de análisis de datos.

purrr viene con otros tres operadores de función en una línea similar:

  • possibly(): devuelve un valor predeterminado cuando hay un error. No proporciona ninguna forma de saber si ocurrió un error o no, por lo que es mejor reservarlo para los casos en los que hay algún valor centinela obvio (como NA).

  • quietly(): convierte la salida, los mensajes y los efectos secundarios de advertencia en componentes de salida, mensaje y advertencia de la salida.

  • auto_browse(): ejecuta automáticamente browser() dentro de la función cuando hay un error.

Consulte su documentación para obtener más detalles.

11.2.2 Almacenamiento en caché de cálculos con memoise::memoise()

Otro operador de función útil es memoise::memoise(). Memoriza una función, lo que significa que la función recordará las entradas anteriores y devolverá los resultados almacenados en caché. La memorización es un ejemplo de la compensación clásica de las ciencias de la computación entre memoria y velocidad. Una función memorizada puede ejecutarse mucho más rápido, pero debido a que almacena todas las entradas y salidas anteriores, utiliza más memoria.

Exploremos esta idea con una función de juguete que simula una operación costosa:

slow_function <- function(x) {
  Sys.sleep(1)
  x * 10 * runif(1)
}
system.time(print(slow_function(1)))
#> [1] 0.808
#>    user  system elapsed 
#>       0       0       1

system.time(print(slow_function(1)))
#> [1] 8.34
#>    user  system elapsed 
#>   0.002   0.000   1.003

Cuando memorizamos esta función, es lenta cuando la llamamos con nuevos argumentos. Pero cuando lo llamamos con argumentos de que se ve antes, es instantáneo: recupera el valor anterior del cómputo.

fast_function <- memoise::memoise(slow_function)
system.time(print(fast_function(1)))
#> [1] 6.01
#>    user  system elapsed 
#>   0.001   0.000   1.002

system.time(print(fast_function(1)))
#> [1] 6.01
#>    user  system elapsed 
#>   0.013   0.000   0.013

Un uso relativamente realista de la memorización es calcular la serie de Fibonacci. La serie de Fibonacci se define recursivamente: los dos primeros valores se definen por convención, \(f(0) = 0\), \(f(1) = 1\), y luego \(f(n) = f(n - 1) + f (n - 2)\) (para cualquier entero positivo). Una versión ingenua es lenta porque, por ejemplo, fib(10) calcula fib(9) y fib(8), y fib(9) calcula fib(8) y fib(7) ), y así sucesivamente.

fib <- function(n) {
  if (n < 2) return(n)
  fib(n - 2) + fib(n - 1)
}
system.time(fib(23))
#>    user  system elapsed 
#>   0.029   0.000   0.029
system.time(fib(24))
#>    user  system elapsed 
#>   0.047   0.000   0.046

Memorizar fib() hace que la implementación sea mucho más rápida porque cada valor se calcula solo una vez:

fib2 <- memoise::memoise(function(n) {
  if (n < 2) return(n)
  fib2(n - 2) + fib2(n - 1)
})
system.time(fib2(23))
#>    user  system elapsed 
#>   0.006   0.000   0.006

Y las llamadas futuras pueden basarse en cálculos anteriores:

system.time(fib2(24))
#>    user  system elapsed 
#>       0       0       0

Este es un ejemplo de programación dinámica, donde un problema complejo se puede dividir en muchos subproblemas superpuestos, y recordar los resultados de un subproblema mejora considerablemente el rendimiento.

Piense cuidadosamente antes de memorizar una función. Si la función no es pura, es decir, la salida no depende solo de la entrada, obtendrá resultados engañosos y confusos. Creé un error sutil en las herramientas de desarrollo porque memoricé los resultados de available.packages(), que es bastante lento porque tiene que descargar un archivo grande de CRAN. Los paquetes disponibles no cambian con tanta frecuencia, pero si tiene un proceso R que se ha estado ejecutando durante algunos días, los cambios pueden volverse importantes y, dado que el problema solo surgió en los procesos R de ejecución prolongada, el error fue muy doloroso para encontrar.

11.2.3 Ejercicios

  1. Base R proporciona un operador de función en forma de Vectorize(). ¿Qué hace? ¿Cuándo podría usarlo?

  2. Lee el código fuente de posiblemente(). ¿Como funciona?

  3. Lee el código fuente de safely(). ¿Como funciona?

11.3 Estudio de caso: Creación de sus propios operadores de función

memoise() y safely() son muy útiles pero también bastante complejos. En este caso de estudio, aprenderá cómo crear sus propios operadores de función más simples. Imagine que tiene un vector con nombre de URL y desea descargar cada uno en el disco. Eso es bastante simple con walk2() y file.download():

urls <- c(
  "adv-r" = "https://adv-r.hadley.nz", 
  "r4ds" = "http://r4ds.had.co.nz/"
  # y muchos más
)
path <- paste0(tempdir(), names(urls), ".html")

walk2(urls, path, download.file, quiet = TRUE)

Este enfoque está bien para un puñado de URL, pero a medida que el vector se alarga, es posible que desee agregar un par de funciones más:

  • Agregue un pequeño retraso entre cada solicitud para evitar martillar el servidor.

  • Mostrar un . cada pocas URL para que sepamos que la función sigue funcionando.

Es relativamente fácil agregar estas funciones adicionales si usamos un bucle for:

for(i in seq_along(urls)) {
  Sys.sleep(0.1)
  if (i %% 10 == 0) cat(".")
  download.file(urls[[i]], paths[[i]])
}

Creo que este ciclo for es subóptimo porque intercala diferentes preocupaciones: pausar, mostrar el progreso y descargar. Esto hace que el código sea más difícil de leer y dificulta la reutilización de los componentes en situaciones nuevas. En cambio, veamos si podemos usar operadores de función para extraer la pausa y mostrar el progreso y hacerlos reutilizables.

Primero, escribamos un operador de función que agregue un pequeño retraso. Voy a llamarlo delay_by() por razones que serán más claras en breve, y tiene dos argumentos: la función para envolver y la cantidad de retraso para agregar. La implementación real es bastante simple. El truco principal es forzar la evaluación de todos los argumentos como se describe en la Sección 10.2.5, porque los operadores de función son un tipo especial de fábrica de funciones:

delay_by <- function(f, amount) {
  force(f)
  force(amount)
  
  function(...) {
    Sys.sleep(amount)
    f(...)
  }
}
system.time(runif(100))
#>    user  system elapsed 
#>   0.001   0.000   0.000
system.time(delay_by(runif, 0.1)(100))
#>    user  system elapsed 
#>     0.0     0.0     0.1

Y podemos usarlo con el walk2() original:

walk2(urls, path, delay_by(download.file, 0.1), quiet = TRUE)

Crear una función para mostrar el punto ocasional es un poco más difícil, porque ya no podemos confiar en el índice del bucle. Podríamos pasar el índice como otro argumento, pero eso rompe la encapsulación: una preocupación de la función de progreso ahora se convierte en un problema que el contenedor de nivel superior debe manejar. En su lugar, usaremos otro truco de fábrica de funciones (de la Sección 10.2.4), para que el contenedor de progreso pueda administrar su propio contador interno:

dot_every <- function(f, n) {
  force(f)
  force(n)
  
  i <- 0
  function(...) {
    i <<- i + 1
    if (i %% n == 0) cat(".")
    f(...)
  }
}
walk(1:100, runif)
walk(1:100, dot_every(runif, 10))
#> ..........

Ahora podemos expresar nuestro bucle for original como:

walk2(
  urls, path, 
  dot_every(delay_by(download.file, 0.1), 10), 
  quiet = TRUE
)

Esto está empezando a ser un poco difícil de leer porque estamos componiendo muchas llamadas a funciones y los argumentos se están dispersando. Una forma de resolver eso es usar la tubería:

walk2(
  urls, path, 
  download.file |> dot_every(10) |> delay_by(0.1), 
  quiet = TRUE
)

La canalización funciona bien aquí porque elegí cuidadosamente los nombres de las funciones para generar una oración (casi) legible: tome download.file luego (agregue) un punto cada 10 iteraciones, luego retrase 0.1s. Cuanto más claramente pueda expresar la intención de su código a través de nombres de funciones, más fácilmente otros (¡incluido usted en el futuro!) podrán leer y comprender el código.

11.3.1 Ejercicios

  1. Sopesar los pros y los contras de download.file |> dot_every(10) |> delay_by(0.1) versus download.file |> delay_by(0.1) |> dot_every(10).

  2. ¿Deberías memorizar download.file()? ¿Por qué o por qué no?

  3. Cree un operador de función que informe cada vez que se crea o elimina un archivo en el directorio de trabajo, usando dir() y setdiff(). ¿Qué otros efectos de funciones globales le gustaría rastrear?

  4. Escriba un operador de función que registre una marca de tiempo y un mensaje en un archivo cada vez que se ejecute una función.

  5. Modifique delay_by() para que, en lugar de retrasar una cantidad de tiempo fija, asegure que haya transcurrido una cierta cantidad de tiempo desde la última vez que se llamó a la función. Es decir, si llamó a g <- delay_by(1, f); g(); Sys.sleep(2); g() no debería haber un retraso adicional.