Fonctions pures et impures
Il existe en programmation informatique (en particulier en programmation fonctionnelle, mais pas que) un concept de pureté des fonctions.
Je veux l’expliquer ici car il est au cœur de ce qui donne une grande partie de leur intérêt aux langages fonctionnels et également de ce qui constitue, même pour d’autres formes de programmation, de bonnes pratiques. Ce court article me servira donc de référence quand ce concept devra intervenir dans d’autres articles.
Qu’est-ce qu’une fonction pure ?
Une fonction (informatique) est pure si elle adopte les mêmes caractéristiques comportementales que les fonctions mathématiques. Pour cela, elle doit :
Toujours produire les mêmes valeurs en sortie pour des arguments identiques en entrée.
Ne produire aucun effet de bord.
Invariance des valeurs en sortie pour des arguments identiques en entrée
Pour qu’une fonction soit pure, sa valeur de sortie ne doit dépendre, directement ou indirectement, que des valeurs des arguments qu’elle a reçu en entrée.
Pour ce faire, la fonction ne doit notamment pas faire appel à des variables non locales mutables, donc susceptibles de changer de valeurs entre deux appels de la fonction. Elle peut cependant faire appel à des variables non mutables (des constantes, donc).
Par exemple, une fonction qui utiliserait l’horloge de l’ordinateur pour élaborer sa valeur de sortie ne pourrait pas être pure.
On peut imaginer deux fonctions de calcul de l’âge d’une personne, l’une pure et l’autre impure :
La fonction impure pourrait ne prendre en argument que la date de naissance de la personne et produire en retour le nombre d’années révolues entre cette date de naissance et la date du jour donnée par l’horloge de l’ordinateur.
On voit que cette fonction est impure, car si on l’appelle tous les ans avec la même date de naissance en argument, elle produira chaque année une valeur différente, qui est l’àge à jour de la personne.
La fonction pure devrait prendre en argument, en plus de la date de naissance de la personne, la date à laquelle on souhaite calculer son âge.
Pour connaitre son âge à jour, le programme devrait d’abord obtenir auprès de l’horloge de l’ordinateur la date du jour, pour passer sa valeur en argument de date de calcul à la fonction. Le programme est impur, puisqu’il accède à l’horloge, mais cet accès se faisant maintenant en dehors de la fonction, celle-ci demeure pure.
Si on appelle la fonction toujours avec la même valeur de date de naissance et la même valeur de date de calcul, alors on obtient toujours le même âge.
On note au passage que la fonction pure est plus générale que la fonction impure, dans la mesure où elle permet de calculer l’âge de la personne à n’importe quelle date de calcul passée en argument, alors que la fonction impure ne permet de calculer que l’âge de la personne à la date d’exécution du programme.
Cela rend la fonction impure plus facilement réutilisable si ce que l’on veut demander au programme évolue. Par exemple, on peut imaginer que dans une première version du programme, on n’ait besoin que de l’âge de la personne à la date du jour. La fonction impure remplit alors parfaitement son office (et elle est même plus simple à utiliser que la fonction pure).
Mais on peut également imaginer que dans une nouvelle version du programme on veuille faire de la simulation basée sur l’âge que la personne aura à une date future. Pour ce faire, la fonction pure pourra être utilisée sans changement : il suffira juste de faire varier la date de calcul qu’on lui passe en argument. La fonction impure, quant à elle, ne pourra pas être utilisée en l’état, car elle ne saura rien calculer d’autre qu’un âge à la date du jour.
Il faudra probablement modifier la fonction impure pour la transformer en sa version pure, et également modifier tous les appels qui lui étaient précédemment faits pour passer la date du jour en second argument. On aurait donc gagné du temps à écrire la version pure de la fonction dès le départ, quand bien même elle était un peu plus complexe à utiliser.
Les fonctions pures ont ainsi une tendance naturelle à une meilleure réutilisabilité que les fonctions impures.
Absence d’effets de bord
Un effet de bord est, pour une fonction, le fait de modifier lors de son exécution des états qui ne lui sont pas locaux et qui perdureront après son exécution.
Pour éviter ces effets de bord, et ainsi demeurer pure, la fonction ne doit notamment :
Pas modifier de variables non locales.
Cela inclue ses propres arguments : les variables qui lui sont passées en arguments ne doivent pas avoir changé de valeur du fait de l’appel de la fonction. S’il s’agit de références vers des structures de données (ou des objets), l’état de ces dernières ne devra également pas avoir changé du fait de l’apped à la fonction.
Par exemple, tous les mutateurs (setters) de classes utilisés en programmation orientée objet sont, à ce titre, des fonctions impures.
On notera au passage que cela coïncide avec la bonne pratique générale en programmation qui consiste en éviter d’utiliser des variables globales mutables (le problème ne se posant pas s’agissant de constantes).
Pas effectuer d’opérations d’entrées / sorties.
Pas lever d’exceptions, dans le cadre de langages qui utilisent cette notion.
Contamination
Une fonction peut évidemment utiliser d’autres fonctions. Mais si une fonctions pure le fait, elle ne peut utiliser que des fonctions pures.
Si une fonction utilise, directement ou indirectement, ne serait-ce qu’une fonction impure au milieu d’un océan de code pur, elle devient alors elle-même impure. On peut donc considérer qu’une fonction impure « contamine » toutes les fonctions qui l’utilisent.
Caractéristiques dérivées des fonctions pures
- Une fonction pure est également forcément référentiellement transparente.
-
C’est à dire que, n’importe où dans le programme, on peut substituer à un appel de cette fonction (pour un jeu de valeurs données passées en arguments) la valeur qu’elle produit en sortie, sans que cela change le comportement du programme.
Cette caractéristique permet notamment la mémoïsation de la fonction à l’exécution du programme : une fois que la fonction a été exécutée sur un jeu de valeurs de ses arguments, il est possible de mémoriser cette valeur pour la substituer à tous les appels ultérieurs qui pourraient être effectués avec le même jeu de valeurs d’arguments plutôt que d’exécuter la fonction à nouveau. Ceci peut constituer une optimisation appréciable dans le cas de fonctions qui impliqueraient des calculs coûteux en temps processeur.
- Les fonctions pures s’accommodent bien d’une évaluation paresseuse.
-
Une évaluation évaluation paresseuse est le fait d’attendre, pour calculer le résultat de la fonction, que ce résultat soit concrètement utilisé par le programme. En attendant que ce moment vienne, éventuellement, l’appel de fonction peut être mémorisé dans sa forme première (nom de la fonction et valeur des arguments) sans déclencher immédiatement son calcul.
Le contraire d’une évaluation paresseuse est une évaluation stricte, qui consiste à procéder à ce calcul dès que l’appel de fonction apparaît dans le programme. Mais le résultat de cet appel peut être stocké dans une variable, ou une liste de valeurs, qui ne sera finalement pas utilisée.
Dans ce cas, le programme aura passé du temps à calculer des valeurs pour rien. Avec une évaluation paresseuse, ces calculs inutiles sont automatiquement évités, sans qu’il y ait besoin d’optimiser le code spécialement pour ça.
Comme la valeur de sortie d’une fonction pure ne dépend que des valeurs de ses arguments en entrée (et ne dépend notamment pas du moment où elle est appelée), les fonctions pures s’accommodent automatiquement très bien d’une évaluation paresseuse.
NB. - Cette affinité est exploitée notamment par le langage Haskell.
- Les fonctions pures sont les plus faciles à tester par des tests unitaires automatisés.
-
Comme la valeur de sortie de la fonction ne dépend d’aucun contexte ou environnement technique à mettre en place (comme une connexion à une base de données ou la date du jour), il suffit de l’appeler avec des jeux de valeurs d’arguments pour lesquelles on connaît les valeurs de sortie attendues pour effectuer des tests automatisés.
- Les fonctions pures favorisent la parallélisation des traitements.
-
Comme elles n’accèdent à aucune forme de mémoire partagée, si ce n’est éventuellement des constantes absolument non mutables, les fonctions pures ne sont pas sujettes aux problèmes de concurrence d’accès à ces zones de mémoire qui peuvent toucher les fonctions impures. Elles peuvent donc servir à bâtir des traitements parallélisés sur plusieurs processeurs sans qu’il y ait besoin de mettre en œuvre de complexes mécanismes de synchronisation.
Fonctions pures et langages de programmation
Il existe des langages de différents paradigmes de programmation. Parmi ceux-ci, certains sont des langages fonctionnels purs (comme Haskell ou Miranda).
Ces langage sont conçus de sorte que les fonctions qu’ils permettent d’écrire soient forcément pures, à défaut de mettre en place des contournements explicites permettant d’écrire des fonctions impures (notamment pour pouvoir effectuer des opérations d’entrées / sorties, sans lesquelles il est impossible d’obtenir un programme utile à quoi que ce soit). Mais alors, le caractère impur de ces fonctions sera explicite et connu du compilateur.
Par ailleurs, on peut écrire des fonctions pures avec n’importe quel langage de programmation, pas seulement avec des langages fonctionnels purs. Il « suffit » d’éviter tout ce que ces langages permettent qui peut être constitutif d’impuretés.
On évitera donc, quand bien même le langage le permet, de modifier le contenu des paramètres de la fonction, d’utiliser des variables globales, de lever des exceptions… Cela nous obligera parfois à transgresser ce qui peut être préconisé comme de « bonnes pratiques » pour certains de ces langages (notamment concernant la gestion d’exceptions) ; mais c’est possible.
Cependant, le compilateur d’un langage qui n’est pas un langage fonctionnel pur ne peut pas partir du principe qu’on écrit ces fonctions en prenant toutes ces précautions. Il n’a pas de moyen de détecter, parmi les différentes fonctions mises en œuvre dans nos programmes, lesquelles sont pures et lesquelles sont impures.
De ce fait, il ne peut pas procéder aux optimisations, comme la mémoïsation ou l’évaluation paresseuse par défaut, que peux se permettre un compilateur d’un langage fonctionnel pur.