IdentifiantMot de passe
Loading...
Mot de passe oublié ?Je m'inscris ! (gratuit)

A Gentle Introduction to Haskell, version 98


précédentsommairesuivant

VI. Les types, encore

Ici nous examinons quelques-uns des aspects les plus avancés des déclarations de type.

VI-A. La déclaration newtype

Une pratique commune de programmation est de définir un type dont la représentation est identique à un type préexistant, mais qui a une identité différente dans le système de typage. Dans Haskell, la déclaration newtype crée un nouveau type à partir d'un type préexistant. Par exemple, les nombres naturels peuvent être représentés par le type Integer en utilisant la déclaration suivante :

 
Sélectionnez
newtype Natural = MakeNatural Integer

Ceci crée un type entièrement nouveau, Natural, dont l'unique constructeur contient un simple Integer. Le constructeur MakeNatural opère une conversion entre un Natural et un Integer :

 
Sélectionnez
toNatural               :: Integer -> Natural
toNatural x | x < 0     = error "Erreur : ne peut créer de naturel négatif!"
            | otherwise = MakeNatural x
fromNatural             :: Natural -> Integer
fromNatural (MakeNatural i) = i

La déclaration d'instance suivante ajoute Natural à la classe Num :

 
Sélectionnez
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 inclus dans Num. Les instances déclarées pour l'ancien type ne sont pas reportées au nouveau type. En fait, le seul objectif de ce type est d'introduire une instance de Num différente ; ce qui ne serait pas possible si Natural était défini en tant que synonyme de Integer.

Cela fonctionnerait aussi en utilisant une déclaration data plutôt qu'une déclaration newtype. Cependant, la déclaration data est plus « coûteuse » pour la représentation de valeurs de type Natural. L'utilisation de newtype évite le niveau supplémentaire d'une indirection (provoquée par la paresse) qu'une déclaration data nécessiterait. Voir la section 4.2.3 du Haskell Report pour plus d'informations sur la relation entre les déclarations newtype, data, et type. [À l'exception du mot-clé, la déclaration newtype utilise la même syntaxe qu'une déclaration data avec un unique constructeur contenant un unique champ , ce qui est logique puisque les types définis avec newtype sont presque identiques à ceux définis avec une déclaration ordinaire data.]

VI-B. Les étiquettes de champs

Les champs à l'intérieur d'un type de données Haskell sont accessibles soit par position soit par nom en utilisant les « étiquettes de champs ». Considérons un type de données pour un point dans le plan :

 
Sélectionnez
data Point = Pt Float Float

Les deux composants d'un Point sont les premier et deuxième arguments du constructeur Pt. Une fonction telle que

 
Sélectionnez
pointx                  :: Point -> Float
pointx (Pt x _)         =  x

peut être utilisée pour obtenir le premier composant d'un point d'une manière plus descriptive, mais, pour de larges structures, il devient laborieux de créer de telles fonctions à la main.

Les constructeurs dans une déclaration data peuvent être déclarés avec des « étiquettes de champs », entre crochets cursifs. Ces étiquettes de champs identifient les composants d'un constructeur par leur nom plutôt que par leur position. Voici une définition alternative de Point :

 
Sélectionnez
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 :

 
Sélectionnez
pointx                  ::   Point -> Float 
pointy                  ::   Point -> Float

Voici une fonction qui utilise ces sélecteurs :

 
Sélectionnez
absPoint                :: Point -> Float
absPoint p              =  sqrt (pointx p * pointx p + 
                                 pointy p * pointy p)

Les étiquettes de champs peuvent aussi ê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 champs dans la déclaration d'un constructeur de données n'empêche pas d'accéder aux champs par leur position ; aussi bien Pt {pointx=1, pointy=2} que Pt 1 2 sont autorisés. Lorsque l'on construit une valeur en utilisant les noms de champs, certains champs peuvent être omis ; les champs absents seront indéfinis.

La correspondance de motif utilisant les noms de champs utilise une syntaxe similaire pour le constructeur Pt :

 
Sélectionnez
absPoint (Pt {pointx = x, pointy = y}) = sqrt (x*x + y*y)

Une fonction de mise à jour utilise les valeurs de champs d'une structure existante pour compléter les composants d'une nouvelle structure. Si p est un Point, alors p {pointx=2} est un point avec le même pointy que p, mais dont le pointx est remplacé par 2. Ce n'est pas une mise à jour destructive : la fonction de mise à jour crée simplement une nouvelle copie de l'objet, complétant les champs spécifiés avec de nouvelles valeurs.

[Les crochets cursifs utilisés en conjonction avec les étiquettes de champs sont un peu particuliers : la syntaxe Haskell permet, en principe, d'omettre les crochets en utilisant la règle de « mise en forme » (décrite dans la section 4.6). Cependant, les crochets cursifs associés aux noms de champs doivent être utilisés explicitement]

Les noms de champs ne sont pas restreints aux types comprenant un unique constructeur (appelés couramment types « enregistrement » [record en anglais]). Dans un type comprenant de multiples constructeurs, les opérations de sélection ou de mise à jour utilisant des noms de champs peuvent échouer à l'exécution. C'est le même comportement que la fonction head lorsqu'elle est appliquée à une liste vide.

Les étiquettes de champs partagent le même espace de nom de niveau supérieur avec les variables ordinaires et les méthodes de classe. Un nom de champs ne peut pas être utilisé dans plus d'un type de données dans son étendue. Cependant, à l'intérieur d'un type de données, le même nom de champ peut être utilisé dans plus d'un des constructeurs à condition qu'il ait le même typage dans tous les cas. Par exemple, dans ce type de données :

 
Sélectionnez
data T = C1 {f :: Int, g :: Float}
       | C2 {f :: Int, h :: Bool}

le nom de champ f s'applique à tous les constructeurs dans T. Donc, si x est de type T, alors x{f=5} fonctionnera pour les valeurs créées par l'un des deux constructeurs dans T.

Les noms de champs ne changent pas la nature élémentaire d'un type de données algébrique ; il s'agit simplement d'une syntaxe commode pour accéder aux composants d'une structure de données par nom plutôt que par position. Ils rendent les constructeurs avec beaucoup de composants plus faciles à gérer étant donné que des champs peuvent être ajoutés ou retirés sans changer toutes les références à ces constructeurs. Pour tous les détails sur les étiquettes de champs et leur sémantique, voir section §4.2.1.

VI-C. Les constructeurs stricts de données

Les structures de données dans Haskell sont généralement « paresseuses » : les composants ne sont évalués que lorsque c'est nécessaire. Cela permet d'avoir des structures contenant des éléments qui, s'ils étaient évalués, provoqueraient une erreur ou ne se termineraient jamais. Les structures paresseuses de données améliorent l'expressivité de Haskell et représentent un aspect essentiel du style de programmation Haskell.

En interne, chaque champ d'un objet paresseux de données est emballé dans une structure communément désignée sous le nom de « pensée » (traduction très libre du mot anglais « thunk ») qui encapsule le calcul informatisé définissant la valeur du champ. Haskell n'entre dans cette pensée que lorsque la valeur est requise ; les pensées qui contiennent 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 déranger les autres composants du tuple. La plupart des langages de programmation sont « stricts » plutôt que paresseux : c'est-à-dire que tous les composants d'une structure de données sont réduits à des valeurs avant d'être placés dans la structure.

Il y a beaucoup de « surcoûts » associés aux pensées : elles prennent du temps à construire et à évaluer, elles occupent de la place dans le tas, et elles causent une rétention d'autres structures nécessaires à l'évaluation de la pensée dans le ramasseur de miettes. Pour éviter ces surcoûts, les fanions de rigueur (strictness flags en anglais) dans les déclarations data permettent à des champs spécifiques de constructeurs d'être évalués immédiatement, interdisant la paresse de manière sélective. Un champ marqué avec « ! » dans une déclaration de données est évalué lorsque la structure est créée au lieu d'être mis en attente dans une pensée. Il y a beaucoup de situations dans lesquelles il est adéquat d'utiliser les fanions de rigueur :

  • les composants de structures qui devront dans tous les cas être évalués à un moment donné lors de l'exécution d'un programme ;
  • les composants de structures qui sont simples à évaluer et qui ne provoquent jamais d'erreur ;
  • les types dans lesquels des valeurs partiellement indéfinies n'ont pas de signification.

Par exemple, la bibliothèque des nombres complexes définit le type Complex comme ceci :

 
Sélectionnez
data RealFloat a => Complex a = !a :+ !a

Notez la définition infixée du constructeur :+. Cette définition marque les deux composants, les parties réelle et imaginaire du nombre complexe, comme étant stricts. C'est une représentation plus compacte des nombres complexes, mais cela a un prix : un nombre complexe avec un composant indéfini, 1 :+ « _|_ » par exemple, est totalement indéfini (« _|_ »). Étant donné qu'il n'est pas utile de définir des nombres complexes partiellement définis, il est approprié d'utiliser les fanions de rigueur pour en obtenir une représentation plus efficace.

Les fanions de rigueur peuvent être utilisés pour traiter les fuites de mémoire : les structures retenues par le ramasseur de miettes, mais qui ne sont plus nécessaires aux calculs.

Le fanion de rigueur, !, ne peut apparaître que dans les déclarations data. Il ne peut pas être utilisé pour les autres signatures de types ni dans aucune autre définition de type. Il n'existe pas de fanion de rigueur équivalent pour marquer les arguments de fonctions, bien que le même effet puisse être obtenu en utilisant les fonctions seq ou !$. Voir §4.2.1 pour plus de détails.

Il est difficile de donner des consignes exactes pour l'utilisation des fanions de rigueur. Ils devraient être utilisés avec prudence : la paresse est une propriété fondamentale de Haskell et l'ajout de fanions de rigueur peut conduire à des boucles infinies difficiles à trouver ou avoir des conséquences inattendues.


précédentsommairesuivant

Les sources présentées sur cette page sont libres de droits et vous pouvez les utiliser à votre convenance. Par contre, la page de présentation constitue une œuvre intellectuelle protégée par les droits d'auteur. Copyright © 2013 gorgonite. Aucune reproduction, même partielle, ne peut être faite de ce site ni de l'ensemble de son contenu : textes, documents, images, etc. sans l'autorisation expresse de l'auteur. Sinon vous encourez selon la loi jusqu'à trois ans de prison et jusqu'à 300 000 € de dommages et intérêts.