Pourquoi les typeclasses c'est top ?

Le polymorphisme, qui permet à une opération d'être appelée sur des types différents, est l'une des bases de la programmation orientée objet. Et pourtant elle ne lui est pas exclusivement réservée. C'est en effet un terme que nous retrouvons en programmation fonctionnelle à travers la notion de typeclasse.

La notion de typeclasse n'a en effet rien à voir avec les classes et les interfaces de la programmation orientée objet. Mais elle présente certains point communs. Bon, déjà, il y a classe dedans... Mais ça c'était facile !

En fait, les typeclasses permettent de "catégoriser" des types divers et de leur associer une interface ou une API commune. Sauf que dans le cas des typeclasses, les possibilités d'extension et de mise en application sont beaucoup plus étendues que dans le cadre des classes, jusqu'à permettre de mieux s'en sortir dans un cadre legacy aussi fermé soit-il. Ceci est facilité parce que les typeclasses se basent plus sur la délégation que sur l'héritage.

Du classique à l'héritage

Prenons un exemple : j'ai des chiens et des dinosaures représentés par les types Dog et Dinosaur. Je devrais pouvoir utiliser la même opération walk pour voir les chiens et les dinosaures marcher, pour laquelle seul va changer le comportement associé.

En programmation plus ou moins classique, on partirait sur une approche utilisant la réflexion pour connaître le type de l'instance et une structure conditionnelle pour y associer un traitement spécifique.

public String walk(Object animal) {
  if (animal.isInstanceOf[Dog])
    return "Tap tap tap tap tap tap tap tap";
  else if (animal.isInstanceOf[Dinosaur])
    return "Boom boom acka-lacka lacka boom";
  else
    throw new Exception("your animal doesn't walk. HAhaHAha!");
}

(Depuis les années 80, on sait que les dinosaures font ce bruit lorsqu'ils se déplacent !)

Le problème de cette approche est que le développeur doit penser au cas d'erreur. En cas d'oubli, seule l'exécution du programme avec la bonne donnée en entrée permet de rappeler que la gestion du cas exceptionnel est manquant. C'est une situation qui peut apparaître OÙ ELLE VEUT ET C'EST SOUVENT DANS LA PROD...

Il y a d'autres aspects qui sont relativement problématiques ici. Par exemple, ajouter un animal comme le canard, mais oublier de l'ajouter dans walk. Ce qui va se traduire par une exception. De plus, si ce code vient du bibliothèque tierce scellée (ie. les modifications dans cette bibliothèque sont... c'est très compliqué), ajouter le comportement pour le canard... Et bien... on peut pas 😕. Par contre, si je veux ajouter une opération dance pour mes animaux, ce ne sera pas un soucis.

La programmation orientée objet apporte une solution à ce problème en ne permettant pas au développeur de faire n'importe quoi, sauf lorsqu'il l'a clairement exprimé 🤔

trait Animal { /* ... */ }

trait Walking {
  def walk: String
}

case class Dog(name: String) extends Animal with Walking {
  /* ... */
  def walk: String = "Tap tap tap tap tap tap tap tap"
}
case class Dinosaur(name: String) extends Animal with Walking {
  /* ... */
  def walk: String = "Boom boom acka-lacka lacka boom"
}

def walk(walking: Walking): String = walking.walk

Si je veux ajouter le canard, je dois alors étendre Animal et Walking. Ce qui m'oblige à définir la méthode walk pour cet animal. Après quoi, je peux appeler la fonction walk(Walking) pour les canards.

L'approche orientée objet permet clairement de s'éviter des problèmes à la source et d'avoir moins de cas d'erreur à gérer. On gagne en flexibilité sur la mise en place de nouveaux comportements associés à une opération. Mais en contrepartie, on perd en flexibilité sur l'ajout de nouvelles opérations : si le code ci-dessus fait partie d'une bibliothèque scellée, je ne pourrais pas ajouter un comportement dance, sauf à le faire en partant sur la solution précédente ou en utilisant d'autres approches basées sur la réflexion. Autre point : Dog et Dinosaur est toujours associé au mixin Walking quelque soit le contexte. On peut imaginer des contextes dans lequel "Dinosaur étend Walking" n'a aucune utilité, par exemple lorsque le dinosaure dort (somnambule), ou est perturbant, par exemple lorsqu'il est décédé (walking dead).

C'est vraiment perturbant !

Les typeclasses offrent une réponse à ces cas problématiques.

Typeclasse

Les typeclasses permettent de catégoriser des types existant dans des contextes bien précis et d'associer à ces types des opérations communes.

La mise en place de typeclasses au sein de Scala passe par un idiome basé sur la notion de déclaration implicite. Comme le principe n'est pas très connu, nous allons procéder par étape.

Nous déclarons d'abord quelques classes sans les lier directement à une classe ou une interface quelconque.

case class Dog(name: String)
case class Dinosaur(name: String)

Nous déclarons ensuite avec un trait un type paramétré qui va permettre de catégoriser n'importe quel type A, sur lequel nous pourrons appeler la méthode walk. C'est notre typeclasse !

trait Walking[A] {
  def walk(a: A): String
}

Définissons une fonction walk.

def walk[A: Walking](a: A): String =
  implicitly[Walking[A]].walk(a)

Cette fonction se base en entrée sur n'importe quel type A. Néanmoins, dans la signature, nous indiquons que ce type est contraint par le fait qu'il doit être de catégorie Walking (A: Walking). Dans le corps de la fonction, nous indiquons que nous recherchons une instance de Walking déclarée implicitement pour le type A donnée (implicitly[Walking[A]]). Une fois récupérée, nous pourrons appeler la méthode walk dessus.

Nous déclarons à présent des instances implicites de Walking pour Dog et Dinosaur avec un comportement spécifique. Ce sont les instances de la typeclasse.

implicit val dogWalking =
  new Walking[Dog] {
    def walk(d: Dog): String = "Tap tap tap tap tap tap tap tap"
  }
 
implicit val dinosaurWalking =
  new Walking[Dinosaur] {
    def walk(d: Dinosaur): String = "Boom boom acka-lacka lacka boom"
  }

Et donc, à l'utilisation ça donne :

walk(Dog("Rambo"))          // Tap tap tap tap tap tap tap tap
walk(Dinosaur("P'tit Rex")) // Boom boom acka-lacka lacka boom

Si la déclaration d'une instance implicite est manquante, nous avons alors une erreur de compilation.

case class Duck(name: String)

walk(Duck("Donald")) // KO 🙀 - compilation error
// could not find implicit value for evidence parameter of type Walking[Duck]

Pour que le code ci-dessus fonctionne avec Duck, il faut alors déclarer une instance implicite de type Walking[Duck].

Quelques remarques ici :

  • Dog et Dinosaur sont décorrélés de Walking. Les deux déclarations peuvent se faire dans des espaces de code différents.
  • Le lien entre Dog et Dinosaur d'un côté avec Walking est concrétisé par les instances implicites (implicit val ...). La déclaration de ces instances peut se faire dans un autre espace de code.

Donc, même si Dog, Dinosaur, Walking et la fonction walk sont dans des bibliothèques scellées, il est toujours possible :

  • de créer de nouveaux animaux avec le comportement Walking,
  • d'associer un comportement Dancing pour Dog et Dinosaur,
  • de ne pas importer le comportement Walking s'il n'est pas nécessaire.

Le tout est vérifié par le compilateur.

Pour terminer

Nous avons vu sans le nommer des exemples de typeclasses à la fin de notre précédent article sur la covariance et la contravariance, avec les notions de Functor et CoFunctor.

C'est en tout cas cette approche qui est employée par des bibliothèques comme Cats ou ScalaZ pour associer à des types un comportement de Functor et de Monad, même à des types déjà définit dans la bibliothèque standard de Scala.

La notion de typeclasse provient du langage Haskell. Contrairement à Scala, les typeclasses ont une syntaxe spécifique dans Haskell. La notion a aussi été adaptée à Kotlin, comme on peut le voir avec la bibliothèque Arrow. Scala 3 devrait proposer une syntaxe spécifique pour les typeclasses, en particulier pour la déclaration des instances.

Nous verrons dans un prochain article l'utilisation des typeclasses avec Magnolia, afin de faciliter la conversion de format de données, en incluant les case class.


Référence

dotty/docs/docs/reference/contextual/derivation.md at 63b4ad7a28c92b32469b6ef997765336bc2274c2 · dotty-staging/dotty
Research platform for new language concepts and compiler technologies for Scala. - dotty/docs/docs/reference/contextual/derivation.md at 63b4ad7a28c92b32469b6ef997765336bc2274c2 · dotty-staging/dotty