¿Qué es la programación funcional?
Esa es una gran pregunta que merece una respuesta larga (Wikipedia ofrece una, Advanced R ofrece otra).
Para nuestros propósitos, estas son algunas características clave:
Sin embargo, R no es un lenguaje funcional puro (a diferencia de, por ejemplo, Haskell).
Considera a R un lenguaje pragmático, que (cada vez más) ofrece capacidades funcionales cuando estas son una forma limpia y fácil de hacer un trabajo. En general, el análisis de datos es el objetivo, y la programación es solo un medio para ese fin.
El uso de pipes |> para conectar funciones es una técnica cada vez más común en varios lenguajes.
En resumen:
Algunos programadores encuentran útil pronunciar el pipe como «entonces» al revisar código: haz el paso 1 ENTONCES haz el paso 2...
El ejemplo siguiente ilustra la sintaxis.
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 se agregó al R base en la versión 4.1.0, y puede que lo veas mencionado como el «pipe nativo».
Antes, el uso de pipes se hacía con %>% del paquete magrittr (el nombre es un juego de palabras para los amantes del arte).
El «pipe de magrittr» sigue disponible, pero los pipes nativos evitan la necesidad de un paquete externo, y la implementación es más simple en algunos detalles.
El editor RStudio (entre otros) usa Ctrl-Shift-M como atajo de teclado para agregar un pipe con los espacios adecuados.
Vimos antes que muchas funciones en R están «vectorizadas»: operan sobre vectores completos, sin necesidad de escribir bucles explícitos.
Es probable que las funciones que solo usan funciones vectorizadas también se vectoricen automáticamente. En un ejemplo trivial:
triple <- function(x) return(3 * x)
triple(1:10)
[1] 3 6 9 12 15 18 21 24 27 30
Sin embargo, las funciones más complejas pueden no aceptar entrada vectorial, por ejemplo si contienen bucles o bloques if-else.
Necesitamos una forma más general de aplicar funciones a vectores (similar a map() en algunos otros lenguajes).
Como R tiene una gran variedad de estructuras de datos en el lenguaje base, también tiene toda una familia de funciones *apply() para operar sobre ellas.
Más técnicamente, los programadores suelen llamarlas «funciones de orden superior» y los matemáticos «funcionales», porque toman funciones como argumentos.
Por ahora, considera lapply() y sapply().
lapply()«List-apply» está diseñada para trabajar con entradas de tipo lista, pero los vectores se convertirán silenciosamente a listas cuando sea necesario.
La salida es una lista, así que puede ser necesario descomprimirla para obtener un vector:
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
Los estudiantes familiarizados con el concepto de Switch pueden notar que, alternativamente, podríamos reemplazar el switch escalar por recode_values() vectorizada de la biblioteca dplyr.
La vectorización es buena, y el R moderno ofrece múltiples formas de lograrla.
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()«Simplified-apply» hace algo similar a lapply, pero además intenta simplificar el valor de retorno: por ejemplo, un vector con nombres en lugar de una lista.
Según el contexto, puede ser necesario usar unname():
sapply(v, h)
small large medium
6 10 8
sapply(v, h) |> unname()
#> [1] 6 10 8
purrr
lapply y sapply son simples, conocidas por la mayoría de los usuarios de R, y todavía bastante populares para vectores y listas simples.
El R base también tiene varias otras funciones apply:
vapply() es similar a sapply() pero además tiene un parámetro obligatorio para especificar el formato de salida.apply() para matrices y arrays de dimensiones superiores.mapply() para múltiples argumentos de lista o vector en paralelo.tapply() es más difícil de explicar, pero funciona con operaciones de agrupación y arrays irregulares.vectorize() es una función bastante misteriosa, que parece ser un envoltorio de mapply().¡Basta ya!
Ofrecer un reemplazo más simple y más consistente para este tipo de confusión es un objetivo clave del Tidyverse.
Ahora existe el paquete purrr, que ofrece varias opciones adicionales como map(), a la vez que mejora la consistencia en el diseño y la claridad en los mensajes de error.
Menos limitado por la compatibilidad con versiones anteriores que el R base, este paquete acerca a R significativamente a la programación funcional moderna que se encuentra en otros lenguajes.
Hay muchas y muy diversas funciones en purrr, que desafían cualquier resumen fácil.
Entre los recursos que ayudan a navegar por la biblioteca están:
La biblioteca purrr opera casi por completo con datos unidimensionales: vectores y listas.
Veremos en un concepto posterior que la biblioteca dplyr ofrece funcionalidad equivalente para dataframes bidimensionales.
La función map() aplica una función a cada elemento de la entrada, y siempre devuelve una lista.
Hasta aquí, suena como lapply(), solo que con otro nombre, y quizá con mejores mensajes de error.
Sin embargo, map() es solo un miembro de una familia grande.
Algunas preguntas que considerar al elegir una función:
map() siempre devuelve una lista, pero si quieres un tipo particular de vector atómico usa map_chr() para strings, map_int() para enteros, etc.i a los nombres de las funciones, como en imap().map por map2 en el nombre, como en map2_dbl() para dos entradas (por ejemplo, valores y pesos, para calcular una media ponderada)pmap para una lista de entradas en paralelo, como en pmap_chr(). Y sí, el paralelismo a nivel de hardware es posible.map_if() para aplicar un filtro de predicado (Boolean), map_at() para especificar criterios de índice posicional, head_while() para procesar la entrada hasta que un elemento no cumpla un predicado.modify() en lugar de map, o modify_tree() para una aplicación recursiva.Estas funciones tienen los datos de entrada como primer argumento siempre que es posible, para asegurar que funcionen bien con los pipes.
Además, purrr es (deliberadamente) más pedante que el R base. Si pides algo específico, las funciones devolverán exactamente eso o se detendrán con un mensaje de error.
Duro, pero muy eficaz para reducir errores difíciles de encontrar.
Las funciones map generalmente tienen un valor de retorno de la misma longitud que la entrada.
Algunas otras funciones reducen la dimensionalidad de los datos, por ejemplo sum().
sum(1:100)
#> [1] 5050
En el caso anterior, convertimos un vector de longitud 100 en un entero de longitud 1. Veremos en un concepto futuro sobre matrices y arrays que el concepto de reducción de dimensiones es más general.
La función sum() viene integrada (como muchas otras funciones estadísticas).
Sin embargo, necesitamos una forma de aplicar funciones arbitrarias que reducen dimensiones a lo largo de una estructura de datos, usando alguna función de orden superior equivalente a map().
En varios otros lenguajes se usan nombres de funciones como fold, foldl y foldr. En R, la función relevante es reduce(), que corresponde al bien conocido (entre los entusiastas de los algoritmos) marco de trabajo MapReduce.
El primer argumento es, como de costumbre, los datos de entrada. El segundo argumento es una función de 2 argumentos: a menudo una función anónima, pero posiblemente algo tan simple como un 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 si aplicamos la función desde el principio o desde el final de la entrada?
No con operadores asociativos como + o *.
Claramente, (1 + 2) + 3 == 1 + (2 + 3).
En cambio, - y / no son asociativos:
(1 - 2) - 3
#> [1] -4
1 - (2 - 3)
#> [1] 2
Para las funciones donde la dirección importa, hay un argumento opcional .dir.
# default is .dir = "forward"
reduce(1:10, `-`)
#> [1] -53
reduce(1:10, `-`, .dir = "backward")
#> [1] -5
Al igual que con map() y map2(), también hay una función reduce2() para operar sobre dos vectores de entrada en paralelo.
En R, nos gusta trabajar con vectores. Supón que, en lugar de un único valor final, quieres ver todos los pasos intermedios.
Para eso, las funciones correspondientes son accumulate() y accumulate2().
La sintaxis es similar a reduce(), con algunos argumentos opcionales adicionales para especificar el tipo de salida.
accumulate(1:10, `+`)
#> [1] 1 3 6 10 15 21 28 36 45 55
Algunos tipos comunes de accumulate tienen sus propias funciones.
El concepto de Funciones de vectores trató cumsum() y cumprod() en el R base, y la biblioteca dplyr tiene cummean() para promedios numéricos, cumall() y cumany() para vectores lógicos.
Con frecuencia, queremos incluir (o excluir) elementos de la entrada que coincidan con algún predicado.
En el concepto de Filtrado de vectores vimos una forma idiomática de hacerlo.
v <- 1:3
v[v >= 2]
#> [1] 2 3
Esto se usa mucho en el R tradicional, pero encaja mal en los pipelines funcionales modernos.
Por consiguiente, purrr ofrece un conjunto de funciones de predicado, que siguen la convención habitual de argumentos f(input, func, ...) para funcionar con pipes.
Los nombres son un poco distintos de los de la mayoría de los otros lenguajes (nombres como filter y drop ya significan algo completamente distinto en R).
Las funciones correspondientes de purrr incluyen keep() y discard(), además de varias otras.
Además, tenemos every(), some() y none(), que reemplazan a all() y any() al devolver un único valor lógico.
La mayoría de los lenguajes funcionales usan la recursión como su alternativa preferida a los bucles.
R tiene otras opciones, a menudo mejores: funciones vectorizadas y funciones de orden superior.
Sin embargo, la recursión es compatible con R, y puede ser útil en contextos como el recorrido de estructuras de árbol.
La profundidad máxima de recursión es 5000 de forma predeterminada, lo cual es relativamente generoso (Python usa 1000 de forma predeterminada). Es posible anular el valor predeterminado.
La optimización de llamadas de cola no es estándar en R, aunque se han desarrollado algunos trucos para lograrla.
La tradición exige que mostremos un ejemplo de factorial en este punto, como el ejemplo clásico de una definición 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
Puedes usar la recursión en R, y a veces es valiosa, pero a menudo hay enfoques más simples.