Les types, encore une fois
Nous examinons ici certains des aspects les plus avancés des déclarations de type.
La déclaration Newtype
Une pratique courante de programmation consiste à définir un type dont la représentation est identique à celle d'un type existant mais possédant une identité distincte dans le système de types. En Haskell, la déclaration newtype crée un nouveau type à partir d'un type existant. Par exemple, les nombres naturels peuvent être représentés par le type Integer en utilisant la déclaration suivante :
- newtype Natural = MakeNatural Integer
Cela crée un tout nouveau type, Natural, dont le seul constructeur contient un seul entier. Le constructeur MakeNatural convertit entre un type Natural et un type Integer :
- toNatural :: Integer -> Natural
- toNatural x | x < 0 = error "On ne peut pas créer de naturels négatifs !"
- | otherwise = MakeNatural x
-
- fromNatural :: Natural -> Integer
- fromNatural (MakeNatural i) = i
La déclaration d'instance suivante admet Natural dans la classe Num :
- instance Num Natural where
- fromInteger = toNatural
- x + y = toNatural (fromNatural x + fromNatural y)
- x - y = let r = fromNatural x - fromNatural y in
- if r < 0 then error "Soustraction non naturelle"
- else toNatural r
- x * y = toNatural (fromNatural x * fromNatural y)
Sans cette déclaration, Natural ne serait pas dans Num. Les instances déclarées pour l'ancien type ne sont pas transférées vers le nouveau. En effet, l'objectif de ce type est d'introduire une instance Num différente. Cela ne serait pas possible si Natural était défini comme un type synonyme de Integer.
Tout cela fonctionne en utilisant une déclaration de données au lieu d'une déclaration de nouveau type. Cependant, la déclaration de données entraîne une surcharge supplémentaire dans la représentation des valeurs de Natural. L'utilisation de newtype évite le niveau supplémentaire d'indirection (provoqué par la paresse) que la déclaration de données introduirait. Voir la page Expressions de cas et correspondance de modèles du rapport pour une discussion plus approfondie de la relation entre newtype, data et les déclarations de type. [À l'exception du mot-clef, la déclaration newtype utilise la même syntaxe qu'une déclaration de données avec un seul constructeur contenant un seul champ. Cela est approprié puisque les types définis à l'aide de newtype sont presque identiques à ceux créés par une déclaration de données ordinaire.]
Étiquettes de champ
Les champs d'un type de données Haskell sont accessibles soit par position, soit par nom à l'aide d'étiquettes de champ. Considérons un type de données pour un point bidimensionnel :
- data Point = Pt Float Float
Les deux composantes d'un Point sont les premier et deuxième paramètre du constructeur Pt. Une fonction telle que :
- pointx :: Point -> Float
- pointx (Pt x _) = x
peut être utilisé pour faire référence à la première composante d'un point d'une manière plus descriptive, mais, pour les grandes structures, il devient fastidieux de créer de telles fonctions à la main.
Les constructeurs dans une déclaration de données peuvent être déclarés avec des noms de champs associés, placés entre accolades. Ces noms de champs identifient les composantes du constructeur par leur nom plutôt que par leur position. Il s'agit d'une autre façon de définir Point :
- data Point = Pt {pointx, pointy :: Float}
Ce type de données est identique à la définition précédente de Point. Le constructeur Pt est le même dans les deux cas. Cependant, cette déclaration définit également deux noms de champs, pointx et pointy. Ces noms de champs peuvent être utilisés comme fonctions de sélection pour extraire un composant d'une structure. Dans cet exemple, les sélecteurs sont :
- pointx :: Point -> Float
- pointy :: Point -> Float
Il s'agit d'une fonction utilisant ces sélecteurs :
- absPoint :: Point -> Float
- absPoint p = sqrt (pointx p * pointx p +
- pointy p * pointy p)
Les étiquettes de champ peuvent également être utilisées pour construire de nouvelles valeurs. L'expression Pt {pointx=1, pointy=2} est identique à Pt 1 2. L'utilisation de noms de champ dans la déclaration d'un constructeur de données n'exclut pas le style positionnel de l'accès aux champs ; Pt {pointx=1, pointy=2} et Pt 1 2 sont tous deux autorisés. Lors de la construction d'une valeur à l'aide de noms de champ, certains champs peuvent être omis ; ces champs absents ne sont pas définis.
La correspondance de motifs à l'aide de noms de champ utilise une syntaxe similaire pour le constructeur Pt&nbps;:
- absPoint (Pt {pointx = x, pointy = y}) = sqrt (x*x + y*y)
Une fonction de mise à jour utilise les valeurs de champ d'une structure existante pour remplir les composantes d'une nouvelle structure. Si p est un Point, alors p {pointx=2} est un point avec le même pointy que p mais avec pointx remplacé par 2. Il ne s'agit pas d'une mise à jour destructive : la fonction de mise à jour crée simplement une nouvelle copie de l'objet, remplissant les champs spécifiés avec de nouvelles valeurs.
[Les accolades utilisées en conjonction avec les libellés de champ sont quelque peu spéciales : la syntaxe Haskell permet généralement d'omettre les accolades en utilisant la règle de mise en page. Cependant, les accolades associées aux noms de champ doivent être explicites.]
Les noms de champ ne sont pas limités aux types avec un seul constructeur (communément appelés types «record»). Dans un type avec plusieurs constructeurs, les opérations de sélection ou de mise à jour utilisant des noms de champ peuvent échouer à l'exécution. Ceci est similaire au comportement de la fonction head lorsqu'elle est appliquée à une liste vide.
Les libellés de champ partagent l'espace de noms de niveau supérieur avec les variables ordinaires et les méthodes de classe. Un nom de champ ne peut pas être utilisé dans plus d'un type de données dans la portée. Cependant, dans un type de données, le même nom de champ peut être utilisé dans plusieurs constructeurs à condition qu'il ait le même typage dans tous les cas. Par exemple, dans ce type de données :
- data T = C1 {f :: Int, g :: Float}
- | C2 {f :: Int, h :: Bool}
le nom de champ f s'applique aux deux constructeurs de T. Ainsi, si x est de type T, alors x {f=5} fonctionnera pour les valeurs créées par l'un ou l'autre des constructeurs de T.
Les noms de champ ne changent pas la nature de base d'un type de données algébrique; ils constituent simplement une syntaxe pratique pour accéder aux composantes d'une structure de données par nom plutôt que par position. Ils rendent les constructeurs avec de nombreuses composantes plus faciles à gérer puisque les champs peuvent être ajoutés ou supprimés sans modifier chaque référence au constructeur. Pour plus de détails sur les étiquettes de champ et leur sémantique.
Constructeurs de données stricts
Les structures de données dans Haskell sont généralement paresseuses : les composantes ne sont pas évalués tant qu'ils ne sont pas nécessaires. Cela permet d'utiliser des structures contenant des éléments qui, s'ils étaient évalués, entraîneraient une erreur ou ne se termineraient pas. Les structures de données paresseuses améliorent l'expressivité de Haskell et constituent un aspect essentiel du style de programmation Haskell.
En interne, chaque champ d'un objet de données paresseux est enveloppé dans une structure communément appelée thunk encapsulant le calcul définissant la valeur du champ. Ce thunk n'est pas saisi tant que la valeur n'est pas nécessaire ; les thunks contenant des erreurs (_|_) n'affectent pas les autres éléments d'une structure de données. Par exemple, le tuple ('a',_|_) est une valeur Haskell parfaitement légale. Le 'a' peut être utilisé sans perturber l'autre composante du tuple. La plupart des langages de programmation sont stricts au lieu d'être paresseux : c'est-à-dire que tous les composantes d'une structure de données sont réduits à des valeurs avant d'être placés dans la structure.
Il existe un certain nombre de frais généraux associés aux thunks : ils prennent du temps à construire et à évaluer, ils occupent de l'espace dans le tas et ils obligent le ramasse-miettes à conserver d'autres structures nécessaires à l'évaluation du thunk. Pour éviter ces frais généraux, les drapeaux de rigueur dans les déclarations de données permettent d'évaluer immédiatement des champs spécifiques d'un constructeur, supprimant de manière sélective la paresse. Un champ marqué par «!» dans une déclaration de données, elle est évaluée lorsque la structure est créée au lieu d'être retardée dans un thunk. Il existe un certain nombre de situations dans lesquelles il peut être approprié d'utiliser des drapeaux de rigueur :
- Composantes de structure étant sûrs d'être évalués à un moment donné pendant l'exécution du programme.
- Composantes de structure étant simples à évaluer et ne provoquent jamais d'erreurs.
- Types dans lesquels les valeurs partiellement indéfinies n'ont pas de sens.
Par exemple, la bibliothèque de nombres complexes définit le type Complexe comme :
- data RealFloat a => Complex a = !a :+ !a
[notez la définition infixe du constructeur :+.] Cette définition marque les deux composants, les parties réelle et imaginaire, du nombre complexe comme étant stricts. Il s'agit d'une représentation plus compacte des nombres complexes, mais cela se fait au détriment de rendre un nombre complexe avec une composante indéfini, 1 :+ _|_ par exemple, totalement indéfini (_|_). Comme il n'y a pas vraiment besoin de nombres complexes partiellement définis, il est logique d'utiliser des drapeaux de rigueur pour obtenir une représentation plus efficace.
Les drapeaux de rigueur peuvent être utilisés pour traiter les fuites de mémoire : structures conservées par le ramasse-miettes mais n'étant plus nécessaires au calcul.
Le drapeau de rigueur, «!», ne peut apparaître que dans les déclarations de données. Il ne peut pas être utilisé dans d'autres signatures de type ou dans d'autres définitions de type. Il n'existe aucun moyen correspondant de marquer les paramètres de fonction comme étant stricts, bien que le même effet puisse être obtenu en utilisant les fonctions seq ou !$.
Il est difficile de présenter des directives exactes pour l'utilisation des drapeaux de rigueur. Ils doivent être utilisés avec prudence : la paresse est l'une des propriétés fondamentales de Haskell et l'ajout d'indicateurs de rigueur peut conduire à des boucles infinies difficiles à trouver ou avoir d'autres conséquences inattendues.