En informatique, il est nécessaire de communiquer avec des services externes ou des utilisateurs pour avoir des applications utiles, ce qui implique dans l'état actuel de l'informatique de gérer le non-déterminisme et la mutabilité. Mais on sait aussi qu'immutabilité et déterminisme font bon ménage pour avoir des applications stables. Tellement stables qu'elles ne pourraient plus s'exécuter et deviendraient donc par essence inutiles ?
Entre ça et le ruban infinie de la machine de Turing, l'informatique n’est en fait plus qu’un immense paradoxe voué à disparaître dans une nébuleuse de logique.
Non ! Pour mettre sur pied des applications qui fonctionnent, il va juste se passer ce que nous faisons depuis toujours en informatique pour qu'elle ait une raison d'exister : des concessions. Par exemple, on peut tricher avec les limites humaines, pour qui un ordinateur fait des calculs instantanés, pour qui la mémoire est tellement volumineuse qu’elle en paraît infinie (même si une mémoire infinie, ce n’est jamais assez 🤡) et affiche sur l'écran des lignes continues. On va aussi jouer à "pas vu, pas pris". Ainsi, on va dire qu'on est immutable, alors qu'en fait le processeur passe son temps à modifier ses registres, ses flags, ses caches... Toutes ces choses qu'on appelle des effets. Mais ça, on ne le voit pas. Alors, on dit qu'on est immutable néanmoins, même avec un calcul méga complexe. L’immutabilité, c’est lorsque la mutabilité ne se voit pas !
Mais il arrive un moment où les effets ça devient tellement gros qu'on ne peut plus les cacher... Gros comme un appel à une base de donnée pour modifier des données et le constater par exemple ou une interaction avec un client Web qui retourne des informations différentes à chaque fois (service météo, forex EUR/USD, les timelines...). Et ça, ces effets, on ne peut pas y échapper si on veut que notre application soit utile.
Malheureusement, avec les effets, ce sont des impuretés qui s'incrustent dans notre code, incluant des bugs connus comme les actions à distance. Et un code impur, ça veut dire du code sur lequel le raisonnement est plus complexe. Il s'ensuit une baisse de la capacité de refactoring, de la lisibilité, de la testabilité... Au moins, la seule chose qui augmente, c'est le temps passé à déboguer.
IO lave plus blanc
Pour retrouver la pureté fonctionnelle de notre code, nous allons mettre en place une hypocrisie une abstraction.
Le seul moyen d'avoir un code isolé des effets, c'est de repousser l'exécution de ces effets. Et pour ça, nous allons retarder cette exécution pour la déclencher à un moment plus opportun. C'est un peu comme dans certaines histoires où on emprisonne un.e bad guy/girl pour le/la sortir lorsqu'il n'y a plus d'autre choix pour sauver une situation 😎.
En programmation fonctionnelle, on fait ça avec une fonction.
Prenons l'expression suivante
List(println("Hello"), println("Hello"))
Son résultat donne
Hello
Hello
res1: List[Unit] = List((), ())
Puisque println
est une fonction qui effectue un affichage à l'écran et ne retourne pas de valeur à part ()
(de type Unit).
Par transparence référentielle (possibilité de remplacer une partie d'une expression par sa valeur), on devrait avoir le même comportement avec le code ci-dessous
val result: Unit = println("Hello")
List(result, result)
Ce qui n'est pas le cas
Hello
res1: List[Unit] = List((), ())
Puisque la variable result
ne récupère sa valeur (qui est ()
) qu'après que println
ait été exécuté. Après quoi, l'utilisation de result
retourne ()
, mais ne ré-appelle pas println
. Dans ce cas, notre code initial ne peut subir de refactoring.
Pour retrouver un comportement équivalent avec la possibilité d'utiliser une variable intermédiaire, il faut intégrer l'appel à println
dans une fonction. Mais cela implique une petite modification après la construction de la liste. Ce n'est pas bien grave, dans la mesure où nous montrons qu'un effet est présent dans notre expression.
val result: Unit => Unit = () => println("Hello")
List(result, result).map(f => f())
Ce qui donne
Hello
Hello
res1: List[Unit] = List((), ())
Nous pouvons utiliser cette forme avec d'autres effets
def printMessage(message: String): () => Unit = () => println(message)
def readLine(): () => Int = ???
def readFile(filename: String): () => Try[String] = ???
def getUserTimeLine(twitterName: String): () => Try[List[Tweet]] = ???
De la fonction au type
Plutôt que d'utiliser une fonction, qui complique un peu notre écriture, nous allons représenter l'effet en créant un type nommé Effect. Ce type prend en paramètre la fonction que nous avons vu précédemment et la déclenche par un appel à run
.
case class Effect[A](run: () => A)
object Effect {
def pure[A](a: => A): Effect[A] = Effect { () => a }
}
Le type Effect représente donc une opération ou un ensemble d'opérations contenant des effets.
def printMessage(message: String): Effect[Unit] =
Effect.pure { println(message) }
def readLine(): Effect[Int] =
Effect { scala.io.StdIn.readLine() }
def readFile(filename: String): Effect[Try[String]] =
Effect { scala.io.Source.fromFile(filename).mkString }
def getUserTimeLine(twitterName: String): Effect[Try[List[Tweet]]] = ???
Le type Effect est en plus un foncteur et une monade. Il est donc possible pour ce type de construire les méthode map et flatMap, et de les utiliser dans un for-comprehension :
val program: Effect[Unit] =
for {
_ <- printMessage("WHAT... Is your name?")
name <- readLine()
_ <- printMessage("WHAT... Is your quest?")
answer <- readLine()
_ <- printMessage(s"Are you sure, $name?")
} yield ()
program.run()
Par convention (surtout avec Haskell), Effect est appelé IO. Voici une implémentation "naïve" du type IO.
case class IO[A](run: () => A) {
def map[B](f: A => B): IO[B] = IO.pure { f(run()) }
def flatMap[B](f: A => IO[B]): IO[B] = IO.pure { f(run()).run() }
}
object IO {
def pure[A](a: => A): IO[A] = IO { () => a }
}
Vous aviez un imprévu ?
Maintenant, on peut tout à fait s'attendre à ce qu'un effet ait un comportement imprévu (eg. IOException, Timeout, 404, 500...). Pour représenter ce type de comportement, il y a deux écoles. La première considère qu'il y a déjà un type pour représenter ça, qui est le type Either
, et donc on va se retrouver avec IO[Either[E, A]]
, où E
représente le type de la situation exceptionnelle (Throwable, String, List[BusinessError]). Généralement, dans ce cas, on réécrit le type en EitherT[IO, E, A]
qui est un monad transformer. Ce qui n'est pas sans poser quelques soucis, en particulier des soucis de performance.
Dans une autre approche, nous pouvons considérer que la situation exceptionnelle est une partie intrinsèque de l'effet et qu'elle ne peut pas en être dissociée. Dans le mesure où un effet peut représenter un accès à une base de données, à une communication avec un service externe, à un traitement impliquant le système d'exploitation, etc., nous avons très souvent des cas où l'effet implique un comportement imprévu. Dans ce cas, il devient nécessaire de remonter cet aspect dans la signature du type.
Un point intéressant, si nous remontons le passé de Scala, nous retrouvons cette argumentation lorsqu'il est question de communication distante en particulier. Et c'est ce qui est aussi inclus dans la notion d'effet.
Ce qui nous donne le type ci-dessous
trait IO[E, A] {
def run: () => Either[E, A]
}
Nous pouvons retrouver une implémentation équivalente dans ZIO. Cette bibliothèque définit aussi un type IO à deux paramètres. Par contre, il n'y a pas de méthode run
dans le type. ZIO fournit déjà un environnement d'exécution représenté par le trait App, à "étendre" en redéfinissant une méthode run
pour utiliser votre code incluant des effets. ZIO définit un type Task[A]
définit comme IO[Throwable, A]
, ce qui permet de simplifier la lecture du type.
IO avec ZIO, c'est BIO !
Partons d'un exemple : une fonction qui lit un entier sur l'entrée standard (stdin). Pour cette fonction, nous avons un cas imprévu si l'utilisateur fournit autre chose qu'un entier.
def readInt: Task[Int] = Task { scala.io.StdIn.readInt() }
Voici un exemple de programme ZIO où cette fonction est intégrée.
import zio._
import zio.console._
object ReadIntMain extends App {
override def run(args: List[String]) =
program.absorb.fold(
// in case of failure
e => { e.printStackTrace(); 1 },
// in case of success
_ => 0)
val program =
for {
_ <- putStrLn("Input an integer:")
number <- readInt
_ <- putStrLn(s"value: $number")
} yield ()
}
Quoiqu'il en soit, dans ce code, les effets sont uniquement exécutés une fois que le résultat de la méthode est récupéré par le main de App. En dehors de ça, les effets sont uniquement encapsulés et représentés dans le type IO.
Ainsi, si nous revenons sur notre exemple initial au tout début de l'article, avec ZIO. Nous avons
val program =
ZIO.collectAll(
List(putStrLn("Hello"), putStrLn("Hello"))
)
Qui donne
Hello
Hello
Par refactoring, on peut écrire le code suivant
val result = putStrLn("Hello")
val program =
ZIO.collectAll(
List(result, result)
)
Qui donne aussi
Hello
Hello
Ce qui respecte le principe de transparence référentielle.
À travers le type IO, les effets deviennent des valeurs manipulables comme tels, qui peuvent être intégrées dans des expressions et craignent d'autant moins le refactoring. Nous pouvons le constater avec la bibliothèque ZIO. Le code devient plus accessible, nous pouvons raisonner simplement dessus et intervenir dessus avec un meilleur niveau de confiance.