Greboca  

DLFP - Dépêches  -  GHC 8.8, 8.10 et 9.0

 -  11 mars - 

GHC (Glasgow Haskell Compiler) 8.8 est sorti le 26 août 2019. GHC 8.10 est sorti le 24 mars 2020. GHC 9.0 vient de sortir, le 4 février 2021.

C’est donc l’occasion de revenir sur les changements de ces versions du principal compilateur pour le langage fonctionnel Haskell. Avec 3 versions majeures sur plus de 2 ans, tout ne peut pas rentrer dans une dépêche. De plus, beaucoup de détails ne sont pas forcément passionnants ou adaptés à un public non expert en compilation, c’est pourquoi cette dépêche se focalise sur les exemples que nous avons jugés intéressants, autant pour les nouveautés que pour l’opportunité de présenter des points saillants du langage.

Sommaire

Note sur les extensions : Dans cette dépêche nous parlons souvent d’extension de GHC. Celles-ci s’activent en haut de vos fichiers Haskell grâce au pragma LANGUAGE, de la façon suivante :

{-# LANGUAGE TypeApplications #-}
{-# LANGUAGE Strict #-}
{-# LANGUAGE DataKinds #-}
{-# LANGUAGE BinaryLiterals #-}

En effet, GHC, par défaut, est compatible avec le Haskell report 2010. Toute modification du langage se fait par le biais d’extensions qu’il est possible d’activer (ou de désactiver).

Application de kind

GHC 8.8 étend la syntaxe d’application de type aux kinds suite à la proposition GHC Proposal #15. Cette section fait quelques rappels sur l’inférence de type et sur l’application de type, pour ensuite présenter l’application de kind.

Grâce à l’inférence de type, il est rarement nécessaire de préciser les types des expressions que nous manipulons en Haskell. Cependant certains cas peuvent être ambigus :

foo :: String -> String
foo x = show (read x)

Ici, la fonction foo accepte un argument x qui sera ensuite passé à la fonction read puis à show. Sans rentrer dans les détails, sachez que read peut convertir une chaîne de caractères en n’importe quel type et que show peut convertir n’importe quel type en chaîne de caractères.

Ainsi il est évident que x est une chaîne de caractères et que le résultat de foo est aussi une chaîne de caractères. Mais quel est le type intermédiaire, renvoyé par read et lu par show ?

Dans ces rares cas ambigus, il est possible de préciser le type intermédiaire grâce à une annotation de type ou grâce à la syntaxe de TypeApplication de la manière suivante :

-- Annotation de type
foo x = show (read x :: Bool)

-- TypeApplication
foo x = show @Bool (read x)

Dans ce contexte, :: Bool ou @Bool précisent que l’argument de show sera un booléen. read va donc devoir produire un Bool.

TypeApplication devient plus confortable que l’annotation de type quand la fonction travail sur un type polymorphique plus complexe. Par exemple, la fonction try suivante :

try :: Exception e => IO a -> IO (Either e a)

Est utilisé pour la gestion d’exception. Le type e est important, car il détermine l’exception qui sera levée. Cependant, cela commence à devenir verbeux d’utiliser une annotation de type, voir les différents exemples :

-- Exemple 1 : annotation sur `try`
res <- (try :: IO String -> IO (Either SomeException String)) (readFile fileName)
case res of
   Left e -> print "Error"
   Right s -> print "Ok"
-- Exemple 2 : annotation sur le retour de `try`
res <- (try (readFile fileName)) :: IO (Either SomeException String)
case res of
   Left e -> print "Error"
   Right s -> print "Ok"
-- Exemple 3 : annotation sur le binding de res
res :: Either SomeException String <- try (readFile fileName)
case res of
   Left e -> print "Error"
   Right s -> print "Ok"
-- Exemple 4 : annotation sur le binding de e
res <- try (readFile fileName)
case res of
   Left (e :: SomeException) -> print "Error"
   Right s -> print "Ok"

On note que l’annotation de type est nécessaire parce qu’il n’est pas fait d’usage de e, ainsi le compilateur ne peut pas inférer le type.

Toutes ces solutions sont verbeuses ou se produisent loin du site d’appel. Grace à TypeApplication, on obtient une syntaxe plus compacte :

res <- try @SomeException (readFile fileName)
case res of
   Left e -> print "Error"
   Right s -> print "Ok"

Note : Dans ce cas, nous sommes un peu chanceux. try est polymorphique sur deux types, e et a, comme montré dans sa signature. La syntaxe try @SomeException permet de préciser le premier type, ici e. Mais si nous avions voulu préciser le second, il aurait fallu utiliser un wildcard, try @_ @String ou être exhaustif, try @SomeException @String. L’ordre de déclaration des types polymorphiques d’une fonction n’est pas arbitraire et a donc un impact sur l’interface publique des fonctions.

C’est assez rare d’avoir besoin de cette syntaxe pour lever des cas ambigus, mais certaines bibliothèques qui exploitent dans ses limites le système de type en ont besoin.

GHC 8.8 permet maintenant de réaliser le même type d’annotations, cependant au niveau du kind, c’est-à-dire le type d’un type.

Par exemple, la fonction Just :: t -> Maybe t peut utiliser TypeApplication tel que Just @Int soit de type Int -> Maybe Int. De manière similaire, la fonction sur les types 'Just :: k -> Maybe k peut utiliser TypeApplication tel que 'Just @Nat soit de kind Nat -> Maybe Nat.

Amélioration du test d’exhaustivité de l’analyse de cas

Haskell permet le « pattern matching », ou l’analyse de cas. C’est une version améliorée du switch bien connu en C, car elle permet de déconstruire des types en profondeur.

Prenons un exemple avec le type Couleur :

data Couleur = Verte | Rouge | Bleue | Gris Float

Ici, le type couleur peut prendre plusieurs valeurs, simples, comme Verte, Rouge ou Bleue, ou plus complexe comme Gris 0.5 qui pourrait representer un gris « moyen ».

Nous pouvons ensuite réaliser une analyse de cas sur cette valeur :

case uneCouleur of
  Verte -> "Les petits hommes verts"
  Rouge -> "Meurs pourriture communiste"
  Bleue -> "Le bleu du ciel n'est pas le bleu de la mer"
  Gris 0.5 -> "J'aime cette nuance de gris"

Le compilateur GHC est capable de produire des warnings dans les cas suivants :

  • Si un cas n’est pas traité, ce qui pourrait générer une erreur lors de l’exécution. On dit alors que le code n’est pas « total » ou « exhaustif ».
  • Si un cas est traité de façon redondante. Dans ce cas, il y a du code « mort ».

Cependant, cette analyse n’est pas triviale et dans certains cas GHC peut se tromper et réaliser des faux négatifs / positifs.

GHC 8.10 et 9.0 améliorent la situation dans deux cas :

  • Les cas impossibles
  • La composition de case.

Cas impossibles

Nous allons imaginer un type permettant de représenter la présence ou non d’une annotation :

data Annotation t = Vide | Note !t

Ici, le type Annotation permet deux cas, Vide, qui représente l’absence d’annotation, et Note x, qui représente une annotation. Le type est polymorphique, ainsi on peut stocker n’importe quoi dedans, comme un Double ou une String, par exemple Note 10.2 ou Note "Bonjour".

Digression. Le lecteur attentif aura remarqué la présence d’un ! dans la définition du cas Note. Ce symbole, appelé Bang, force l’évaluation de la valeur contenue dans Note. Malheureusement une valeur paresseuse ici rendrait l’exemple faux pour un lecteur très attentif.

Ainsi, on va pouvoir écrire une fonction telle que :

foo :: Annotation t -> String
foo Vide = "L'annotation est vide"
foo (Note _) = "L'annotation est pleine"

On note aussi ici que l’analyse de cas en Haskell se fait autant avec un case que lors de la déclaration d’une fonction.

Ne pas traiter l’un des deux cas revient à écrire une fonction « partielle » et GHC va générer un message.

Cependant, il existe un type en Haskell qui s’appelle Void. Celui-ci est surprenant car bien que le type existe, il n’est pas possible de construire une valeur de ce type. Ainsi, le type Annotation Void n’admet qu’un unique cas, Vide. Ainsi, une fonction traitant une Annotation Void sera complète en ne traitant que le cas Vide, tel que :

bar :: Annotation Void -> String
bar Vide = "C'est vide"

Cette fonction n’est pas très intéressante, mais elle est totale. Tous les cas sont traités, car il n’est pas possible de construire un cas Note x car x serait de type Void et il n’est pas possible de construire une valeur de type Void quand il est strict, d’où la présence du symbol ! dans la définition de Note.

Digression 2. Plus exactement, tout type non strict en Haskell admet une valeur particulière, nommée /bottom/ qui représente un calcul qui ne peut pas se terminer, comme let x = x + 1 in x. Ainsi, il existe une valeur possible pour Void, mais celle-ci représente un calcul qui ne se termine pas. L’annotation ! permet de dire au compilateur que la valeur contenue dans Note doit être forcée. Comme il n’est pas possible de forcer /bottom/, alors cela veut dire qu’il n’existe pas de valeur possible pour Void.

Avant GHC 8.10, celui-ci ne réalisait pas que cette fonction était totale et générait un message inadapté. C’est maintenant réglé.

Exhaustivité profonde

Reprenons une analyse de cas sur notre type Couleur :

case c of
   Rouge -> "Rouge"
   Bleue -> "Bleue"
   Grey 0 -> "Noire"
   Grey 1 -> "Blanche"
   _ -> case c of 
      Verte -> "Verte"
      Grey _ -> "C'est du gris"

Cette fonction est totale, car tous les cas sont bien gérés par la combinaison des deux case. Mais si on regarde localement, on voit que si le premier case gère tous les cas, le second ne traite que Verte et Grey _, sans traiter Rouge ni Bleue. Cela reste cependant correct puisque ces cas sont traités avant.

Avant GHC 9.0, ce cas donnait lieu à un message plutôt agaçant. Le code était correct, mais GHC ne s’en rendait pas compte. GHC 9.0 a revu en profondeur l’algorithme chargé de vérifier l’exhaustivité de l’analyse de cas, rendant celle-ci plus rapide mais surtout, le nouvel algorithme ne se limite plus à une analyse locale et produit donc des messages plus pertinents.

ImportQualifiedPost

Une nouvelle extension fait son apparition dans GHC 8.10. ImportQualifiedPost permet maintenant de faire des import qualifiés avec une nouvelle syntaxe, comparons :

import qualified Foo

Avec :

{-# LANGUAGE ImportQualifiedPost #-}

import Foo qualified

J’avais personnellement fait une proposition que j’estime bien plus ambitieuse sur la syntaxe d’import, mais celle-ci n’aura pas été retenue.

:instances

Une nouvelle commande pour GHCi 8.10, :instances, qui permet de lister les instances de classe disponibles pour un type. C’est assez pratique pour comprendre ce qui est possible avec un type :

> :instances Int
instance Eq Int -- Defined in ‘GHC.Classes’
instance Ord Int -- Defined in ‘GHC.Classes’
instance Enum Int -- Defined in ‘GHC.Enum’
instance Num Int -- Defined in ‘GHC.Num’
instance Real Int -- Defined in ‘GHC.Real’
instance Show Int -- Defined in ‘GHC.Show’
instance Read Int -- Defined in ‘GHC.Read’
instance Bounded Int -- Defined in ‘GHC.Enum’
instance Integral Int -- Defined in ‘GHC.Real’

Ici nous pouvons voir que le type Int est instance de nombreuses classes (ou « interfaces »), incluant la comparaison (Eq), la relation d’ordre (Ord), l’énumération (Enum)…

Nouveau GC à faible latence

Haskell est un langage avec un ramasse-miettes (GC). Cela signifie que les allocations et la libération de la mémoire se font automatiquement. Dans le cas de GHC, le ramasse-miettes va « stop the world » lors de son exécution. Pour simplifier, disons que le programme Haskell va s’arrêter le temps de laisser au ramasse-miettes le temps de faire son travail.

Le design actuel du GC dans GHC cherchait à minimiser le temps passé dans le GC, ce qui minimise directement le temps d’exécution du programme. C’est le bon compromis quand on s’intéresse uniquement au temps total d’exécution.

Cependant, dans un cas où l’on s’intéresse au temps de réponse de l’application, on pouvait malheureusement se retrouver avec de longues pauses à intervalles non réguliers. C’est fort désagréable dans des applications temps réel. Imaginez un jeu vidéo qui tourne très bien (disons 100 images par seconde) mais qui toutes les minutes, fait une pause d’une seconde.

Une alternative proposée depuis GHC 8.10 est de ne pas toujours arrêter les threads d’exécution (appelés mutators) et dans certains cas d’exécuter le ramasse-miettes en parallèle. Cette solution est moins efficace en termes de performances brutes, car elle se fait au prix de synchronisations coûteuses entre le GC et l’exécution, mais elle a le bénéfice de ne plus provoquer de pause longue et imprédictible lors de l’exécution. Pour reprendre la métaphore du jeu vidéo, celui-ci tourne maintenant à 90 images par secondes, mais ne fait plus de pause.

Je vous invite à lire cet article sur le GC qui détaille le travail réalisé.

Interface pour les bigs nums

GHC propose des types entiers de taille infinie, les types Integer et Natural, respectivement signé et non signé. Derrière se cache la célèbre bibliothèque gmp. Cela permet par exemple de calculer des factorielles :

>>> product [1..100]
93326215443944152681699238856266700490715968264381621468592963895217599993229915608941463976156518286253697920827223758251185210916864000000000000000000000000

GHC 9.0 propose maintenant une interface générique au-dessus de gmp et une bibliothèque écrite en Haskell native. Cette dernière ne rivalise pas avec gmp en termes de performance, mais se comporte mieux que l’ancienne implémentation alternative integer-simple.

On peut voir plusieurs raisons à l’usage d’une implémentation alternative, comme la licence, ou tout simplement, l’utilisation d’une bibliothèque écrite en pur Haskell va simplifier la génération de code pour des cibles alternatives comme le javascript.

LexicalNotation

Le moins (i.e. -) est un opérateur qui peut être à la fois binaire, a - b, et unaire, -x. Cela pose de nombreux problèmes en termes de syntaxe, dans les parseurs.

En Haskell, comment se lit f -3 ? Est-ce la fonction f appliquée a -3, comme on pourrait écrire f(-3) dans d’autres langages. Ou est-ce la valeur f à qui on soustrait 3? Dans ce contexte c’est la seconde solution.

Mais ce n’est pas tout. En Haskell nous avons un raccourci de syntaxe, les « sections ». Tout opérateur binaire peut être « pré-appliqué ». Par exemple (2+) est équivalent à une fonction qui ajoute 2. De façon similaire, (/pi) est une fonction qui divise par pi. La position de l’argument à droite ou à gauche de l’opérateur ayant un sens, (2^) et (^2) représentant respectivement une puissance de deux et le carré. Mais que signifie (-1)? Est-ce l’opérateur (-) pré appliqué avec 1 en argument droit, ou est-ce le nombre négatif -1 ?

Avant GHC 9.0, (-1) était de façon inconditionnelle équivalent au nombre négatif -1. Depuis GHC 9.0, et avec l’extension LexicalNegation, (-1) représente le nombre négatif, et (- 1) (notez l’espace), représente la fonction qui soustrait un.

C’est un changement qui peut sembler anodin et tout simple, mais il est assez évocateur de la difficulté d’avoir une syntaxe avancée et cohérente.

LinearTypes

Les types linéaires, ou plutôt les fonctions linéaires, sont un nouvel outil du système de type qui permet d’exprimer de nouvelles contraintes. Dans cette section je me contenterai d’exemple « haut niveau » d’utilisation, sans entrer dans les détails de syntaxe et d’usage. Les curieux peuvent aller consulter la Proposal sur les types linéaires qui contient de nombreux détails.

De façon simplifiée, une fonction linéaire garantit que ses arguments sont utilisés une fois. Ils ne peuvent pas être utilisés plus d’une fois et ils ne peuvent pas ne pas être utilisés.

Détaillons avec un exemple :

titi x y = let
   a = foo x
   b = biz x
   in a + b

Dans cette fonction:

  • x est utilisé deux fois
  • y n’est pas utilisé.
  • a et b sont utilisés une unique fois.

Ainsi, a et b peuvent satisfaire les contraintes de fonctions linéaires.

GHC 9.0 permet d’exprimer ce type de contrainte dans le système de type et d’ainsi empêcher la compilation si le code écrit ne respecte pas ces contraintes. Par exemple, comparons les fonctions suivantes :

identity :: a -> a
identity v = v

identityLinear :: a %1-> a
identityLinear v = v

identityLinear est la version « linéaire » de identity. Observez comment la syntaxe de la flèche -> change pour devenir %1->.

Ces contraintes sont particulièrement utiles pour de la gestion de ressources. En s’assurant qu’un objet est forcément utilisé une fois, on peut s’assurer qu’une ressource sera correctement détruite. De plus on peut garantir qu’il n’y a pas plusieurs utilisations de la ressource.

D’ailleurs, le système de type de rust utilise une méthode proche des types linéaires pour suivre la durée de vie de ses variables et fournir des garanties similaires. Savoir qu’un objet ne sera jamais réutilisé peut aussi servir à des fins d’optimisation, en réutilisant par exemple les ressources allouées par celui-ci. C’est un processus similaire qui est utilisée en C++ avec la move semantic.

Une dépêche en cours de rédaction se concentre uniquement sur un exemple d’utilisation des types linéaires pour la création d’une bibliothèque robuste de gestion de socket, basé sur l’exemple de Tweag sur les types linéaires et les sockets.

QualifiedDo

La syntaxe do en Haskell permet d’ordonner des calculs et d’introduire une relation de dépendance. L’exemple le plus abordable concerne la réalisation d’entrées sorties :

do
  putStrLn "Quel est votre prénom ?"
  prénom <- getString
  putStrLn ("Bonjour " ++ prénom)

Ici, la syntaxe de do permet d’exprimer que ces opérations seront réalisées dans l’ordre et met en évidence la dépendance entre ces instructions. do n’est en fait qu’un sucre syntaxique au-dessus des opérateurs (>>) et (>>=), le code suivant étant parfaitement équivalent au bloc précédant :

putStrLn "Quel est votre prénom ?"
  >> (
    getString >>= (\prénom ->
       putStrLn ("Bonjour " ++ prénom))

La syntaxe do est cependant générique et s’adapte à nombreuses opérations pour qui l’ordre et la notion de dépendance sont importants, tels que des listes, des calculs pouvant échouer, des parseurs, des calculs parallèles, des entrées sorties, de la gestion de configuration, du logging… do s’adapte en fait à tous les types qui sont instances (i.e. implémente l’interface) de Monad.

Cependant, l’interface de Monad peut être trop restrictive. Il existe de nombreuses variations autour de celles-ci, comme les monades linéaires, qui sont utilisées en conjugaison avec les types linéaires décrits plus haut.

Pour ces cas, l’extension RebindableSyntax permet de redéfinir le fonctionnement de do. Mais cette extension est peu pratique, implique de nombreux effets non désirés et est trop globale puisqu’elle entraîne une surcharge de toute la syntaxe d’Haskell.

GHC 9.0 apporte le support pour QualifiedDo qui permet de qualifier l’opérateur do et d’utiliser une définition des opérateurs (>>=) et (>>) présente dans un module. Au lieu d’écrire do, on peut écrire M.doM est un module qui contient les définitions nécessaires à la surcharge de do.

Cette approche, contrairement a RebindableSyntax est limitée au do et bien plus pratique. Nécessaire avec l’arrivée des types linéaires qui en abusent, on peut imaginer que cette extension va démocratiser l’utilisation de monad exotiques.

Haskell Language Server

Haskell Language Server est une implémentation du Language Server Protocol pour Haskell.

Il y a eu beaucoup de changements dans ce domaine depuis quelques mois et la sortie récente du HLS 1.0.0.0 nous permet maintenant de profiter d’une intégration d’Haskell avec les éditeurs de texte qui n’a plus à rougir vis-à-vis d’autres langages.

Conclusion

GHC évolue, et c’est bien, pour qui s’intéresse à Haskell. Il est cependant assez difficile de vendre bon nombre de changements car ceux-ci sont souvent internes (comme le nouveau GC, des changements dans la génération de code…), ou alors touchent des points très avancés du système de type. Certains changements comme les types linéaires ne peuvent pas être résumés en quelques mots dans une dépêche.

Ce que j’attends du futur d’Haskell, et donc de GHC, se situe principalement au niveau de l’environnement, des outils. Je suis très satisfait de voir les évolutions actuelles sur le Haskell langage serveur. J’attends dans les années à venir un travail sur le debug et les performances. Voir à ce sujet le travail de Well-Typed sur un debugueur d’allocation mémoire.

GHC 2021 pourrait voir le jour sous la forme d’une liste d’extensions de GHC permettant ainsi de réduire la longue (très longue) liste d’extensions à activer en haut de chaque fichier. La Record Dot Syntax pourrait permettre de rendre le langage plus accessible en proposant une solution à une discussion longue comme le langage concernant l’usage des records.

GHC 9.2 est prévu pour « bientôt », les notes de version présumées sont déjà disponibles.

Cette dépêche est loin d’être complète, alors n’hésitez pas à utiliser l’espace commentaires pour demander / apporter des précisions, comparer à votre langage préféré ou tout simplement dire bonjour.

Commentaires : voir le flux Atom ouvrir dans le navigateur

par Guillaum, Yves Bourguignon, nokomprendo, Anthony Jaguenaud, Benoît Sibaud, Snark, tisaac, enikar, BAud, Nils Ratusznik, palm123, olivierweb

DLFP - Dépêches

LinuxFr.org

Communiquer avec le serveur depuis un navigateur Web : XHR, SSE et WebSockets

 -  19 avril - 

Dans cette dépêche, nous allons faire un tour d’horizon de différentes manières de communiquer avec un serveur depuis une application Web, avec un (...)


GnuPG 2.3.0 est sorti

 -  16 avril - 

Le 7 avril 2021, le projet GnuPG a publié la première version officielle de sa nouvelle branche de développement, GnuPG 2.3.0. Les nouveautés de (...)


Hotspot, à la recherche du point chaud…

 -  14 avril - 

Depuis maintenant quelques semaines, j’ai repris les contributions au projet Calligra, et plus particulièrement au traitement de texte (cf ce (...)


La lettre d'information XMPP de mars 2021

 -  13 avril - 

N. D. T. — Ceci est une traduction de la lettre d’information publiée régulièrement par l’équipe de communication de la XSF, essayant de conserver les (...)


Résultats du concours Wiki Loves Monuments 2020

 -  25 mars - 

Le 19 mars 2021, les gagnants du concours 2020 de Wiki Loves Monuments ont été dévoilés ! lien nᵒ 1 : Annonce officiellelien nᵒ 2 : Site international (...)