Transparence référentielle - II : The rise of non-strict evaluation

Ayant précédemment abordé la transparence référentielle, nous nous posions précédemment des questions sur la prédictabilité du code dans un cadre contenant des effets (appel de service, accès aux données du système, variable globale...). Bien évidemment, les effets créent des dépendances fortes avec des éléments externes à l'application, ils ne facilitent pas cette prédictabilité et par conséquent, ils ne facilitent ni la testabilité et ni la maintenabilité d'une application et de ses composantes. Mais les effets sont un mal nécessaire et il faut composer notre code avec ! Alors comment faire ?

En programmation fonctionnelle, dès que quelque chose ne semble a priori pas faisable, on passe par des fonctions et des paramètres. C'est exactement ce qu'on va faire avec les effets afin de retrouver la transparence référentielle.

En gros, nous allons représenter les effets par des fonctions, composer des expressions à partir des ces fonctions-effets et une fois que l'ensemble de l'expression est bâtie, représentant ainsi l'orchestration des effets, on exécute le tout, lançant ainsi les effets que s'ils sont nécessaires.

Exemple

Prenons cet exemple, où on dépose plusieurs fois la même instruction d'affichage dans une liste.

List(println("hello"), println("hello"), println("hello"))
// hello
// hello
// hello
// List[Unit] = List((), (), ())

Chaque println s'affiche immédiatement et retourne la valeur (), qui est la seule valeur possible pour le type Unit, type de sortie de println. Selon le principe de transparence référentielle, il devrait être possible de remplacer les trois println("hello") précédents par une variable représentant cet appel. Sauf que...

val printHello: Unit = println("hello")
// hello
// printHello: Unit = ()

List(printHello, printHello, printHello)
// List[Unit] = List((), (), ())

Contrairement au code précédent, nous n'avons ici qu'un seul hello d'affiché. En effet, à la déclaration de la variable printHello, l'instruction println va d'abord afficher son paramètre avant de retourner (), qui est en fait la véritable valeur contenue dans printHello. Par la suite, l'utilisation de printHello retournera donc () sans chercher à appeler println("hello"). J'ai ici un comportement différent de l'exemple précédent : je ne suis donc pas référentiellement transparent, mais référentiellement opaque.

Pour pallier cette particularité, nous allons "différer" l'évaluation du println en l'encadrant dans un contexte réplicable : c'est-à-dire une fonction.

val printHello: () => Unit = () => println("hello")
// printHello: () => Unit = <function0>

En effet, une fonction va "différer" l'évaluation de son contenu dans la mesure où rien ne se passe dans la fonction jusqu'à ce qu'elle soit appelée. De plus, appeler plusieurs fois la même fonction nous garanti d'exécuter et de réexécuter autant de fois que demandé le même code.

Notre liste précédente devient une liste de fonctions.

val task: List[() => Unit] =
  List(printHello, printHello, printHello)
// task: List[() => Unit] = List(<function0>, <function0>, <function0>)

Et pour retrouver le même comportement qu'avant, il faut appeler les fonctions contenues une à une. Ce qui se fait simplement en utilisant map, ou mieux dans ce cadre : foreach.

task.foreach(t => t()) // or task.foreach(_())
// hello
// hello
// hello

Le fait de pouvoir "différer" une évaluation, qui sera exécutée par la suite au besoin, rentre dans ce qui est appelé l'évaluation non-stricte. Autrement dit, dans ce cadre, l'évaluation d'une expression se fait en respectant les dépendances, mais pas exactement dans l'ordre exprimé dans le code. C'est dans ce sens qu'elle est non-stricte.

Un autre point que nous voyons apparaître ci-dessus : c'est la dichotomie qu'il y a entre une zone de code sans effet (lors de la déclaration de printHello et task) et une zone de code avec effet (lors de l'appel à foreach). Cette dichotomie est une approche classique en programmation fonctionnelle. Mais pas que... Vous la voyez apparaître aussi dans l'architecture hexagonale, séparant des services déterministes et sans états (assimilables à des fonctions pures) du monde extérieur (requête utilisateur, base de données, service tiers, sonde... assimilables à des composants à effet).

Ici la transparence référentielle, nous a permis d'élaborer un moyen de sortir partiellement du dictat de l'approche impérative en différant les effets grâce à des fonctions. Ainsi nous avons pu séparer une zone fonctionnellement pure dans un coin du code et une zone impérative dans un autre, tout en les rendant plus visibles. Cette approche facilite d'autant la lisibilité du code et sa capacité à être refactorable.

Dans un prochain article nous verrons comment améliorer cette première conception avec des fonctions en introduisant un type représentant des effets.