Shapeless Introduction

Le langage Scala est un langage à tiroirs.
Ce que je veux dire par là, c’est que Scala couvre un large spectre de connaissance.
Lorsque l’on démarre on va commencer par utiliser le langage en suivant les mêmes patterns du langage que l’on utilisait avant.

Puis au fur et à mesure on va découvrir de nouveaux horizons de nouvelles approches. Cette progression va nous donner un nouveau regard et une nouvelle façon de coder.

Progressivement, le système de type va se positionner au centre de nos préoccupations de développeur.

Lorsque les patterns de la programmation fonctionnelle sont passés dans le langage commun, on commence alors à s’intéresser à des librairies de plus haut niveau.

Parmi ces librairies on trouve Shapeless. Elle est souvent perçue comme très complexe et difficile à aborder par les débutants.

Pourtant, cette librairie est au coeur d’un bon nombre de lib que nous utilisons quotidiennement dans nos applications.

Une liste non exhaustive ici

Je vous propose ici, une introduction simple et pragmatique afin de démystifier Shapeless et d’en faire votre allié. Pour faire cette introduction, nous allons prendre un use case, que je trouve intéressante.

Shapeless permet de faire de la programmation générique. En se basant sur le système de types et sa capacité faire la dérivation de ces types.
Je suppose que ceux qui découvrent Shapeless se sentent déja perdu.. mais pas d’inquiètude, poursuivez et tout, j’espère, deviendra plus clair.

Premièrement, définissons notre objectif. Nous allons coder une petite librairie qui nous permettra, a partir de case classes, de générer des instances de celle-ci. Chaque attribut sera affecté avec des données générées de manière aléatoire avec des données générées de manière aléatoire. Très utile dans le cadre des tests unitaires. Ce use case est librement inspiré de la librairie suivante : https://github.com/DanielaSfregola/random-data-generator. Ici, nous ferons notre propre implémentation dans une version simplifiée.

Shapeless, premiers pas

Le coeur de shapeless repose principalement sur sa capacité à pouvoir transformer la représentation courante d’une case classe (product type):

case class User(firstname:String, lastname: String, groupId:Int, isAdmin: Boolean)

comme ceci, une HList :

String :: String :: Int : Boolean :HNil

On voit rapidement le lien entre les deux représentations. Basiquement, une HList est la liste du type de chaque attribut de la case.
A quoi cela peut-il bien servir ?. En fait cette représentation, sous forme de HList est très puissante. Là où dans un language comme Scala le type est défini par son nom (en règle générale, on ne va pas aborder le Duck typing ou typage structurel, qui n’est généralement pas une bonne pratique). Donc avec cette HList, on exprime le type de la case classe uniquement par sa structure, le type de ses attributs.

Un des premiers intérêts est de pouvoir faire cohabiter deux types qui ont la même structure.. mais pas le même nom.

Par exemple, imaginons que l’on souhaite faire cohabiter deux systèmes qui chacun ont la représentation deux leurs utilisateurs :

Système A :

case class User(firstname:String, lastname: String, groupId:Int, isAdmin: Boolean)

Système B :

case class Utilisateur(prenom:String, nom: String, equipe:Int, administrateur: Boolean)

Intuitivement, on voit que ce sont les mêmes types.. mais selon le typage nominal, les types sont différents.
Par contre une fois dérivée via Shapeless :

La représentation sous forme de HList est la même pour les deux types ci-dessus:

String :: String :: Int :: Boolean ::HNil

Bien que l’on ait plus le typage nominal, on garde l’avantage et la sécurité du système de type avec cette représentation.

À travers un exemple concret, nous allons voir comment dériver une case class.

Principe de dérivation

Que veut dire dériver une case class? C’est passer de la structure case class à celui de HList et pour chaque élément de la HList faire quelque chose et enfin produire un résultat.

Exemple concret : Générateur de données random pour nos cases classes.

L’objectif ici est de produire une instance d’une de nos cases classes avec toutes les valeurs des attributs affectés avec des valeurs aléatoires.

Concrètement, on voudrait remplacer le code suivant :

Soit la case class suivante :

case class Person(name:String, lastname:String,yearOfBirth:Int)

On veut remplacer, ce genre de code en dur

val p = Person("nameP1", "lastnameP1", 1970)

par

val p = generator[Person]

et automatiquement on obtiendra un p avec des valeurs renseignées aléatoirement.
La structure de base va se baser sur Shapeless et son principe de dérivation.
Un prérequis est nécessaire pour aborder cet exemple, il faut comprendre et maitriser le principe des implicites et de type class. Pour cela, je vous renvoie à cet article pour une introduction :

Prêt? alors c’est parti !

Qu’allons-nous faire?

Nous allons mettre en place un certain nombre de petits composants. Chacun de ces composants va intervenir dans le processus de dérivation de la case class.
Avant de vous présenter les différents composants, je vais vous les décrire, ensuite nous verrons leurs implémentations concrètes.

Définition d’un contrat

Tout le mécanisme va reposer sur un contrat que l’on va définir via un trait.
Ce trait sera utilisé pour définir, pour chaque type, la manière dont il sera transformé.

trait Parser[A] {
  def apply: A
}

A étant le type à parser.

On associe à ce trait un companion object pour fournir l’implémentation de la méthode apply.

object Parser {
  def apply[A](implicit parser: Parser[A]): A = parser.apply
}

Le trait Parser sera l’élément central.

Remarque

Dans notre contrat nous définissons la méthode apply. Ce choix repose sur le fait que la méthode apply, dans Scala, de faire des raccourcis. Je vous laisse vous référer à la documentation du langage pour plus de détails.
Le nom de cette méthode pourrait être nommé autrement sans aucun problème.

Transformation

Nous allons rentrer maintenant dans la ‘plomberie’ du processus de dérivation. La première chose à faire est de transformer la case class à en HList (et dans le même temps faire la conversion inverse lorsque toutes les données seront générées) :

(1) implicit def caseClassParser[A, R <: HList](
(2) implicit
(3)   gen: Generic.Aux[A, R],
(4)   reprParser: Parser[R]
): Parser[A] = new Parser[A] {
(5)    def apply: A = gen.from(reprParser.apply)
}

C’est ici que les choses commencent à s’obscurcir un ‘peu’ :).
C’est également ici que la librairie Shapeless entre en scène.
En effet la lecture de ce bout de code n’est pas triviale.
Pas de panique, nous allons décortiquer tout cela.

La première chose à noter, est le fait que la fonction est déclarée `implicit` (1), ceci indique que le compilateur sera ammené à l’invoquer durant la phase de résolution des implicits si à un moment donné les conditions en terme de types sont remplies.
On constate également en ligne (2) que les paramètres (gen et reprParser sont également implicits, ceci indique qu’au moment de l’invocation de cette méthode, le compilateur va rechercher dans les différents scopes à sa disposition des implicits répondant au critères imposés par les types paramétrés (A et R).

Pour le cas du premier paramètre

gen:Generic.Aux[A,R]

il s’agit d’une fonction de la librairie Shapeless qui permet deux choses :

  • Effectuer la transformation de A (case class) en Hlist
  • R correspond à la représentation interne de cette fameuse HList déduite du type A. L’objectif de R est de rendre accessible ce type afin qu’il puisse être utilisé dans lors de la dérivation.

On aurait pu remplacer
Generic.Aux[A, R]
par le bout de code suivant:
gen:Generic[A] { type Repr = R }

la fonction Aux est une manière plus élégante de l’exprimer.

La ligne (5) est la méthode qui sera appelée derrière la fonction caseClassParser

Et qui déclenchera tout le processus de manière effective.

reprParser.apply -> Va résoudre l’appel de fonction apply sur le Parser[HList] puisque R sera de type HList
gen.from(reprParser.apply) -> va instancier A et appliquer les valeurs résolues par le processus pour servir une case classe contenant les valeurs aléatoires.

Le coeur du processus

Le coeur du processus de dérivation se repose sur un parcours récursif de la HList d’entrée (pour mémoire : reprParser.apply)

Le principe est qu’à chaque élément parcouru, le compilateur va rechercher le parser pour le type correspondant. Voici le code :

(1) implicit val hnilParser: Parser[HNil] = new Parser[HNil] {
      def apply: HNil = HNil
}

(2) implicit def hconsParser[H: Parser, T <: HList: Parser]: Parser[H :: T] =
new Parser[H :: T] {
    def apply: H :: T = {
        (3) implicitly[Parser[H]].apply :: implicitly[Parser[T]].apply
    }
}

Soit les deux fonctions ci-dessus, la première représente le cas d’arrêt. C’est-à-dire la fin de la HList (représentée par HNil)
La deuxième fonction représente la partie récursive.

La première chose à comprendre ici est comment cette fonction peut être invoquée?
On remarque d’abord qu’il est déclaré implicit. Donc elle sera invoquée dans la phase de résolution des types par le compilateur.
Le compilateur est intelligent et au moment de la résolution des implicits, il est capable de décomposer le type

Parser[HList]

en

Parser[Head] et Parser[Tail] (Head étant le type du premier élément de la HList et Tail de type Hlist)

Du coup, pour résoudre le type Parser[HList], le compilateur pourra invoquer la fonction hconsParser[Head, Tail]

En effet, les deux types attendus sont un Parser[V] et un Parser[HList].

En ligne (3), d’un côté on recherche le Parser correspondant au type H et ensuite on fait l’appel récursif en rappelant la fonction via le Parser[HList].

La récursion se fera jusqu’à ce que la deuxième partie correspond à un appel à Parser[HNil] qui est le cas d’arrêt.

Chaque appel à Parser[D].apply dans la première partie (à gauche de l’opérateur cons ::) à une instance correspondant au type D cela a pour effet d’avoir la transformation suivante :

String :: Int :: Int :: HNil -> "random" :: 10 :: 1234 :: HNil

Concrètement comment on passe de String à une valeur aléatoire. Et bien en appliquant le même principe.. c’est-à-dire qu’il faut qu’il y ait dans le scope une implémentation implicit de Parser[String], pareil pour les autres types.

Les types basiques

Nous venons de voir que pour faire la transformation de chaque type de la liste, il faut le parser correspondant.
Sans surprise, voila comme les fournir :

implicit val stringParser: Parser[String] = new Parser[String] {
  def apply: String = //génerer une chaine aléatoire
}

implicit val intParser: Parser[Int] = new Parser[Int] {
  def apply: Int = //génerer un Int aléatoire
}

implicit val doubleParser: Parser[Double] = new Parser[Double] {
  def apply: Double = //génerer un double aléatoire
}

/!\
Nous ne décrivons pas ici le mécanisme de génération de valeurs aléatoires. Ce mécanisme est complètement indépendant.
Dans la lib en référence, ce sont les générateurs de ScalaCheck qui sont utilisés.
/!\

Un jeu de fonctions typées déclarées implicits.
La liste n’est pas exhaustive ce qu’il faudra fournir généralement l’ensemble des types de base : String, Long, Int, Boolean, etc..

Nous avons maintenant tous les composants de base permettant de dériver une case classe simple c’est-à-dire :

UneCaseClasse(String, Long,Boolean)

Pour dériver la case classe ci-dessous :

val r:UneCaseClasse = Parser.apply[UneCaseClasse]

Nous obtiendrons une instance de UneCaseClaseClasse avec ses attributs initialisés avec des valeurs aléatoires.

Synthèse

Nous avons vu ici, un cas d’utilisation de la librairie Shapeless. C’est un cas d’utilisation assez courant.
Nous avons détaillé étape par étape le processus de dérivation d’une case classe.
Ce que nous n’avons pas vu encore c’est comment étendre le système avec des cases classes plus complexes. Ca fera l’objet d’un prochain article.
Ce qu’il est crucial de bien comprendre pour maîtriser ce que nous venons de voir est le mécanisme des implicits et la pattern de type class.

N’hésitez pas à partir à la dérive 😉