Qu'est-ce que la programmation fonctionnelle ?
C'est une vaste question, qui mérite une longue réponse (Wikipédia en propose une, Advanced R en propose une autre).
Pour ce qui nous intéresse ici, voici quelques caractéristiques essentielles :
Cependant, R n'est pas un langage fonctionnel pur (contrairement, par exemple, à Haskell).
Considère R comme un langage pragmatique, qui offre (de plus en plus) des fonctionnalités fonctionnelles là où celles-ci constituent un moyen propre et simple d'accomplir une tâche. En général, l'analyse de données est le but, et la programmation n'est qu'un moyen d'y parvenir.
L'utilisation de pipes |> pour relier des fonctions est une technique de plus en plus courante dans plusieurs langages.
En bref :
Certains programmeurs trouvent utile de prononcer le pipe « puis » lorsqu'ils relisent du code : fais l'étape 1, PUIS fais l'étape 2...
L'exemple ci-dessous illustre la syntaxe.
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"
Ce type de pipe a été ajouté au R de base dans la version 4.1.0, et on le désigne parfois sous le nom de « pipe natif ».
Auparavant, on utilisait %>% du paquet magrittr (le nom est un jeu de mots pour les amateurs d'art).
Le « pipe magrittr » est toujours disponible, mais les pipes natifs évitent d'avoir recours à un paquet externe, et leur implémentation est plus simple sur certains détails.
L'éditeur RStudio (entre autres) utilise Ctrl-Shift-M comme raccourci clavier pour insérer un pipe avec les espaces adéquats.
On a vu précédemment que de nombreuses fonctions en R sont « vectorisées » : elles opèrent sur des vecteurs entiers, sans qu'il soit nécessaire d'écrire des boucles explicites.
Une fonction qui n'utilise que des fonctions vectorisées a de bonnes chances d'être elle aussi vectorisée automatiquement. Un exemple trivial :
triple <- function(x) return(3 * x)
triple(1:10)
[1] 3 6 9 12 15 18 21 24 27 30
Cependant, des fonctions plus complexes peuvent ne pas accepter un vecteur en entrée, par exemple si elles contiennent des boucles ou des blocs if-else.
Il nous faut une façon plus générale d'appliquer des fonctions à des vecteurs (semblable à map() dans certains autres langages).
Comme R propose une grande variété de structures de données dans son langage de base, il possède aussi toute une famille de fonctions *apply() pour les traiter.
Plus techniquement, les programmeurs les appellent souvent « fonctions d'ordre supérieur » et les mathématiciens « fonctionnelles », parce qu'elles prennent des fonctions en arguments.
Pour l'instant, intéressons-nous à lapply() et sapply().
lapply()« List-apply » est conçue pour travailler sur des listes en entrée, mais les vecteurs seront silencieusement convertis en listes si nécessaire.
Le résultat est une liste, il faudra donc peut-être la déballer pour obtenir un vecteur :
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
Les apprenants qui connaissent le concept Switch remarqueront qu'on pourrait aussi remplacer le switch scalaire par la fonction vectorisée recode_values() de la bibliothèque dplyr.
La vectorisation, c'est bien, et le R moderne offre plusieurs façons d'y parvenir.
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 » fait quelque chose de semblable à lapply, mais essaie en plus de simplifier la valeur de retour : par exemple, un vecteur nommé plutôt qu'une liste.
Selon le contexte, il peut être nécessaire d'utiliser unname() :
sapply(v, h)
small large medium
6 10 8
sapply(v, h) |> unname()
#> [1] 6 10 8
purrr
lapply et sapply sont simples, familières à la plupart des utilisateurs de R, et restent très populaires pour les vecteurs et les listes simples.
Le R de base propose aussi plusieurs autres fonctions apply :
vapply() est semblable à sapply() mais possède en plus un paramètre obligatoire pour préciser le format de sortie.apply() pour les matrices et les tableaux à dimension supérieure.mapply() pour traiter en parallèle plusieurs arguments de type liste ou vecteur.tapply() est plus difficile à expliquer, mais fonctionne avec des opérations de regroupement et des tableaux irréguliers.vectorize() est une fonction assez mystérieuse, qui semble n'être qu'une enveloppe autour de mapply().Assez, on a compris !
Offrir un remplacement plus simple et plus cohérent à cette confusion est l'un des objectifs clés du Tidyverse.
Il existe désormais le paquet purrr, qui propose plusieurs options supplémentaires comme map(), tout en améliorant la cohérence de sa conception et la clarté de ses messages d'erreur.
Moins contraint par la rétrocompatibilité que le R de base, ce paquet rapproche sensiblement R de la programmation fonctionnelle moderne que l'on trouve dans d'autres langages.
Le paquet purrr contient des fonctions nombreuses et variées, difficiles à résumer simplement.
Voici quelques ressources pour s'y retrouver dans la bibliothèque :
La bibliothèque purrr travaille presque exclusivement sur des données à une dimension : des vecteurs et des listes.
Nous verrons dans un concept ultérieur que la bibliothèque dplyr offre des fonctionnalités équivalentes pour les data frames à 2 dimensions.
La fonction map() applique une fonction à chaque élément de l'entrée, et renvoie toujours une liste.
Jusqu'ici, cela ressemble à lapply(), simplement renommée, et peut-être avec de meilleurs messages d'erreur.
Cependant, map() n'est qu'un membre d'une vaste famille.
Quelques questions à se poser pour choisir une fonction :
map() renvoie toujours une liste, mais si tu veux un type précis de vecteur atomique, utilise map_chr() pour les strings, map_int() pour les entiers, etc.i, comme imap().map par map2 dans le nom, comme map2_dbl() pour deux entrées (par exemple des valeurs et des poids, pour calculer une moyenne pondérée)pmap pour une liste d'entrées parallèles, comme pmap_chr(). Et oui, le parallélisme au niveau matériel est possible.map_if() pour appliquer un filtre par prédicat (booléen), à map_at() pour préciser des critères d'indice de position, à head_while() pour traiter l'entrée jusqu'à ce qu'un élément échoue à un prédicat.modify() à la place de map, ou modify_tree() pour une application récursive.Ces fonctions prennent les données d'entrée en premier argument chaque fois que possible, pour bien fonctionner avec les pipes.
De plus, purrr est (délibérément) plus pointilleux que le R de base. Si tu demandes quelque chose de précis, les fonctions renverront exactement cela ou s'arrêteront avec un message d'erreur.
C'est rude, mais très efficace pour réduire les bugs difficiles à trouver.
Les fonctions map renvoient généralement une valeur de la même longueur que l'entrée.
D'autres fonctions réduisent la dimensionnalité des données, par exemple sum().
sum(1:100)
#> [1] 5050
Dans le cas ci-dessus, on convertit un vecteur de longueur 100 en un entier de longueur 1. Nous verrons dans un prochain concept sur les matrices et tableaux que la notion de réduction de dimension est plus générale.
La fonction sum() est intégrée (comme beaucoup d'autres fonctions statistiques).
Cependant, il nous faut un moyen d'appliquer des fonctions de réduction de dimension arbitraires à une structure de données, à l'aide d'une fonction d'ordre supérieur équivalente à map().
Dans plusieurs autres langages, on utilise des noms de fonctions comme fold, foldl et foldr. En R, la fonction concernée est reduce(), qui correspond au framework MapReduce, bien connu des amateurs d'algorithmes.
Le premier argument est, comme d'habitude, les données d'entrée. Le deuxième est une fonction à deux arguments : souvent une fonction anonyme, parfois aussi simple qu'un opérateur arithmétique comme +.
# 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
Cela change-t-il quelque chose d'appliquer la fonction depuis le début ou depuis la fin de l'entrée ?
Pas avec les opérateurs associatifs comme + ou *.
De toute évidence, (1 + 2) + 3 == 1 + (2 + 3).
En revanche, - et / ne sont pas associatifs :
(1 - 2) - 3
#> [1] -4
1 - (2 - 3)
#> [1] 2
Pour les fonctions où le sens importe, il existe un argument facultatif .dir.
# default is .dir = "forward"
reduce(1:10, `-`)
#> [1] -53
reduce(1:10, `-`, .dir = "backward")
#> [1] -5
Comme pour map() et map2(), il existe aussi une fonction reduce2() qui opère en parallèle sur deux vecteurs d'entrée.
En R, on aime travailler avec des vecteurs. Imaginons que, au lieu d'une seule valeur finale, tu veuilles voir toutes les étapes intermédiaires.
Pour cela, les fonctions correspondantes sont accumulate() et accumulate2().
La syntaxe est semblable à celle de reduce(), avec quelques arguments facultatifs supplémentaires pour préciser le type de sortie.
accumulate(1:10, `+`)
#> [1] 1 3 6 10 15 21 28 36 45 55
Certains types courants d'accumulation ont leurs propres fonctions.
Le concept Fonctions vectorielles a présenté cumsum() et cumprod() dans le R de base, et la bibliothèque dplyr propose cummean() pour les moyennes numériques, cumall() et cumany() pour les vecteurs logiques.
Souvent, on veut inclure (ou exclure) les éléments de l'entrée qui satisfont un certain prédicat.
Dans le concept Filtrage de vecteurs, nous avons vu une façon idiomatique de le faire.
v <- 1:3
v[v >= 2]
#> [1] 2 3
C'est très utilisé dans le R traditionnel, mais cela s'intègre mal aux pipelines fonctionnels modernes.
Par conséquent, purrr fournit un ensemble de fonctions de prédicat, qui suivent la convention d'arguments habituelle f(input, func, ...) pour bien s'utiliser avec les pipes.
Les noms sont un peu différents de ceux de la plupart des autres langages (des noms comme filter et drop signifient déjà quelque chose de tout autre en R).
Les fonctions purrr correspondantes incluent keep() et discard(), entre autres.
On trouve aussi every(), some() et none(), pour remplacer all() et any() lorsqu'il s'agit de renvoyer une seule valeur logique.
La plupart des langages fonctionnels utilisent la récursivité comme alternative préférée aux boucles.
R dispose d'autres options, souvent meilleures : les fonctions vectorisées et les fonctions d'ordre supérieur.
Cependant, la récursivité est prise en charge en R, et peut être utile dans des contextes comme le parcours de structures arborescentes.
La profondeur de récursion maximale est fixée par défaut à 5000, ce qui est relativement généreux (Python, lui, s'arrête à 1000 par défaut). Il est possible de modifier cette valeur par défaut.
L'optimisation des appels terminaux n'est pas standard en R, bien que quelques contournements aient été mis au point.
La tradition veut qu'on montre ici un exemple de factorielle, l'exemple classique d'une définition récursive.
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
Tu peux utiliser la récursivité en R, et c'est parfois précieux, mais il existe souvent des approches plus simples.