Classes de types et Functor, en Haskell
Voir aussi : video youtube - video peertube - code source
En plus des types algébriques (ADT), Haskell supporte les classes de types (Type Classes). Celles-ci permettent notamment de spécifier des contraintes sur des types polymorphes. Les classes de types sont un outil très performant en Haskell, qui est utilisé pour définir des structures de haut-niveau comme les Functor et les Monad.
Les classes de types existent également dans d’autres langage, sous des formes généralement moins évoluées. Par exemple, les traits en Rust et les concepts en C++20.
Cet article présente les classes de types en Haskell, à partir de quelques exemples. Il introduit également un exemple de structure évoluée, les Functor.
Les classes de types
Attention, la notion de classe
abordée ici ne doit pas être confondue avec
celle utilisée en programmation orientée-objet.
Exemple avec un ADT
Pour rappel, un type algébrique permet de définir une somme de types produits.
Par exemple, on peut définir un type Figure
qui peut prendre des valeurs
Rectangle
ou des valeurs Disque
, de la façon suivante.
data Figure
= Rectangle Double Double
| Disque Double
On peut ensuite utiliser ce type et ces valeurs pour définir ou appeler des fonctions.
surface :: Figure -> Double
Rectangle l h) = l * h
surface (Disque r) = pi * r**2
surface (
printSurface :: Figure -> IO ()
= print . surface
printSurface
main :: IO ()
= do
main Rectangle 2 21)
printSurface (Disque 3.66) printSurface (
Les types algébriques sont très pratiques pour représenter un état bien défini
à l’avance mais ils peuvent parfois manquer d’extensibilité. Par exemple dans
le code précédent, si on veut gérer des figures supplémentaires, il faut
modifier le type Figure
et potentiellement toutes les fonctions qui
l’utilisent.
Exemple avec une Type Class
En simplifiant, une classe de type déclare les fonctions qu’un type doit définir pour instancier cette classe. On peut voir cela un peu comme les interfaces en Java (mais où les instances concrètes sont déterminées à la compilation).
Par exemple, la classe HasSurface
suivante déclare la fonction surface
(et
sa signature).
class HasSurface a where
surface :: a -> Double
On peut ensuite définir un type Rectangle
et lui faire instancier la classe
HasSurface
en définissant la fonction surface
pour le type Rectangle
.
data Rectangle = Rectangle Double Double
instance HasSurface Rectangle where
Rectangle l h) = l * h surface (
De même, on peut définir autant de types que l’on veut et leur faire instancier cette classe.
newtype Disque = Disque Double
instance HasSurface Disque where
Disque r) = pi * r**2 surface (
Enfin, on peut profiter du polymorphisme ad-hoc, pour définir des fonctions utilisables sur tout type de cette classe.
printSurface :: HasSurface a => a -> IO ()
= print . surface printSurface
Ici, printSurface
prend en paramètre un type polymorphe a
mais qui doit
respecter la contrainte d’être de classe HasSurface
.
On dit que printSurface
prend un a
et retourne un IO ()
où a
est un
type de classe HasSurface
.
La classe Show
La bibliothèque de base de Haskell définit de nombreuses classes de types.
Définition
La classe Show
représente les types affichables, c’est-à-dire possèdant une
fonction show :: a -> String
qui prend un paramètre et retourne un texte.
On peut demander des informations sur Show
dans ghci :
Prelude> :info Show
class Show a where
showsPrec :: Int -> a -> ShowS
show :: a -> String
showList :: [a] -> ShowS
{-# MINIMAL showsPrec | show #-}
-- Defined in ‘GHC.Show’
instance Show Int -- Defined in ‘GHC.Show’
instance Show a => Show [a] -- Defined in ‘GHC.Show’
...
Ceci nous indique que :
- avec un type de classe
Show
, on peut utiliser les fonctionsshowsPrec
,show
, etshowList
. - on peut instancier la classe
Show
pour un nouveau type en définissant la fonctionshowsPrec
ou la fonctionshow
(les fonctions restantes sont déterminées automatiquement). - le type
Int
est déjà de classeShow
, de même que les listes (d’éléments eux-mêmes affichables), etc.
Instanciation pour un ADT
Illustrons comment instancier la classe Show
pour le type algébrique List
suivant :
data List = Nil | Cons Int List
Il s’agit donc d’un type récursif qui définit une liste d’entiers de façon
classique. La valeur Nil
représente la liste vide et la valeur Cons
représente l’ajout d’un élément à une sous-liste.
On peut ensuite instancier la classe Show
pour notre type List
.
instance Show List where
-- show :: List -> String
show Nil = "Nil"
show (Cons x Nil) = "Cons " ++ show x ++ " Nil"
show (Cons x xs) = "Cons " ++ show x ++ " (" ++ show xs ++ ")"
Il s’agit donc essentiellement de définir les fonctions demandées par la
classe, pour notre type. Pour la classe Show
, il suffit de définir la
fonction show
.
Avec cette instance, on peut désormais utiliser, avec notre List
, toutes les
fonctions définies pour des types de classe Show
, comme par exemple la
fonction print
.
main :: IO ()
= do
main print Nil
print (Cons 1 (Cons 2 Nil))
Instanciation pour un ADT polymorphe
Notre type List
précédent représentait des listes d’entiers uniquement. On
peut le modifier pour représenter des listes génériques.
data List a = Nil | Cons a (List a)
Ceci signifie simplement, qu’on a désormais un type polymorphe List a
et
qu’une valeur Cons
contient désormais un type a
et non obligatoirement un
Int
.
Il faut donc légèrement modifier l’instance de Show
.
instance Show a => Show (List a) where
-- show :: List a -> String
show Nil = "Nil"
show (Cons x Nil) = "Cons " ++ show x ++ " Nil"
show (Cons x xs) = "Cons " ++ show x ++ " (" ++ show xs ++ ")"
Dans cette instanciation, on a donc remplacé List
par List a
. De plus,
comme notre définition de show
affiche les éléments de la liste (avec show x
), il faut également ajouter la contrainte de type que a
doit être de
classe Show
.
On a ainsi une liste générique, que l’on peut afficher si ses éléments sont eux-mêmes affichables.
main :: IO ()
= do
main print (Nil :: List String)
print (Cons 1 (Cons 2 Nil) :: List Int)
Instanciation automatique
Haskell permet d’implémenter des instances automatiquement, via deriving
.
Ainsi, le code précédent se résume en fait à :
data List a = Nil | Cons a (List a)
deriving Show
main :: IO ()
= do
main print (Nil :: List String)
print (Cons 1 (Cons 2 Nil) :: List Int)
La classe Functor
Définition
Intuitivement, les foncteurs sont des types “à l’intérieur desquels on peut appliquer une fonction”.
Prelude> :info Functor
class Functor (f :: * -> *) where
fmap :: (a -> b) -> f a -> f b
(<$) :: a -> f b -> f a
{-# MINIMAL fmap #-}
-- Defined in ‘GHC.Base’
instance Functor [] -- Defined in ‘GHC.Base’
instance Functor Maybe -- Defined in ‘GHC.Base’
...
On peut voir cela comme une généralisation de la fonction map
des listes
Haskell à d’autres types. Pour cela, on a donc la classe Functor
, qui
déclare la fonction fmap
(pour “flat map”).
Ainsi, on peut “fmapper” une fonction sur les éléments d’une liste, ce qui produit une nouvelle liste, de même type ou non, selon la fonction utilisée.
Prelude> fmap (*2) [1..4]
2,4,6,8]
[
Prelude> fmap show [1..4]
"1","2","3","4"] [
De même, on peut fmapper dans n’importe quel foncteur, par exemple Maybe
.
Prelude> fmap (*2) (Just 21)
Just 42
Prelude>
Et on peut définir des fonctions qui “fonctionneront” sur tous les foncteurs.
Prelude> fmap2fois f x = fmap f (fmap f x)
Prelude> fmap2fois (*2) [1..4]
4,8,12,16]
[
Prelude> fmap2fois (*2) (Just 3)
Just 12
Remarque sur le HKT
On notera le type de la fonction précédente (et également celui de la fonction
fmap
en fait) :
Prelude> :t fmap2fois
fmap2fois :: Functor f => (a -> a) -> f a -> f a
Ici le paramètre de classe (f
) est utilisé dans la signature de fonction
comme un type polymorphe (f a
). Il s’agit en fait d’un type de sorte
supérieur (Higher Kinded Types).
Ceci est une fonctionnalité très puissante de Haskell (et sera certainement
utilisé dans des articles suivants de ce blog). Pour l’instant, on retiendra
juste que la classe Show
permet de construire une contrainte à partir d’un
type alors que la classe Functor
construit une contraint à partir d’une
fonction sur des types :
Prelude> :kind Show
Show :: * -> Constraint
Prelude> :kind Functor
Functor :: (* -> *) -> Constraint
Instanciation manuelle
Comme pour toute classe de type, on peut instancier Functor
pour nos propres
types, par exemple pour List a
.
data List a = Nil | Cons a (List a)
deriving Show
instance Functor List where
-- fmap :: (a -> b) -> List a -> List b
fmap _ Nil = Nil
fmap f (Cons x xs) = Cons (f x) (fmap f xs)
main :: IO ()
= do
main print (Nil :: List String)
print $ fmap (*2) (Cons 1 (Cons 2 Nil) :: List Int)
print $ fmap (*2) [1,2::Int]
On remarquera qu’on instancie bien Functor
pour List a
et non pour List
car, comme expliqué précédemment, un foncteur construit une contrainte à partir
d’une fonction sur des types :
*Main> :kind List
List :: * -> *
*Main> :kind (List Int)
List Int) :: * (
Instanciation automatique
Haskell est également capable d’instancier Functor
automatiquement, avec
l’extension DeriveFunctor
.
Finalement, on peut implémenter une liste générique, affichable et “fmappable” avec les quelques lignes suivantes !!!
{-# LANGUAGE DeriveFunctor #-}
data List a = Nil | Cons a (List a)
deriving (Functor, Show)
main :: IO ()
= do
main print (Nil :: List String)
print $ fmap (*2) (Cons 1 (Cons 2 Nil) :: List Int)
print $ fmap (*2) [1,2::Int]
Autre exemple d’instanciation
Le système de types, classes de types et HKT de Haskell est très pratique pour manipuler des structures arborescentes. Par exemple, pour un arbre binaire générique, affichable et fmappable :
{-# LANGUAGE DeriveFunctor #-}
data Tree a
= Leaf
| Node a (Tree a) (Tree a)
deriving (Functor, Show)
main :: IO ()
= do
main let t1 = Node (1::Int) (Node 2 Leaf Leaf) (Node 3 Leaf Leaf)
print t1
print $ fmap (*2) t1
Conclusion
Une classe de types déclare les fonctions qu’un type de cette classe doit définir. Ceci permet de spécifier des contraintes pour affiner le polymorphisme ad-hoc. En Haskell, les classes de types permettent également de définir des types de sortes supérieures et ainsi d’implémenter des structures évoluées comme les foncteurs. Au delà de cette approche un peu abstraite, ceci permet concrètement d’écrire du code plus expressif, plus concis et plus sûr.