Fonctions d’ordre supérieur (retours)

Les environnements

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 :

  • Elle a pour argument une ou des fonctions.
  • Elles retournent a minima une fonction.

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 :

  • Aucune des assignations (<-) internes aux fonctions n’a modifié l’environnement global ; le x global vaut toujours 1L.
  • Le arg de la fonction mystère semble avoir été passé à la fonction anonyme.
  • Le résultat du x retourné par fun_1() est celui défini à l’intérieur de la fonction anonyme, à savoir 3L.
  • Le résultat du x retourné par 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 :

  • Les environnements sont isolés en écriture selon l’assignation standard <-. Ici il faut bien comprendre que plusieurs objets x existent en même temps dans des environnements différents.
  • à l’exception de l’emptyenv, tous les environnements ont un environnement parent. La structure des environnements forme donc un arbre.
  • Chaque appel de fonction définit un environnement, et non chaque définition. À chaque fois qu’on appelle une fonction, on crée un nouvel environnement. Ici on crée donc deux environnements différents pour mystere.
  • Lorsque l’on cherche à évaluer un symbole recherche un objet dans un environnement et qu’il n’y existe pas, on va chercher ce symbole dans l’environnement parent (puis à nouveau l’environnement parent si on ne trouve toujours pas et ainsi de suite). Ici, par exemple, 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.

Note

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.

L’assignation remontante (<<-)

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.

Ne pas assigner le global

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…

Note

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 ?

x <- 1L
mystere <-
  function(arg) {
    x <- 2L
    function() {
      x <<- arg
      return(x)
    }
  }

fun_1 <- mystere(3L)
fun_2 <- mystere(4L)
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.

Closures

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.

Warning

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.

Closures complexes

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.

Quizz

Question 1

C’est lors de la définition de la fonction que l’on détermine son environnement.

Vrai

Faux

C’est lors de l’appel d’une fonction que l’on détermine un environnement. La nuance est importante car une même fonction appelée plusieurs fois définit plusieurs environnements différents.

Question 2

Dans le code ci-dessous :

x <- 4
fonction <-
  function(val) {
    x <- val
    return(x)
  }
fonction(3)

Le x dans l’environnement global est effacé de manière irrémédiable.

Vrai

Faux

Non, l’assignation simple affecte des valeurs dans l’environnement créé lors de l’appel d’une fonction et il n’y a strictement aucun danger de conflit.

Question 3

Dans le code ci-dessous :

x <- 4
fonction <-
  function(val) {
    x <<- val
    return(x)
  }
fonction(3)

Le x dans l’environnement global est effacé de manière irrémédiable.

Vrai

Faux

Vrai, l’assignation remontante <<- remonte les environnements parents jusqu’à retrouver un objet de même nom. Ici, elle va donc modifier le x de l’environnement global.

Question 4

Dans le code ci-dessous :

x <- 4
fonction <-
  function(val) {
    x <- 0
    x <<- val
    return(x)
  }
fonction(3)

Le x dans l’environnement global est effacé de manière irrémédiable.

Vrai

Faux

Vrai, l’assignation remontante <<- remonte les environnements parents jusqu’à retrouver un objet de même nom. Ici, elle va donc modifier le x de l’environnement global en ignorant celui qui est directement dans l’environnement local.

Question 5

Dans le code ci-dessous :

x <- 4
fonction <- function() {
  x <- 0
  function(val) {
    x <<- val
    return(x)
  }
}

fonction()(3)

Le x dans l’environnement global est effacé de manière irrémédiable.

Vrai

Faux

Faux, l’assignation remontante <<- remonte les environnements parents jusqu’à retrouver un objet de même nom. Ici, le premier environnement parent est celui instancié par fonction. Et il s’avère qu’il contient un objet de nom x. Donc le x de l’environnement global n’est pas écrasé.

Question 6

Dans le code ci-dessous :

x <- 4
fonction <- function() {
  function(val) {
    x <<- val
    return(x)
  }
}

fonction()(3)

Le x dans l’environnement global est effacé de manière irrémédiable.

Vrai

Faux

Vrai, l’assignation remontante <<- remonte les environnements parents jusqu’à retrouver un objet de même nom. Ici, le premier environnement parent est celui instancié par fonction. Mais il ne contient aucun objet de nom x. On remonte donc encore les environnements jusqu’à l’environnement global, et donc le x global est modifié.

Question 7

Dans le code ci-dessous :

nouveau_compteur <-
  function() {
    n <- 0L
    function() {
      n <<- n + 1L
      return(n)
    }
  }

compteur_a <- nouveau_compteur()
compteur_b <- compteur_a
compteur_a()
compteur_a()
compteur_b()

Combien va afficher le résultat de compteur_b() ?

0

1

2

3

La réponse vaut 3. Un compteur a été instancié dans compteur_a par nouveau_compteur(), puis il a été recopié dans compteur_b, mais n’a pas été réinstancié. On désigne donc le même compteur.

Question 8

Dans le code ci-dessous :

nouveau_compteur <-
  function() {
    n <- 0L
    function() {
      n <<- n + 1L
      return(n)
    }
  }

compteur_a <- nouveau_compteur()
compteur_b <- nouveau_compteur()
compteur_a()
compteur_a()
compteur_b()

Combien va afficher le résultat de compteur_b() ?

0

1

2

3

La réponse vaut 1. Deux compteurs différents ont été instanciés dans compteur_a et compteur_b.

Exercice

Supposons que l’on ait une fonction qui mette longtemps à répondre, par exemple :

calcul_de_dingue <- function(arg) {
  Sys.sleep(3L) # Une attente de 3 secondes pour simuler un long calcul.
  return(2 * arg)
}

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 :

  • appeler 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.
  • Tout appel subséquent de 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).

calcul_de_dingue <- function(arg) {
  Sys.sleep(3L) # Une attente de 3 secondes pour simuler un long calcul.
  return(2 * arg)
}

calcul_de_dingue_cache <- function(taille_cache = 10L) {
  cache <- vector("list",taille_cache)
  cache_suivant <- 1L
  function(arg) {
    index_en_cache <- which(vapply(cache, \(x) identical(x$arg,arg), TRUE))[1L]
    # 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_moins_dingue <- calcul_de_dingue_cache()
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
calcul_de_dingue <- function(arg) {
  Sys.sleep(3L) # Une attente de 3 secondes pour simuler un long calcul.
  return(2 * arg)
}

calcul_de_dingue_cache <- function(taille_cache = 10L) {
  cache <- vector("list",taille_cache)
  cache_suivant <- 1L
  function(arg) {
    index_en_cache <- which(vapply(cache, \(x) identical(x$arg,arg), TRUE))[1L]
    # 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)) {
      valeur <- calcul_de_dingue(arg)
      cache[[cache_suivant]] <<- list(arg = arg,
                                      valeur = valeur)
      cache_suivant <<- cache_suivant %% taille_cache + 1L
      valeur
    }
    else cache[[index_en_cache]]$valeur
  }
}

calcul_moins_dingue <- calcul_de_dingue_cache()
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