¿Qué es la programación funcional?
Es una pregunta ambiciosa que merece una respuesta larga (Wikipedia ofrece una, Advanced R ofrece otra).
Para lo que nos ocupa, estas son algunas características clave:
Sin embargo, R no es un lenguaje funcional puro (a diferencia de, por ejemplo, Haskell).
Considera R un lenguaje pragmático que (cada vez más) ofrece capacidades funcionales cuando estas son una forma limpia y sencilla de hacer el trabajo. Por lo general, el análisis de datos es el objetivo, y la programación no es más que un medio para lograrlo.
El uso de pipes |> para conectar funciones es una técnica cada vez más común en varios lenguajes.
En resumen:
A algunos programadores les resulta útil pronunciar el pipe como «then» cuando revisan código: haz el paso 1, THEN 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 añadió al R base en la versión 4.1.0, y puede que lo veas mencionado como el «pipe nativo».
Antes, el pipe 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 su implementación es más sencilla en algunos detalles.
El editor RStudio (entre otros) usa Ctrl-Shift-M como atajo de teclado para añadir un pipe con los espacios adecuados.
Vimos antes que muchas funciones de R están «vectorizadas»: operan sobre vectores enteros, sin necesidad de escribir bucles explícitos.
Es probable que las funciones que solo usan funciones vectorizadas estén ellas mismas vectorizadas automáticamente. 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 puede que no acepten una 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 otros lenguajes).
Como R tiene una gran variedad de estructuras de datos en el lenguaje base, también cuenta con toda una familia de funciones *apply() para operar con ellas.
Técnicamente, los programadores suelen llamarlas «funciones de orden superior» y los matemáticos, «funcionales», porque reciben funciones como argumentos.
Por ahora, veamos lapply() y sapply().
lapply()«List-apply» está pensado para trabajar con entradas de tipo lista, pero los vectores se convertirán en listas de forma silenciosa cuando sea necesario.
La salida es una lista, así que puede que haya que desempaquetarla 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
Quienes estén familiarizados con el concepto Switch quizá se den cuenta de que también podríamos sustituir el switch escalar por la versión vectorizada de recode_values() del paquete dplyr.
La vectorización es buena, y el R moderno ofrece varias formas de conseguirla.
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 devuelto: por ejemplo, un vector con nombres en lugar de una lista.
Según el contexto, puede que sea 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 sencillas, conocidas por la mayoría de los usuarios de R y siguen siendo muy populares para vectores y listas simples.
El R base también tiene otras cuantas funciones de tipo 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 varios 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 una alternativa más sencilla y coherente a este tipo de confusión es uno de los objetivos clave del Tidyverse.
Ahora existe el paquete purrr, que ofrece varias opciones adicionales como map(), a la vez que mejora la coherencia del diseño y la claridad de los mensajes de error.
Menos limitado por la retrocompatibilidad que el R base, este paquete acerca R de forma notable a la programación funcional moderna que se encuentra en otros lenguajes.
En purrr hay muchísimas funciones diversas, que no admiten un resumen sencillo.
Algunos recursos que te ayudarán a moverte por el paquete:
El paquete purrr opera casi por completo con datos unidimensionales: vectores y listas.
En un concepto posterior veremos que el paquete dplyr ofrece la misma funcionalidad 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 igual que lapply(), solo que con otro nombre y quizá con mejores mensajes de error.
Sin embargo, map() es solo un miembro de una familia numerosa.
Algunas preguntas que conviene plantearse al elegir una función:
map() siempre devuelve una lista, pero si quieres un tipo concreto 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 por predicado (Boolean), map_at() para especificar criterios por índice de posición, 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 que funcionen bien con los pipes.
Además, purrr es (a propósito) más quisquilloso que el R base. Si pides algo concreto, las funciones devolverán exactamente eso o se detendrán con un mensaje de error.
Duro, pero muy eficaz para reducir bugs difíciles de encontrar.
Por lo general, las funciones map tienen un valor devuelto de la misma longitud que la entrada.
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. En un futuro concepto sobre matrices y arrays veremos que el concepto de reducción de dimensiones es más general.
La función sum() es nativa (al igual que muchas otras funciones estadísticas).
Sin embargo, necesitamos una forma de aplicar funciones arbitrarias de reducción de dimensiones a una estructura de datos, usando alguna función de orden superior equivalente a map().
En otros lenguajes se usan nombres de funciones como fold, foldl y foldr. En R, la función correspondiente es reduce(), que se corresponde con el conocido (entre los aficionados a los algoritmos) framework MapReduce.
El primer argumento es, como de costumbre, los datos de entrada. El segundo argumento es una función de dos argumentos: a menudo una función anónima, pero puede ser 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 *.
Está claro que (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 en las que importa la dirección, 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 existe la función reduce2() para operar con 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 están las funciones accumulate() y accumulate2().
La sintaxis es similar a la de 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 acumulación tienen sus propias funciones.
El concepto Vector Functions trataba de cumsum() y cumprod() en el R base, y el paquete dplyr tiene cummean() para medias numéricas, y cumall() y cumany() para vectores lógicos.
A menudo queremos incluir (o excluir) elementos de la entrada que cumplan algún predicado.
En el concepto Vector Filtering vimos una forma idiomática de hacerlo.
v <- 1:3
v[v >= 2]
#> [1] 2 3
Se usa mucho en el R tradicional, pero encaja mal en los pipelines funcionales modernos.
Por eso, 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 algo distintos de los de la mayoría de los otros lenguajes (nombres como filter y drop ya significan algo completamente distinto en R).
Entre las funciones correspondientes de purrr están keep() y discard(), además de varias otras.
También tenemos every(), some() y none(), que sustituyen a all() y any() a la hora de 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, R admite la recursión, 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 bastante generoso (en Python es 1000). Es posible cambiar ese valor predeterminado.
La optimización de llamadas de cola no es estándar en R, aunque se han desarrollado algunos trucos para conseguirla.
La tradición exige que mostremos aquí un ejemplo de factorial, como ejemplo clásico de 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 resulta valiosa, pero a menudo hay enfoques más sencillos.