Published by

Il y a 3 ans -

Temps de lecture 8 minutes

Découvrir la programmation fonctionnelle #5 | Typeclass

Introduction

En tant que développeur, nous utilisons souvent pas mal de librairies externes qui prévoient un large panel de cas d’usages. Malheureusement, aussi large soit le panel de cas d’usage prévu, nous nous retrouvons souvent avec des cas spécifiques propres à nos applications. Afin de pallier ce problème, l’alternative est de développer des classes “chapeaux” que l’on pourra tordre en fonction de nos uses cases. En Scala, un mécanisme d’Implicit permet de réduire cette verbosité et de simplifier leur utilisation.

Au cours de cet article, nous allons brièvement réviser les Trait ainsi que les Implicit afin de pouvoir définir les typeclasses qui en découlent.

Cet article est donc un préquel permettant de comprendre les exemples qui seront fournis dans la suite de cette série notamment pour introduire les éléments qui nous viennent tout droit de la théorie des catégories à savoir les semigroupes, les functors, les monoids, les monades etc.

Traits

Traits are used to share interfaces and fields between classes. They are similar to Java 8’s interfaces. Classes and objects can extend traits but traits cannot be instantiated and therefore have no parameters.

Scala’s official documentation

En Scala, un trait est similaire aux interfaces en Java 8. Ils permettent de définir un comportement ou des caractéristiques. En spécifiant les méthodes ou les valeurs qui devront être implémentées par les classes qui l’étendent :

trait Equal[A] {
  def isEqual(v: A): Boolean
}

Le trait Equal permet de décrire la comparaison de deux objets de même type (ici A) avec la méthode isEqual.

Dans un cadre global, le Trait peut contenir une implémentation par défaut de toutes ou parties des méthodes (ou valeurs) définies qui pourront être écrasées par les classes qui étendent ce Trait.

Nous pouvons par exemple définir une case classe Point et étendre ce trait.

case class Point(x: Int, y: Int) extends Equal[Point]{
    override def isEqual(v: Point): Boolean = x == v.x && y == v.y
}

Implicits

Les implicites en Scala sont un moyen de réduire la verbosité d’un code. Nous distinguons 3 types grands types d’implicits :

Les paramètres implicites

Les paramètres implicites permettent d’éviter de renseigner explicitement un paramètre dans un appel de fonction. Cela est particulièrement utile pour les variables de contexte dont la plupart des fonctions de notre programme à besoin.

implicit val sparkSession = new SparkSession()

Cela nous permettra d’appeler nos méthodes qui ont besoin des éléments de configuration sans être appelés explicitement.

def f(inputParam: Param)(implicit sparkSession: SparkSession)

 

L’appel de la fonction f sera simplement :

f(inputParam)

Cet appel sera automatiquement traduit par le compilateur pour le développeur en :

f(inputParam)(sparkSession)

Un paramètre implicite peut également être écrit sous la forme :

def f[T: List](inputParam: T)

Cette forme est appelée context bound elle permet par la suite de récupérer le paramètre implicite de la manière suivante :

val a = implicitly[List[T]]

À noter que les deux formes de déclaration de fonction se valent.

Les fonctions implicites

Les fonctions implicites sont des fonctions qui ne sont pas appelées explicitement. Elles sont appelées par le compilateur qui jugera de la nécessité de l’appel de cette fonction. En d’autres termes, si le compilateur juge que l’appel à une fonction empêchera la compilation de planter, il appellera la fonction.

Très souvent, les fonctions implicites servent à réaliser des conversions implicites. Cela permet d’hériter de nouvelles méthodes ou de convertir des types en un type voulu pour une ingestion par une autre méthode.

implicit def convertToInt(s: Option[String]): Int = { 
  s match {
    case Some(v) => Integer.parseInt(v)
    case None => 0
  }
}

Cette fonction dans le scope de notre code sera appellée automatiquement pour résoudre des opérations du type :

1 + Some("2")

À noter que convertToInt n’est pas une fonction pure. En effet, son exécution plante lorsque v ne peut pas être converti en entier.

 

Les classes implicites

Les classes implicites permettent de rajouter des méthodes d’instances à une classe.

Prenons l’exemple d’une case classe Point dont nous ne sommes pas propriétaire :

case class Point(x: Int, y: Int)

Si nous souhaitons lui rajouter des méthodes nous pouvons simplement le faire avec une classe implicite.

implicit class PointThatMoves(p: Point) {
  def moveLeft(): Point = p.copy( x = p.x - 1)

  def moveRight(): Point = p.copy(x = p.x + 1)

  def moveUp(): Point = p.copy(y = p.y + 1)

  def moveDown(): Point = p.copy(y = p.y - 1)
}

Lorsque ce code sera importé dans notre code à coté d’une classe Point, celle-ci sera automatiquement reconnue comme un PointThatMoves et donc héritera des méthodes implémentées dans PointThatMoves. Cela nous permettra donc de manipuler un objet Point comme ceci :

val p = Point(1, 2)

p.moveDown().moveLeft().moveRight()

Typeclasses

Les Typeclass sont un peu une combinaison des traits et des implicites. De ce fait, ils nous permettent d’insuffler un comportement à une catégorie d’objets.

La partie Trait nous permettra d’uniformiser/mutualiser l’interface, tandis que les implicites serviront à transformer automatiquement les classes sans que nous n’ayons le besoin de le faire manuellement.

Prenons l’exemple de la somme de deux Option dont le comportement désiré est le suivant :

  • Si Option(a) et Option(b) sont nulles alors la somme vaut 0.
  • Si Option(a) n’est pas nulle et Option(b) est nulle ou vice versa alors la somme vaut le contenu de l’option non nulle.
  • Si les deux Option(a) et Option(b) sont non nulles alors la somme vaut a+b

En Scala natif, nous pourrions l’implémenter comme suit :

(Option(a), Option(b)) match {
  case (None, None) => 0
  case (Some(x), None) => x
  case (None, Some(y)) => y
  case _ => a + b
}

Nous ne pouvons utiliser les for-comprehension dans ce cas précis car le résultat vaudrait None si l’une des deux Option n’est pas définie.

 

Étant donné que cette écriture est lourde, nous pouvons écrire une fonction qui fait le même travail. Seulement, ce faisant, nous perdons de la flexibilité sur l’utilisation de ce comportement lorsqu’on voudra l’intégrer à d’autres structures. Les type classes permettent de les réutiliser à l’infini et l’associer à d’autres traits et ainsi profiter de toute la puissance des mixins.

Nous pourrions donc développer un opérateur permettant d’additionner les Option comme suit : Option(a) + Option(b) qui se chargerait de nous renvoyer le bon résultat.

Avec les typeclasses, nous pouvons décrire ce type d’opération comme un comportement et en fournir une implémentation réutilisable.

Pour ce faire, nous définissons d’abord les caractéristiques des classes qui sont susceptibles de posséder cette fonctionnalité.

trait CanBeAdded[A] {
  def combine(v: Option[A], sh: Option[A]): Option[A]
}

Et pour finir, on décrit le comportement.

object CanBeAdded {
  implicit val optionIntCanBeAdded: CanBeAdded[Int] = new CanBeAdded[Int] {
    override def combine(v: Option[Int], sh: Option[Int]): Option[Int] = (v, sh) match {
      case (None, None) => Some(0)
      case (Some(x), None) => Some(x)
      case (None, Some(y)) => Some(y)
      case (Some(x), Some(y)) => Some(x + y)
    }
  }
}

Ceci développé, nous pouvons utiliser la méthode combine pour additionner les Option comme suit :

> combine(Option(22), Option(20))

Le résultat est :

> Option(42)

Nous pouvons même aller plus loin en définissant une classe implicite :

implicit class CanBeAddedSyntax(s: Option[Int]) {
  def |+|(v: Option[Int]): Option[Int] = combine(s, v)
}

En l’important dans le scope, cela nous permet de faire des appels de la manière suivante ce qui peut s’avérer plus pratique dans certains cas.

> Option(22) |+| Option(20)
> Option(42)

 

Il est important de noter que les implicites peuvent avoir un effet baguette magique voire magie noire. Leur usage peut induire des comportements inattendus et rendre difficile le déboggage. Nicolas Dechandon traite
des comportements inattendus et des difficultés de déboggage que peuvent engendrer l’usage des implicites

dans un précédent article.
Pour terminer, afin de voir précisément ce qui est injecté par les implicites, vous pouvez activer l’option de visualisation des implicites sur Intellij. Dans un autre article, Intellij présente les résultats de l’option de l’injection des implicites.

 

Conclusion

Dans cet article, nous avons revu les trait, puis nous nous sommes penchés sur les différentes formes des implicites en Scala avant de définir les Typeclasses.

Avec cette base, nous pourrons nous pencher sur des termes fonctionnellement plus techniques comme les monades, functors, semigroupes etc.

En attendant la suite, n’hésitez pas à poser vos questions en commentaire.

Published by

Commentaire

Laisser un commentaire

Votre adresse e-mail ne sera pas publiée. Les champs obligatoires sont indiqués avec *

Nous recrutons

Être un Sapient, c'est faire partie d'un groupe de passionnés ; C'est l'opportunité de travailler et de partager avec des pairs parmi les plus talentueux.