Les « type classes » en Scala

Une « type class » est une construction de type qui supporte le polymorphisme « ad hoc ». En d’autres termes, un tel système permet de définir des fonctionnalités qui peuvent être ajoutées à n’importe quel type A, si une instance de la type class est fournie pour ce type A. Ceci permet de mieux découpler le code et d’atteindre certains objectifs de qualité comme nous le verrons plus tard.

Définir une type class

En langage de programmation Scala, nous pouvons définir une type class à l’aide d’un trait (à peu près l’équivalent d’une interface Java). Définissons maintenant un trait permettant d’effectuer une opération d’addition.

trait Addable[A]:
  extension (a1: A) def add(a2: A): A

Le mot clé extension signifie que nous allons augmenter les capacités d’un type A en lui ajoutant une ou des méthodes. Ici la méthode prend un autre A en paramètre et renvoie une somme (également de type A).

Définir des instances

Nous avons aussi besoin d’instances pour utiliser notre type class. Commençons par définir des instances permettant d’ajouter des entiers et des chaînes de caractère.

given Addable[Int] with
  extension (i1: Int) def add(i2: Int) = i1 + i2

given Addable[String] with
  extension (s1: String) def add(s2: String) = s"$s1 . $s2"

Le mot clé given permet de rendre cette instance accessible dans le scope où il est défini. Ce scope peut être, dans cet ordre de priorité, la méthode courante, la classe courante ou les imports (*).

Il est ensuite possible d’utiliser notre méthode add directement sur String ou Int :

println(
  "Hello".add("How are you?")
)

// Ceci affiche : Hello . How are you?

Nous pouvons ajouter la capacité add à n’importe quel type, sans modifier ce type directement.
Pour revenir à la qualité de code, si vous connaissez le DDD (Domain Driven Design), vous savez qu’il est important de garder un domaine métier pur. Les type classes sont très utiles pour cela dans beaucoup de cas comme la sérialisation JSON ou le mapping vers une base de données. A titre d’exemple, les librairies JSON en Scala définissent des type classes permettant de bénéficier d’une méthode toJson sur n’importe quel type. Il n’est donc pas nécessaire de rajouter des annotations sur nos types, ou de définir des DTO (et leurs fonctions de copie pénibles et souvent source d’erreurs de copier coller).

Exemple avec la librairie ZIO-Json, pour avoir un décodeur et un encodeur JSON par défaut :

import zio.json._
case class Fruit(name: String)

given JsonDecoder[Fruit] = DeriveJsonDecoder.gen[Fruit]
given JsonEncoder[Fruit] = DeriveJsonEncoder.gen[Fruit]

Note : DeriveJson*.gen sont des fonctions de la librarie qui vont créer automatiquement des instances pour le type demandé, en analysant les champs à la compilation (et non pas au runtime).

Il est bien sûr possible de définir des encodeurs/décodeurs plus complexes en customisant les champs.

Nous pouvons ensuite par exemple encoder un objet en JSON de cette manière :

println(
  Fruit("Banana").toJson
)

// Ceci affiche:  {"name":"Banana"}

Additionner des couleurs

Notez qu’il est également possible de définir des instances de type classes pour des types que nous ne sommes pas en capacité de modifier, comme des types venant de librairies ou de modules externes.

Par exemple, pour un type Color défini comme ceci :

enum Color :
    case Red 
    case Green
    case Blue
    case Purple
    case Yellow
    case Unknown

Sans modifier le type Color, nous pouvons lui ajouter de nouvelles capacités. Pour faire simple, revenons sur l’addition et voyons comment on peut additionner des couleurs :

given Addable[Color] with
  extension (c1: Color) def add(c2: Color) = 

   // exemple stupide d'addition de couleurs
   (c1, c2) match {
     case (Color.Red, Color.Green) => Color.Yellow
     case (Color.Red, Color.Blue) => Color.Purple
     //... etc.
     case _ => Color.Unknown
   }

println(
  Color.Red.add(Color.Blue) 
)

// Ceci affiche: Purple

Fonctions plus puissantes

Enfin, nous pouvons écrire des méthodes qui nécessitent des instances de type classes pour fonctionner, en utilisant les capacités « augmentées » des types.

Par exemple, nous pouvons écrire une méthode addAll qui prendra une liste de n’importe quel type A, si une instance de Addable[A] est définie, et faire la somme de tous les A :

def addAll[A: Addable](l: List[A]) = l.reduceLeft(((total, current) => total.add(current)))

// ou simplement :
def addAll[A: Addable](l: List[A]) = l.reduceLeft(_.add(_))

println(addAll(List("Coucou", "comment", "ça", "va")))
   
// Ceci affiche : Coucou . comment . ça . va

Quelques explications :

  • La syntaxe A: Addable signifie que nous posons la contrainte suivante (vérifiée à la compilation) : une instance de Addable[A] doit être déinie dans le scope courant.
  • reduceLeft est une méthode définie sur les listes qui permet de contruire récursivement un résultat à partir des élements de la liste. Elle prend en paramètre à chaque étape l’état courant du résultat ainsi que l’élément courant de la liste.

Note : la méthode sum des listes Scala fonctionne exactement de cette manière.

Les concepteurs du langage Kotlin s’intéressent aussi aux type classes, vous pouvez en apprendre plus en suivant les liens suivants :

* : Des instances par défaut peuvent aussi être déclarées dans le companion object d’un type.