Licence CC BY-SA

Types et classes de types

Haskell est un langage statiquement et fortement typé. Cependant, l'inférence de type (le fait que les types soient devinés automatiquement par le compilateur) fait que vous n'avez pas vraiment eu à vous soucier des questions de type jusqu'ici. Il est quand même très utile de savoir comment fonctionne le système de type d'Haskell, qui va vous être présenté dans ce chapitre de façon progressive, en commençant par des types simples.

Types simples

Types de variables

Déterminer le type d'une expression

Pour commencer, on va mettre quelques déclarations simples dans un fichier :

1
2
3
4
5
-- types.hs
reponse = 42
presquePi = 3.14
lettre = 'c'
vrai = True

Maintenant, on charge ce fichier dans ghci. La commande :t permet de connaître le type d'une expression inféré par le compilateur:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
Prelude> :l "types.hs"
[1 of 1] Compiling Main             ( types.hs, interpreted )
Ok, modules loaded: Main.
Prelude> :t reponse
reponse :: Integer
Prelude> :t presquePi
presquePi :: Double
Prelude> :t lettre
lettre :: Char
Prelude> :t vrai
vrai :: Bool

On a donc trouvé les types de nos trois variables : reponse a pour type Integer (c'est-à-dire "nombre entier"), presquePi a pour type Double (le type des nombres décimaux), lettre a le type Char, le type des caractères, et vrai a le type Bool des booléens.

On peut écrire les types directement dans le code source. La déclaration de type doit se situer juste avant la déclaration de la variable, et a pour forme : nom :: Type. Par exemple :

1
2
3
4
5
reponse :: Integer
reponse = 42

presquePi :: Double
presquePi = 3.14

Si on indique un type qui ne correspond pas au type de la variable, ghc se plaint :

1
2
lettre :: Integer
lettre = 'a'

On charge ce code dans ghci :

1
2
3
4
5
6
Prelude> :l types.hs
types.hs:7:9:
    Couldn't match expected type `Integer' against inferred type `Char'
    In the expression: 'c'
    In the definition of `lettre': lettre = 'c'
Failed, modules loaded: none.

On obtient un message d'erreur effrayant. N'ayez pas trop peur, vous allez apprendre à le déchiffrer : ghc indique d'abord le fichier et la ligne à laquelle l'erreur se produit, puis l'erreur en elle-même. Ici, il nous indique qu'il attendait (expected type) une valeur de type Integer, mais qu'il a trouvé que l'expression avait comme type (inferred type) Char, et que les deux types ne correspondent pas.

Des types composés

Maintenant, on pourrait se demander quel est le type de quelque chose comme (12, "Coucou"). On pourrait d'abord penser que le type est Paire, ou quelque chose d'équivalent. Cependant, dans ce cas-là, si je prends le premier élément d'une paire et que je lui ajoute un nombre, rien ne me garantit que cet élément sera vraiment un nombre. Si c'est un caractère, ou quelque chose d'autre de bizarre, j'obtiendrais alors une erreur de type à l'exécution, ce qui ne devrait pas arriver en Haskell. C'est pour cela que le type d'une paire indique le type de ses deux éléments :

1
2
3
paire = (12, 'a')

triplet = (42,23,paire)

Testons-cela dans ghci :

1
2
3
Prelude> :l types.hs
Prelude> :t paire
paire :: (Integer, Char)

On note le type des n-uplets en notant entre parenthèses les types des éléments, séparés par des virgules. Essayez de deviner le type de triplet puis comparez avec celui donné par ghci.

Pour les listes, le type se note entre crochets :

1
2
3
4
5
liste :: [Integer]
liste = [1,2,3,4,5]

message :: String
message = "Hello World !"

Pour message, on a écrit le type String, mais les chaînes de caractères ne sont qu'un raccourci pour écrire une liste de caractères, et ainsi le type String n'est qu'un autre nom pour [Char]. Dans ses messages d'erreur et les types qu'il donne, ghc mélange parfois les deux noms, mais cela reste le même type.

Le type d'une valeur Maybe s'écrit avec le constructeur de type Maybe, qui prend un argument qui est le type contenu :

1
2
numero :: Maybe Integer
numero = Just 123

J'ai appelé ces types "types composés" car ils sont formés à partir d'un constructeur de type et des plusieurs types qu'on lui donne en paramètre, et pas seulement d'un type simple.

Fonctions

Les fonctions aussi ont un type : il faut indiquer les types et le nombre de leurs arguments, ainsi que le type de données qu'elles renvoient. Par exemple, on prend le code suivant, qui contient un certain nombre de fonctions pas très utiles (c'est juste pour l'exemple) :

1
2
3
4
5
6
nombre = 23

ajouter x = x + nombre
bonjour x = "Bonjour " ++ x

super x y = (ajouter x, bonjour y)

Prenez le temps de les tester un peu dans ghci pour voir ce qu'elles font. Maintenant, on veut trouver leur type :

1
2
3
4
Prelude> :t ajouter
Integer -> Integer
Prelude> :t bonjour
[Char] -> [Char]

Le type d'une fonction à un argument s'écrit A -> B, où A est le type de l'argument et B le type renvoyé par la fonction. On comprend bien le sens de la flèche : la fonction transforme une valeur de type A en une valeur de type B.

Maintenant, passons aux fonctions à deux arguments :

1
2
Prelude> :t super
Integer -> [Char] -> (Integer,[Char])

Le type d'une fonction à deux arguments s'écrit donc A -> B -> C. De même, le type d'une fonction à trois arguments s'écrit sous la forme A -> B -> C -> D, et ainsi de suite. Ici, le sens de la flèche parait plus difficile à comprendre, mais vous saurez tout dans le chapitre sur la programmation fonctionnelle.

Polymorphisme et classes de types

Le polymorphisme

Un problème

Maintenant, quel est le type de la fonction head qui prend le premier élément d'une liste ? Si on veut l'utiliser sur une liste d'entiers, il faut que son type soit [Integer] -> Integer. Si on veut pouvoir l'utiliser sur une liste de caractères, son type doit être [Char] -> Char, et la même chose avec tous les types. On pourrait dire que c'est une fonction qui prend une liste d'éléments de n'importe quel type, et renvoie un élément de n'importe quel type, mais dans ce cas on perd le fait que la valeur retournée est du même type que les éléments de la liste, et on risque toujours d'avoir des erreurs de types à l'exécution du programme.

La solution

Regardons quel est le type de head :

1
2
Prelude> :t head
head :: [a] -> a

Le a n'est pas un nom de type, puisqu'il ne commence pas par une lettre majuscule. En réalité c'est une variable de type, et on peut la remplacer par n'importe quel type, à partir du moment où on remplace à chaque endroit où elle apparait la variable par ce type. Par exemple, on pourrait utiliser head comme une fonction de type [Integer] -> Integer, ou [Char] -> Char, mais pas comme une fonction de type [Integer] -> Char, puisqu'on a remplacé a par deux types différents.

On peut aussi introduire plusieurs variables de types dans une même signature. Par exemple, quel est le type de la fonction suivante ?

1
construireTriplet x y z = (x,y,z)

Le polymorphisme ne concerne pas que les fonctions : par exemple, Nothing a pour type Maybe a. Pour vous entrainer, vos pouvez essayer de trouver le type de quelques fonctions sur les listes, comme :, ++, reverse ou concat simplement en pensant à ce qu'elles font, et le vérifier avec la commande :t. Si vous ne vous souvenez plus de ce que font ces fonctions, vous pouvez relire la partie sur les listes du deuxième chapitre. Quand vous écrivez le code d'une fonction et que vous savez ce qu'elle fait mais pas comment la coder, il est souvent pratique de commencer par penser au type de la fonction et de l'écrire, car cela peut donner des indications sur comment devrait fonctionner la fonction.

Classes de types

Quel le type de + ?

Le polymorphisme règle un certain nombre de problèmes, mais on a toujours des problèmes pour donner le type de certaines fonctions. Par exemple, l'opérateur + doit permettre d'additionner tous les types de nombres : on doit donc pouvoir l'utiliser avec le type Integer -> Integer -> Integer, mais aussi avec le type Double -> Double -> Double. On pourrait donc penser que le type de + est a -> a -> a. Cependant, cela pose toujours un problème : pour certains types, l'addition n'a pas de sens. Par exemple, que voudraient dire la multiplication ou la division sur les listes ? Pour régler ce problème, on utilise les classes de types. Regardez dans ghci le type de + avec la commande :t (+) (on a besoin de la notation infixe quand on veut parler de l'opérateur tout seul en tant que fonction) : c'est (Num a) => a -> a -> a. Comme prévu, il y a bien une variable de type, puisque la fonction doit être polymorphe. Cependant, cette signature est composée de deux parties, séparées par une double flèche =>. La partie à droite est un type construit normalement, qui peut contenir des variables de type. La partie à gauche est plus intéressante : c'est un ensemble de contraintes sur ces variables de type, séparées par des virgules. Une contrainte de la forme Num a signifie que le type a doit faire partie de la classe de types Num, qui correspond aux nombres. On peut donc comprendre cette contrainte comme "a doit être un type numérique". On voit aussi qu'on ne peut additionner que des nombres du même type. Par exemple, il est impossible d'ajouter un Double et un Integer. On peut avoir plusieurs contraintes dans une même signature. Par exemple, le type de f x y = (x+1,y+1) est f :: (Num a, Num b) => a -> b -> (a,b).

Limitations

Quand on écrit un nombre entier dans le code source du programme, il est vu par le compilateur comme une valeur de type (Num a) => a, c'est-à-dire n'importe quel type de nombre. De même, quand on entre un nombre décimal, il est vu comme une valeur de type (Fractional a) => a. Cependant, pour des raisons de performances et sous certaines conditions, il peut arriver que le compilateur décide d'utiliser un type moins polymorphe que prévu. Par exemple, avec le fichier suivant :

1
2
entier = 13
decimal = 2.5
1
2
3
4
Prelude> :t entier
Integer
Prelude> :t decimal
Double

On voit que dans ce cas-là, le type est plus restreint que prévu. Cela pose des problèmes, par exemple une erreur de type quand on tente de multiplier les deux nombres. Il est possible de forcer le compilateur à donner un type polymorphe en indiquant soi-même le type :

1
2
3
4
5
entier :: (Num a) => a
entier = 13

decimal :: (Fractional a) => a
decimal = 2.5

Vous pouvez aussi obtenir des erreurs du type :

1
2
3
4
5
6
7
../haskell/Test.hs:8:7:
    Ambiguous type variable `a' in the constraint:
      `Eq a' arising from a use of `==' at ../haskell/Test.hs:8:7-10
    Possible cause: the monomorphism restriction applied to the following:
      egal :: a -> a -> Bool (bound at ../haskell/Test.hs:8:0)
    Probable fix: give these definition(s) an explicit type signature
                  or use -XNoMonomorphismRestriction

Dans ce cas, il suffit de donner un type à la variable qui pose problème pour résoudre le problème.

Classes de types les plus courantes

Maintenant, il est temps de voir les classes de types définies dans le Prelude. Je ne décrirais pour chaque classe que quelques fonctions utiles, ou seulement ce qu'elle représente. Pour plus d'information sur une classe donnée, reportez-vous à la documentation du Prelude. Ne vous sentez pas obligés de tout connaitre par coeur : vous pourrez revenir à cette partie du tuto ou lire la documentation plus tard. Les principales classes à retenir sont Num, Fractional, Eq et Ord.

Commençons par les classes numériques. Elles forment une hiérarchie de classes assez compliqué. La classe Num fournit les opérations mathématiques de base : l'addition, la soustraction et la multiplication. Elle fournit aussi une fonction fromInteger :: (Num a) => Integer -> a, qui permet de transformer tout nombre entier en n'importe quel autre type de nombre. La classe Real représente les types qui sont un sous-ensemble de nombres rationnels. Elle permet d'utiliser la fonction toRational :: (Real a) => a -> Rational, qui permet de transformer un nombre en nombre ratinonel. Il doivent aussi pouvoir être ordonnés. La classe Integral correspond aux nombres entiers. Par exemple, les types Int (entiers à nombre de chiffre limité) et Integer (entiers aussi grands qu'on veut) sont tous les deux des instances de cette classe. Les opérations intéressantes sont div et mod, qui permettent de trouver respectivement le quotient et le reste de la division euclidienne d'un nombre par un autre, et les opérations gcd et lcm qui permettent de trouver respectivement le PGCD et le PPCM de deux nombres. Il y a aussi une opération toInteger :: (Integral a) => a -> Integer qui permet de transformer n'importe quel nombre entier en Integer. La classe Fractional permet d'utiliser la division. Floating rajoute toutes les opérations trigonométriques, l'exponentielle et les logarithmes. La classe RealFrac intègre les opérations d'arrondi vers le haut et vers le bas.

Pour les autres classes, c'est plus simple : La classe Eq est la classe des objets dont on peut déterminer s'ils sont égaux ou pas. Elle permet d'utiliser les fonctions == et /=. La classe Ord correspond aux types dont on peut comparer les éléments. Elle fournit les fonctions de comparaison habituelles. Enfin, la classe Enum correspond aux types dont on peut énumérer les éléments, et permet par exemple d'utiliser la notation de séquences. Par exemple, les entiers et les caractères font partie de cette classe, donc on peut écrire [1..10] et ['a'..'z'].

Enfin, deux classes . La classe Show fournit une fonction show :: (Show a) => a -> String. Elle permet de convertir une valeur en chaine de caractères, par exemple pour l'afficher. Les valeurs sont représentées sous une forme qui peut normalement être utilisées dans du code Haskell. Par exemple :

1
2
3
4
Prelude> show 42
"42"
Prelude> show [1,2,3]
"[1,2,3]"

La fonction read, de la classe de types Read fait l'inverse : elle transforme une chaine de caractère en la valeur qu'elle représente. Cependant, le type n'est pas déterminé dynamiquement en fonction de ce qui est lu, mais à la compilation. Si on veut tester cette fonction dans ghci, il ne sait pas quel type de données on attend, il faut donc le préciser (cela n'est pas nécessaire en général, puisque le type de la valeur est déterminé suivant le contexte dans lequel on l'utilise). Pour cela, on utilise la notation ::

1
2
3
4
5
6
Prelude> read "42" :: Int
42
Prelude> read "[1,2,3]" :: [Int]
[1,2,3]
Prelude> read "[1,2,3]" :: Int
*** Exception: Prelude.read: no parse

Si read n'arrive pas à lire correctement la valeur, il renvoie une erreur.


Maintenant, vous devriez être capable de comprendre une erreur de type. Ce chapitre ne vous permet pas de coder beaucoup de choses nouvelles, mais plutôt de savoir ce que vous faites. Le chapitre suivant va vous présenter la récursivité, une technique très puissante qui permet de coder beaucoup de choses que vous ne pouvez pas faire jusqu'à maintenant.