Scala - Type Class

Cet article a pour objectif de faire une introduction au pattern type class. L’idée est de préparer un terrain de connaissances pour de futur article qui utilisera cette notion.

Lorsque l’on code avec le langage Scala (ou tout autre langage fonctionnel), alors on base nos développements sur le système de type et par extension le compilateur.

L’idée est de typer au maximum les choses afin que le compilateur puisse faire le maximum de vérification à la compilation. L’objectif est de rendre la partie runtime sûr et déterministe.
En partant de ce principe, il existe un pattern en programmation fonctionnelle appelé le type class. Expliqué simplement, le type class permet de pouvoir étendre facilement un programme en respectant le principe de l’open/close, c’est-à-dire que l’on code quelque chose une fois et on n’a pas besoin de revenir dessus pour l’améliorer ou l’étendre.

Ici, nous allons parler de l’écosystème Scala. Dans ce monde, le type class est beaucoup utilisé. On le retrouve couramment dans les libs que l’on utilise au quotidien, PlayJson par exemple (ou tout autre parser Json écrit en Scala d’ailleurs).
Le principe général est que l’on va définir d’un côté un moteur (au sens large) et de l’autre des interpréteurs qui s’appliqueront en fonction du type que l’on est en train de manipuler.

L’ensemble de ces interpréteurs devront respecter un contrat afin qu’ils puissent être manipuler à travers ce contrat.
Comme exemple simple d’implémentation, nous allons écrire un transformer de case class sous une forme textuelle. L’exemple est simpliste et se rapproche fortement des parsers Json que l’on peut trouver sur le marché. Cela permettra de garder le focus sur le pattern.

Ici le contrat sera défini au travers d’un trait :

trait Transformer[A] { def apply(cclass : A):String }

Le trait Transformer définit le contrat pour qu’un type puisse définir son propre transformer. Rien de particulier pour le moment. Ensuite, nous allons définir l’ “engine” qui utilisera l’ensemble de ces transformers :

object TransformerEngine { def transform[A](p: A)(implicit t: Transformer[A]): String = { t.apply(p) } }

Cet objet propose une méthode particulière. En effet, elle est currifiée. Le premier paramètre représente l’instance du type que l’on souhaite transformer; le deuxième paramètre est préfixé par le mot clef implicit. C’est ici que tout se joue.

Ce mot clef indique au compilateur de faire une recherche, dans différents scopes, d’un type (une case class) respectant un contrat. Dans notre cas ce contrat est défini par le trait Transformer. En plus de ce critère, le compilateur devra trouver un Transformer paramétrer avec le type A. Ce mécanisme d’implicit est souvent vu comme quelque chose d’un peu magique lorsque l’on n’en comprend pas le fonctionnement.

En fait, c’est un mécanisme très puissant qui va nous permettre de faire beaucoup de choses intéressantes, ici la mise en place du pattern type class.

 Voici un exemple d’utilisation :

val p = Person("test", 18d ) //(1) TransformerEngine.transform(p) //(2)

En ligne (1) on créé une instance de la case class Person.
En ligne (2), on transforme l’instance sous forme de chaîne de caractères.

À l’exécution, un processus de résolution de l’implicit va se déclencher. Le paramètre p va fixer le A de la définition et du coup le compilateur va rechercher une instance de type Transformer[Person]. S’il ne trouve pas, alors la compilation s’arrêtera en erreur.

Pour que notre code compile, il faut ajouter ce fameux implicit et aussi qu’il soit accessible. En règle générale, lorsque l’on veut associer un implicit à une case class, on le place dans le companion object de la case class, comme ci-dessous pour Person :

object Person { implicit val personTr:Transformer[Person] = new Transformer[Person]{ def apply(cclass: Person): String = { s" name : ${cclass.name} age : ${cclass.age}" } }

Nous venons de définir une instance implicit de type Transformer[Person]. Si l’on recompile le code et qu’on l’exécute, tout se passera bien. Et voilà! Vous venez de voit comment se mettait en œuvre la pattern de Type Class. Maintenant, vous allez me dire quel intérêt ? En fait cela présente plusieurs intérêts :

  1. On respecte l’approche open/close, on écrit une fois le moteur et ensuite on peut étendre sans revenir sur notre code. Un nouveau type ? Juste à rajouter le transformer implicit correspondant.
  2. Tout le mécanisme repose sur le système de type, donc le compilateur fait toutes les vérifications afin d’éviter les mauvaises surprises à l’exécution.
  3. On garde un couplage faible étant donné qu’entre l’engine et les implémentations on trouve un contrat. Pas de dépendance à l’implémentation.

Le mécanisme d’implicit est vraiment sympa lorsqu’on commence à bien le maitriser et dans ces cas là on a tendance à le coller partout. Attention mal utilisé, cela peut avoir des répercussions sur les performances du compilateur.