<- 1L
x <-
mystere function(arg) {
<- 2L
x function() {
<<- arg
x return(x)
}
}
<- mystere(3L)
fun_1 <- mystere(4L)
fun_2 x
[1] 1
fun_1()
[1] 3
x
[1] 1
fun_2()
[1] 4
x
[1] 1
On l’a vu dans la partie précédente, la notion de fonction d’ordre supérieur désigne une fonction avec au moins l’une des propriétés suivantes :
Dans cette deuxième partie sur les fonctions d’ordre supérieur, nous abordons la thématique des fonctions avec ayant pour retour des fonctions.
Celles-ci posent un problème vis-à-vis de l’évaluation de leurs symboles internes (aussi appelés noms). Que se passe-t-il par exemple quand j’exécute ce code R ? On y appelle une fonction qui renvoie elle-même une fonction. Il y a plein de choses qui s’appellent x
! Comment cela peut bien se résoudre ?
On se rend compte en observant les résultats de quatre choses :
<-
) internes aux fonctions n’a modifié l’environnement global ; le x
global vaut toujours 1L
.arg
de la fonction mystère semble avoir été passé à la fonction anonyme.x
retourné par fun_1()
est celui défini à l’intérieur de la fonction anonyme, à savoir 3L
.fun_2()
est celui défini à l’intérieur de la fonction anonyme, à savoir 4L
.Pourquoi donc ?
Dans R, les objets sont contenus dans des environnements. Les environnements sont plus ou moins des petites bulles :
<-
. Ici il faut bien comprendre que plusieurs objets x
existent en même temps dans des environnements différents.mystere
.fun_1
n’a pas connaissance de arg
. Alors on cherche dans l’environnement au dessus. Surprise ! On y trouve arg
et l’on peut donc utiliser cette valeur. C’est ce qui explique que fun_1()
et fun_2()
peuvent renvoyer des résultats différents.Dans la suite, on s’attardera surtout sur la partie bleue du graphique ci-dessus. La partie orange a été abordée à titre d’information, mais n’est pas vraiment le sujet de cette formation. De notre point de vue, l’environnement le plus bas est donc le globalenv.
R se charge automatiquement de supprimer les environnements relatifs à fun_1
et fun_2
si on supprime fun_1
et fun_2
. Il dispose pour cela de ce qu’on appelle un ramasse-miettes.
<<-
)On remplace les deux assignations intra-fonctionnelles par un nouvel opérateur, l’assignation remontante <<-
.
On observe que le x
global est cette fois-ci modifié par l’assignation remontante. En effet, cet opérateur remonte les différents environnements parents jusqu’à trouver un x déjà existant et le modifie.
Ici, à chaque affectation, on remonte donc jusqu’à l’environnement global et on trouve x
. On modifie alors celui-ci.
À la fin de l’exécution, on a donc le schéma ci-dessus. On n’a pas créé de version locale de x
.
Attention cependant, quand bien même l’opérateur <<-
peut créer des fonctions manipulant l’environnement global comme on vient de le voir, ce n’est pas une bonne pratique. L’utilisation de <<-
doit être fait de manière circonscrite à des cas que l’on va aborder dans la suite de cette page. Modifier l’environnement global est le plus souvent une (très) mauvaise pratique. En effet, cela rompt complètement le paradigme fonctionnel dès lors que l’on a des états globaux pouvant mener à des interdépendances, des problèmes d’isolation…
Par défaut, si la remontée des environnements ne donne rien, l’assignation remontante assignera dans l’environnement global.
Que se passe-t-il si on n’utilise l’assignation remontante que dans la fonction anonyme et plus pour le x <- 2L
de la fonction de second ordre ? Pourquoi ?
<- 1L
x <-
mystere function(arg) {
<- 2L
x function() {
<<- arg
x return(x)
}
}
<- mystere(3L)
fun_1 <- mystere(4L)
fun_2 x
[1] 1
fun_1()
[1] 3
x
[1] 1
fun_2()
[1] 4
x
[1] 1
En apparence, les fonctions semblent se comporter à nouveau comme si on utilisait l’assignation locale. Mais, en réalité, le comportement par environnements est différent.
À la fin de l’exécution on a le graphe ci-dessus. Les x <- 2L
des différentes environnements de mystère ont été ecrasés respectivement par les x <<- 3L
et x <<- 4L
de fun_1
fun_2
. Ici, comme on n’utilise de toutes manières pas les valeur 2L
, ce n’est pas très grave. On préserve l’isolation entre environnements donc tout va bien ; même si ici la notation est inutile.
On a vu que des fonctions peuvent retourner des fonctions, et qu’il s’agit d’un des deux types de fonctions d’ordre supérieur. Dans R, on appelle aussi ces fonctions des function factories (fabrique de fonctions en français, mais le terme n’est pas très employé). Les fonctions renvoyées par les function factories sont appelées closures (fermetures en français, mais le terme n’est pas très employé non-plus).
Le nom de closure illustre un aspect de ces fonctions d’ordre supérieur. On a vu qu’il est possible d’écrire dans les environnements parents qui apparaissent comme isolés les uns des autres. Et bien utilisons cette propriété !
Mais que se passe-t-il ? C’est très étonnant ! Il semble que l’on ait défini des états ; et pourtant il n’y a aucune variable n
dans l’environnement global !
En fait, on appelle ces fonctions des closures parce que les closures “enferment” leur environnement parent. Elles le préservent également du ramasse-miette puisque le ramasse-miette ne va jamais supprimer un environnement qui est encore désigné quelque part. Elle permettent de maintenir un espace isolé où l’on peut tout-à-fait faire des modifications qui persistent dans le temps.
Seul l’appel à la fabrique permet de créer un nouvel environnement. Ici, c’est nouveau_compteur()
qui crée un nouveau compteur. On appelle un tel espace instance.
Il est important de noter que compteur_1bis
réfère exactement au même compteur que compteur_1
, car il désigne le même environnement. C’est précisément le principe d’une instance.
Cette astuce est puissante et permet de maintenir des états à l’intérieur d’un langage fonctionnel. Cela rompt l’approche maximaliste d’un langage fonctionnel idéal qui ne contiendrait que des fonctions complètement pures, mais cela la rompt de manière maitrisée, la plus locale possible. On ne doit bien sûr user de cette astuce qu’avec parcimonie, quand cela semble nécessaire.
Une closure peut renvoyer plusieurs fonctions différentes par exemple dans une liste. On s’approche alors beaucoup de la définition d’un objet en Programmation Orientée Objet.
Ici, les deux appels de nouvelle_resolution()
permettent de définir deux environnement différents. Dans chacun de ces environnements, les fonctions setX()
, setY()
, getX()
et getY()
agiront de manière isolée, comme on le voit dans le schéma ci-dessous.
C’est lors de la définition de la fonction que l’on détermine son environnement.
✗Vrai
✓Faux
Dans le code ci-dessous :
<- 4
x <-
fonction function(val) {
<- val
x return(x)
}fonction(3)
Le x
dans l’environnement global est effacé de manière irrémédiable.
✗Vrai
✓Faux
Dans le code ci-dessous :
<- 4
x <-
fonction function(val) {
<<- val
x return(x)
}fonction(3)
Le x
dans l’environnement global est effacé de manière irrémédiable.
✓Vrai
✗Faux
Dans le code ci-dessous :
<- 4
x <-
fonction function(val) {
<- 0
x <<- val
x return(x)
}fonction(3)
Le x
dans l’environnement global est effacé de manière irrémédiable.
✓Vrai
✗Faux
Dans le code ci-dessous :
<- 4
x <- function() {
fonction <- 0
x function(val) {
<<- val
x return(x)
}
}
fonction()(3)
Le x
dans l’environnement global est effacé de manière irrémédiable.
✗Vrai
✓Faux
Dans le code ci-dessous :
<- 4
x <- function() {
fonction function(val) {
<<- val
x return(x)
}
}
fonction()(3)
Le x
dans l’environnement global est effacé de manière irrémédiable.
✓Vrai
✗Faux
Dans le code ci-dessous :
<-
nouveau_compteur function() {
<- 0L
n function() {
<<- n + 1L
n return(n)
}
}
<- nouveau_compteur()
compteur_a <- compteur_a
compteur_b compteur_a()
compteur_a()
compteur_b()
Combien va afficher le résultat de compteur_b()
?
✗0
✗1
✗2
✓3
Dans le code ci-dessous :
<-
nouveau_compteur function() {
<- 0L
n function() {
<<- n + 1L
n return(n)
}
}
<- nouveau_compteur()
compteur_a <- nouveau_compteur()
compteur_b compteur_a()
compteur_a()
compteur_b()
Combien va afficher le résultat de compteur_b()
?
✗0
✓1
✗2
✗3
Supposons que l’on ait une fonction qui mette longtemps à répondre, par exemple :
<- function(arg) {
calcul_de_dingue Sys.sleep(3L) # Une attente de 3 secondes pour simuler un long calcul.
return(2 * arg)
}
Où arg
est un vecteur numérique.
On souhaite utiliser une closure pour définir une fonction calcul_moins_dingue
. calcul_moins_dingue
renvoie les mêmes valeurs que calcul_de_dingue
pour les mêmes entrées mais stocke ses résultats pour éviter de relancer les calculs plus d’une fois.
L’idée est que calcul_moins_dingue
doit :
calcul_de_dingue
si elle n’a pas été déjà appelée avec une certaine valeur, et stocker le résultat dans une liste.calcul_moins_dingue
avec une valeur déjà appelée doit restituer le résultat stocké plutôt que de réappeler calcul_de_dingue
.On appelle ce principe un cache ; l’axiome sous-jacent est que calcul_de_dingue est une fonction pure (on peut complètement prévoir son résultat à partir de ses arguments). On pourra utiliser la fonction identical(x, y)
qui permet de vérifier que deux objets sont exactement identiques (identical(1,1L)
est faux).
<- function(arg) {
calcul_de_dingue Sys.sleep(3L) # Une attente de 3 secondes pour simuler un long calcul.
return(2 * arg)
}
<- function(taille_cache = 10L) {
calcul_de_dingue_cache <- vector("list",taille_cache)
cache <- 1L
cache_suivant function(arg) {
<- which(vapply(cache, \(x) identical(x$arg,arg), TRUE))[1L]
index_en_cache # Cette ligne précédente peut être remplacée par
# index_en_cache <- Position(\(x) identical(x$arg,arg), cache)
# Qui fait la même chose (mais on n'a pas vu Position)
if (is.na(index_en_cache)) {
# Remplir ici
# C'est le cas où l'on ne retrouve pas la bonne entrée dans la cache.
}else {
# Remplir ici
# C'est le cas où index_en_cache contient le numéro de cache et donc on
# n'a pas à refaire le calcul.
}
}
}
<- calcul_de_dingue_cache()
calcul_moins_dingue calcul_moins_dingue(c(1, 3))
calcul_moins_dingue(c(1, 3)) # Pas besoin de calcul, déjà en cache
calcul_moins_dingue(c(4, 7))
calcul_moins_dingue(c(4, 7)) # Pas besoin de calcul, déjà en cache
calcul_moins_dingue(c(1, 3)) # Pas besoin de calcul, déjà en cache
calcul_moins_dingue(c(4, 7)) # Pas besoin de calcul, déjà en cache
<- function(arg) {
calcul_de_dingue Sys.sleep(3L) # Une attente de 3 secondes pour simuler un long calcul.
return(2 * arg)
}
<- function(taille_cache = 10L) {
calcul_de_dingue_cache <- vector("list",taille_cache)
cache <- 1L
cache_suivant function(arg) {
<- which(vapply(cache, \(x) identical(x$arg,arg), TRUE))[1L]
index_en_cache # Cette ligne précédente peut être remplacée par
# index_en_cache <- Position(\(x) identical(x$arg,arg), cache)
# Qui fait la même chose (mais on n'a pas vu Position)
if (is.na(index_en_cache)) {
<- calcul_de_dingue(arg)
valeur <<- list(arg = arg,
cache[[cache_suivant]] valeur = valeur)
<<- cache_suivant %% taille_cache + 1L
cache_suivant
valeur
}else cache[[index_en_cache]]$valeur
}
}
<- calcul_de_dingue_cache()
calcul_moins_dingue calcul_moins_dingue(c(1, 3))
[1] 2 6
calcul_moins_dingue(c(1, 3)) # Pas besoin de calcul, déjà en cache
[1] 2 6
calcul_moins_dingue(c(4, 7))
[1] 8 14
calcul_moins_dingue(c(4, 7)) # Pas besoin de calcul, déjà en cache
[1] 8 14
calcul_moins_dingue(c(1, 3)) # Pas besoin de calcul, déjà en cache
[1] 2 6
calcul_moins_dingue(c(4, 7)) # Pas besoin de calcul, déjà en cache
[1] 8 14