Une introduction aux classes de types en Scala

  • Partie 1: Cet article
  • Partie 2: Sérialisation sécurisée dans Scala
  • Partie 3: Gestion des erreurs typographiques avec des types de type supérieur

Qu’est-ce qu’une classe de type

L’un des plus grands obstacles à la compréhension et à l’utilisation des classes de type dans votre propre code est le nom lui-même. Contrairement à ce que son nom évoque, une classe de type n’est pas une classe d’un langage orienté objet. Selon wikipédia:

En informatique, une classe de type est une construction de système de type qui prend en charge le polymorphisme ad hoc. Ceci est réalisé en ajoutant des contraintes aux variables de type dans des types polymorphes paramétrés.

Nous avons du déballage à faire:

  • Qu’est-ce que le polymorphisme « ad hoc  » ? Quoi qu’il en soit, c’est ce que les classes de type vont nous aider à atteindre.
  • Qu’est-ce qu’un type « paramétralement polymorphe »? Quoi qu’il en soit, c’est ainsi que nous allons créer nos propres classes de types.

Les classes de type sont apparues pour la première fois dans Haskell et constituent un moyen fondamental de construire des abstractions dans Haskell. Ils sont si essentiels qu’ils sont présentés dans la première page du livre Learn You a Haskell:

Une classe de type est une sorte d’interface qui définit un comportement. Si un type fait partie d’une classe de type, cela signifie qu’il prend en charge et implémente le comportement décrit par la classe de type.

Ok, il semble donc que nous essayions de définir un comportement pour nos types et d’implémenter ce comportement différemment selon le type. Cette dernière partie, « implémenter le comportement différemment selon le type » est le bit de « polymorphisme » de précédemment. Le terme commence donc à avoir du sens: c’est une classe pour les types. Une classe qui implémente le comportement désigné par la classe de type est dite membre de cette classe de types — ‘classe de type’.

À ce stade, il serait approprié de dire que cela ressemble à un héritage. Ils nous permettent tous les deux de construire des abstractions et de fournir un polymorphisme.

Alors pourquoi explorer des classes de types? Dans de nombreux cas, ils sont un peu plus puissants et extensibles qu’une hiérarchie d’héritage. Ils peuvent être beaucoup plus simples et conviennent beaucoup mieux lorsque votre base de code penche déjà FP sur OO. Bien que même dans un projet fortement OO, les classes de type peuvent toujours sauver la situation.

Définir une classe de type

C’est assez d’introduction — pour le reste de l’article, nous explorerons une fonctionnalité très familière, qui est en fait implémentée en Java via l’héritage, et l’implémenter en utilisant une classe de type: stringification.

Nous connaissons tous la méthode qui existe sur tous les objets en Java (et donc Scala) : .toString. Son but est de produire une représentation sous forme de chaîne de l’objet en question. Regardons comment nous utiliserions les classes de type pour y parvenir. La version de Haskell de toString s’appelle Show, nous nous en tiendrons donc à peu près à ce nom:

trait Showable {
def show(a: A): String
}

Voilà : il y a notre classe de type Showable. Pourquoi prend-il un paramètre de type A ? Si nous rappelons notre première définition des classes de types, nous lisons que « obtenu en ajoutant des contraintes aux variables de type dans des types polymorphes paramétrés. »Showable est notre type polymorphe paramétralement, et A est notre variable de type, ce qui signifie simplement qu’il prend un type (A) comme paramètre de type. (Notez que nous n’avons placé aucune contrainte sur A, selon la définition, mais c’est bien. C’est quelque chose que nous faisons lorsque nous composons des classes de type ensemble, ou en faisons usage, qui viendra plus tard.)

Implémentant une classe de type

Maintenant, nous devons fournir des implémentations du trait Showable pour tous les types que nous souhaitons pouvoir afficher. Ces implémentations sont appelées « instances » de notre classe de type, car elles seront littéralement des instances du trait Showable. Ce seront le plus souvent des classes anonymes qui remplacent .show, et comme il y a un passe-partout impliqué dans leur définition (rappelez-vous les ‘objets de fonction’ dans la programmation Java pré-lambda?), nous allons définir un assistant appelé make. Commençons par une implémentation pour un type de nom d’utilisateur:

object Showable {
def make(showFn: A => String): Showable = new Showable {
override def show(a: A): String = showFn(a)
}
}final case class UserName(first: String, last: String)implicit val defaultUserNameShowable = Showable.make(userName => s"${userName.first} ${userName.last}")val janeName = UserName("Jane", "Doe")

Alors, comment pourrions-nous invoquer cela? Nous pourrions appeler directement la méthode à partir de l’instance showable, comme ceci:

defaultUserNameShowable.show(janeName) // "Jane Doe"

Syntaxe améliorée

Nous avons réussi à invoquer notre implémentation Showable de UserName, mais ce n’était pas très agréable. Nous devions avoir une référence à notre instance de classe de type (defaultUserNameShowable) et connaître son nom. C’est quelque chose que nous voulons abstraire. Si l’histoire se terminait ici, les classes de type dans Scala ne seraient pas si agréables à utiliser. Heureusement, nous pouvons améliorer considérablement l’ergonomie en utilisant des implicits. Nous augmenterons l’objet compagnon de Showable avec un assistant d’invocation:

implicit class ShowSyntax(a: A) extends AnyVal {
def show(implicit showable: Showable): String = showable.show(a)
}
...
janeName.show

En plaçant cette « méthode d’extension » quelque part dans la portée, nous pouvons simplement prétendre que tous les objets ont une méthode .show.

Qu’est-ce que cela a réalisé

Alors pourquoi ne pas simplement remplacer toString sur UserName à la place? Eh bien, disons que UserName vit dans une bibliothèque dont nous dépendons, et est déjà compilé au moment où nous la consommons. Nous ne pouvons pas le sous-classer car il s’agit d’une classe final. Cependant, en utilisant des classes de types, nous sommes libres d’y « attacher » essentiellement un nouveau comportement de manière ad hoc. C’est la pièce « ad hoc » de la définition ci-dessus: nous pouvons ajouter ce polymorphisme séparément de l’endroit où le type lui-même est défini.

De plus, contrairement à l’héritage, lorsque nous définissons des classes de type dans Scala, elles ne doivent pas être « cohérentes », ce qui signifie que nous pouvons définir plusieurs implémentations pour Showable, et choisir entre elles. Pour certaines classes de type, c’est une bonne chose, pour d’autres pas tellement.

Dans Haskell, les classes de types sont cohérentes. Dans Scala 3, selon Martin Odersky, nous aurons la possibilité de choisir, pour chaque classe de type, si elle doit être cohérente ou non.

Mais pour l’instant, donnons une autre signification de show pour UserName:

implicit val secretUserNameShowable = Showable.make(_ => "<secret person>")

Tout ce que nous devons faire maintenant est de nous assurer qu’une seule de nos instances Showable est dans la portée à la fois. Et sinon, ce n’est pas grave, nous obtiendrons une erreur de « valeurs implicites ambiguës » au moment de la compilation:

janeName.show
> ambiguous implicit values:
both value secretUserNameShowable in object typeclasses of type => Showable
and value defaultUserNameShowable in object typeclasses of type => Showable
match expected type Showable

Une note importante est que nous n’avons pas couplé UserName et Showable. Tout ce que nous avons fait est de définir une signification de Showable pour UserName, tout en les gardant complètement indépendants les uns des autres. Ce découplage a d’énormes implications en termes de maintenabilité et aide à éviter les relations de dépendance enchevêtrées dans tout votre code.

Sécurité au moment de la compilation

Que se passe-t-il si nous tentons d’appeler .show sur un objet pour lequel nous n’avons pas fourni de signification show ? En d’autres termes, quand il n’y a pas d’instance de Showable dans la portée de notre cible? Nous obtenons simplement une erreur de compilation:

java.util.UUID.randomUUID().show
> could not find implicit value for parameter showable: Showable

Nous pouvons réellement personnaliser ce message d’erreur lorsque nous définissons notre classe de type:

@scala.annotation.implicitNotFound("No Showable in scope for A = ${A}. Try importing or defining one.")
trait Showable {
...
}
...
janeName.show
> No Showable in scope for A = java.util.UUID. Try importing or defining one.

Points à retenir

  • Les classes de types nous permettent de définir un ensemble de comportements entièrement séparément des objets et des types qui implémenteront ces comportements.
  • Les classes de type sont exprimées en Scala pur avec des traits qui prennent des paramètres de type et impliquent de rendre la syntaxe propre.Les classes de type
  • nous permettent d’étendre ou d’implémenter un comportement pour les types et les objets dont nous ne pouvons pas modifier la source. (Ils fournissent le polymorphisme ad hoc nécessaire pour résoudre le problème d’expression).

Ce qui vient ensuite

Accordé, Showable est une classe de type triviale, mais pas sans ses utilisations. Cependant, nous pouvons facilement en imaginer beaucoup plus utiles:

trait SafeSerializable {
def serialize(a: A): Array
def deserialize(bytes: Array): A
}
trait Convertible {
def to(a: A): B
def from(b: B): A
}
trait Functor] {
def map(a: F, f: A => B): F
}

SafeSerializable cela pourrait être une excellente alternative à quelque chose comme la sérialisation Akka où nous attendons l’exécution pour voir si un sérialiseur est enregistré ou non pour notre type. Il est beaucoup plus sûr de transmettre implicitement votre implémentation de sérialisation au moment de la compilation !

Convertible pourrait être un excellent moyen réutilisable de convertir des données entre types. Il existe une bibliothèque de Twitter avec le même objectif à l’esprit appelée Bijection.

Functor est un bloc de construction pour certaines abstractions extrêmement puissantes dans un domaine de la programmation fonctionnelle appelé « programmation catégorielle ». Les bibliothèques comme ScalaZ et Cats sont construites sur des classes de type comme Functor. Une notion importante que nous n’avons pas discutée cette fois-ci est que les classes de types peuvent être facilement composées pour construire un comportement de plus en plus complexe à partir de blocs de construction simples comme Functor.

De plus, de nombreuses bibliothèques essentielles dans l’écosystème Scala (Play JSON, Slick, et. al) utilisent des classes de types et les exposent comme moteur principal de leur API dans de nombreux cas (e.g. Lire la classe de type Format de JSON). Comprendre vous-même les classes de type est un excellent moyen de devenir plus efficace pour utiliser ce qui existe dans l’écosystème Scala, même si vous n’écrivez jamais les vôtres.

À l’avenir, je regarderai des classes de type immédiatement pratiques comme SafeSerializable. Pour une introduction aux classes de types catégoriques plus abstraites comme Functor] et Monad], ne cherchez pas plus loin que la documentation de la bibliothèque cats. Pour un guide plus approfondi de la programmation dans Scala à l’aide de classes de types, le underscore.io a un livre fantastique sur le sujet: Scala avec des chats

Partie 2: Sérialisation sécurisée par type dans Scala

GIST avec code

Laisser un commentaire

Votre adresse e-mail ne sera pas publiée.