Transparence référentielle - I : la perte de la prédictibilité

La transparence référentielle est ce que vous recherchez lorsque vous développez. L'immutabilité ? Le déterminisme ? C'est dépassé ! La transparence référentielle est le spécialiste des problèmes techniques. Elle va vous aider à retrouver le plaisir de coder. Résolution de la dette technique, succès social, réussite dans vos projets. Les résultats sont immédiats.

Dans cette première partie, nous allons voir ce à quoi correspond la transparence référentielle et différents use cases. Puis, nous allons parler de son pire ennemi : les effets.

Définition

Selon Wikipédia, une expression est référentiellement transparente si elle peut être remplacée par sa valeur sans changer le comportement du programme (c'est-à-dire que le programme a les mêmes effets et les mêmes sorties pour les mêmes entrées, quel que soit son contexte d'exécution). À l'inverse, une expression est référentiellement opaque si elle n'est pas référentiellement transparente.

En dehors de l’enthousiasme qu'elle semble susciter, comment cette chimère académique peut-elle avoir le moindre intérêt ?

La transparence + référentielle quand on ne sait pas ce que c'est !

Bien qu'elle n'aide pas forcément tant que ça, cette définition fait apparaître la notion de "comportement" et le fait qu'on ne change pas ce comportement. Ce qui est intéressant, puisque c'est à la base du refactoring (la version anglaise de cette notion est un peu plus parlante). Nous allons voir que les deux termes sont intimement liés et que nous allons trouver dans la transparence référentielle des conditions pour améliorer la lisibilité du code, sa maintenabilité et sa testabilité. Nous avons donc là un concept digne d'être connu par les partisans du software crafting, qui nous amène à nous poser de bonnes questions, tout en faisant (indirectement) la promotion de la programmation fonctionnelle pure.

Use cases

Pour commencer, nous allons partir à l'inverse de la définition vue plus haut : nous avons des valeurs et nous allons les remplacer par des expressions. Voici une expression où une valeur est répétée.

List(42, 42, 42)

Par refactoring, cette expression est équivalente à :

val lifeTheUniverseAndEverythingElse = 42

List(
  lifeTheUniverseAndEverythingElse,
  lifeTheUniverseAndEverythingElse,
  lifeTheUniverseAndEverythingElse)

Nous avons un cas classique où par transparence référentielle, on peut passer par la création d'une constante pour donner un sens à un nombre magique, sans changer le comportement de ces lignes de code.

Nous pouvons faire le même exercice avec un chaînage d'appel de méthode. L'exemple ci-dessous est un cas typique de oneline, c'est-à-dire écrire tout une expression virtuellement en une seule ligne ou en un seul chaînage d'appels. Malheureusement, cette pratique ne facilite pas la lecture du code.

val wordCounts =
  myText
    .split("[\r\n]+")
    .flatMap(line => line.split("\\s+"))
    .groupBy(word => word)
    .mapValues(occurrences => occurrences.size())

Par transparence référentielle, il est possible de refactorer le code ci-dessus en introduisant au choix une variable intermédiaire ou une fonction, à nouveau sans changer de comportement.

def wordsIn(text: String): Array[String] =
  text
    .split("[\r\n]+")
    .flatMap(line => line.split("\\s+"))

val wordCounts =
  wordIn(myText)
    .groupBy(word => word)
    .mapValues(occurrences => occurrences.size())

Le nom ici permet de donner un plus de sens au code, d'en clarifier l'intention.

La notion de "changer de comportement" est plutôt subjectif et va potentiellement varier en fonction du contexte. Au minimum, il va s'agir de s'intéresser uniquement au résultat de ces expressions. Mais si on est sur un projet où l'on doit porter une attention aux performances, alors cette performance perçue fait partie du comportement et il est possible de constater une différence de comportement dans les différentes solutions vues plus haut. Dans ce cas et dans ce cas uniquement les différentes alternatives ne sont pas référentiellement transparentes.

Anti use cases

Voyons d'autres cas moins anecdotiques où la transparence référentielle ne fonctionne pas.

def makeCoffee(flavour: Flavour): Coffee = {
  launchNuke("HAPPY THERMONUCLEAR WAR") // wow such benevolent

  Coffee.of(flavour)
}

Si le principe de transparence référentielle est respecté, on devrait avoir une équivalence entre les deux parties du code ci-dessous.

List(makeCoffee(Expresso), makeCoffee(Expresso), makeCoffee(Expresso))

// vs

val expresso = makeCoffee(Expresso)
List(expresso, expresso, expresso)

Or, les deux parties dans ce code n'ont pas le même comportement. En effet, la première ligne de code envoie trois ogives nucléaires, alors que les deux suivantes en envoient qu'une seule. (Bon. Lancer une tête nucléaire ou trois, en terme de résultat... comment dire ?)

Le Dr Folamour approuve l'opacité référentielle.

Et on retrouve ça dans des fonctions plus innocentes (... en apparence).

def commit(contract: Contract): Contract = {
  LocalDateTime now = LocalDateTime.now()

  contract
    .sign
    .timestamp(now)
}

Deux appels successifs à commit avec le même contrat peut donner des contrats avec un timestamp différent. Ce qui ne facilite pas la mise en place de tests, car il faudrait changer l'heure du système pour pouvoir retrouver un contexte dans lequel la fonction commit a un comportement prédictif.

Comparons aussi cette fonction :

def intSqrt_1(n: Int): Int {
  if (n < 0) throw new IllegalArgumentException()

  // ... do computation here ...

  result
}

Avec celle-ci :

def intSqrt_2(n: Int): Option[Int] =
  if (n < 0) None
  else {
    // ... do computation here ...

    Some(result)
  }

Selon la version de intSqrt utilisée, le résultat de l'expression suivante sera différente.

List(intSqrt(-1), intSqrt(1), intSqrt(-1))

Pour intSqrt_1, on obtient une exception. Pour intSqrt_2, on obtient la liste suivante : List(None, Some(1), None). Libre au développeur par la suite de lancer une exception au premier None rencontré, au second None rencontré ou de filtrer les None et de continuer les traitements.

Quoiqu'il en soit, intSqrt_1 n'est pas référentiellement transparent. La transparence référentielle nécessite d'être dans le cadre d'une expression et de ne traiter qu'avec des valeurs. Or intSqrt_1 ne retourne pas de valeur lorsque le paramètre est négatif et casse le flux de traitement.

Autrement dit, pour pouvoir respecter la transparence référentielle, nous avons besoin de fonctions pures. Une fonction pure est une fonction déterministe (elle retourne la même valeur pour un même paramètre), totale (elle retourne toujours une valeur quelque soit la valeur permise en entrée) et sans effet.

Les trois exemples précédents montre que ce qui ne permet pas d'être référentiellement transparent, c'est cette notion d'effet. Nous allons voir ce dont il s'agit.

Effet

Les effets représentent un concept fondamental en programmation impérative. Dans ce paradigme de programmation, un programme correspond à une composition d'instructions. Une instruction est un élément du programme qui permet de modifier l'état du système ou de constater son état actuel. Le propre d'un effet est ainsi de modifier ou de constater l'état du système.

Un effet crée un couplage fort entre une application ou un service et un service tier, quelque soit sa nature (OS, service externe, IoT...). Et puis, un effet, c'est plutôt invisible. Ce couplage fort et implicite ne facilite pas :

  • le déterminisme, puisque des valeurs différentes peuvent être retournées pour le même appel (eg. now(), random(), forexService.getRate("USD", "EUR")),
  • la testabilité, qui a besoin de déterminisme,
  • le refactoring, par définition avec la transparence référentielle,

Et de par la nature même des effets, le code devient plus difficile à analyser, nous devons passer plus de temps pour comprendre l'étendue de ses actions. Ce qui nécessite plus de concentration, requiert plus d'énergie et réduit le courage des développeurs pour attaquer des problèmes avec le code. La notion de courage étant à la base de l'agilité, les effets ne seraient pas "agiles" ?

Mais les effets sont nécessaires. Dans les applications de la vraie vie, nous avons tous besoin de connaître l'heure et la date actuelle, nous avons tous besoin de stocker et de lire des trucs en base de données, nous avons tous besoin de dialoguer avec un service qui va potentiellement fournir des réponses différentes, même si on lui fournit la même requête. Sans ça, quel utilité donner à notre application ?

XKCD 1312 (CC BY-NC 2.5)

Alors, dans un cadre où les effets sont une nécessité, comment faciliter la testabilité et prédictibilité de notre code ? Comment pouvons-nous mettre en place la transparence référentielle ? C'est ce que nous verrons dans un prochain article.

Photo : https://unsplash.com/photos/B2mq60Ksrsg

Photo by Pahala Basuki on Unsplash
Download this free HD photo of indonesia, pulau papan togean, desa pulau papan, and malenge in Indonesia by Pahala Basuki (@yukiehamada)


Why Referential Transparency matters?
It’s not always obvious to understand what referential transparency is and why it matters. It’s often intertwined with “complex” frameworks, dealing with Functional Programming. Here, I start from a…