Journal Portage de TapTempo en Haskell

Posté par  (site web personnel) . Licence CC By‑SA.
Étiquettes :
21
1
mar.
2018

Sommaire

Bonjour à tous,

Suite aux portages de TapTempo en divers langages (Rust, Ada, Javascript, Perl (5.10), Python (2.7), bash), il fallait une version Haskell de TapTempo.

J'ai essayé de respecter scrupuleusement ce qui est décrit dans le journal d'introduction de TapTempo de mfz. Toute différence serait un bug, j'attends vos rapport de bug sur GitHub.

Le répertoire src est composé des fichiers suivants, chacun ayant sa petite particularité Haskell que je détaille dans la suite :

  • Version.hs : calcul du numéro de version, réalisé à la compilation par git, depuis le code Haskell
  • I18N.hs et messages/ : localisation, en trichant un peu sur la pureté d'Haskell
  • Model.hs : modèle de configuration, valeurs par défaut de configuration, avec des entiers au niveau du type
  • TapTempo.hs : boucle principale de TapTempo, et son type de paramètre à l'exécution au typage plus que robuste
  • Main.hs : lecture de la ligne de commande très robuste et presque automatique.

Version.hs

TapTempo sait afficher son numéro de version avec --version. Je voulais me servir de git describe qui donne un numéro de version en fonction du commit courant et des tags du dépôt. Le fichier version.hs contient une expression évaluable pendant la compilation qui demande à git ce numéro :

versionString = do
  s <- runIO (readProcess "git" ["describe"] "")
  let striped = init s
  [| striped |]

En effet, le compilateur Haskell peut effectuer lors de la compilation n'importe quel code Haskell pour générer du code Haskell… Dans ce cas, on appelle avec readProcess la commande git adéquat. init sert ici à supprimer le retour à la ligne. Au final, $(versionString) nous donnera la version du dépôt.

I18N.hs

Je n'avais JAMAIS auparavant réalisé l'internationalisation d'une application, je découvre donc. La base de donnée de message est lue lors de la compilation grace à cette ligne:

mkMessage "TapTempo" "messages/" ("en")

Voici un morceau du fichier fr.msg :

Hello: Appuyer sur la touche entrée en cadence (q pour quitter).
GoodBye: Au revoir !
Tempo bpm@Float p@Int: Tempo : #{showBpm p bpm}

Par la suite, on pourra utiliser dans le code source message MsgGoodBye pour obtenir la localisation du message correspondant.

Détails intéressants:
- La base de donnée de message peut faire appelle à des fonctions, ici showBpm se charge de mettre en forme les bpm avec la bonne précision, on pourra donc appeler dans le code message (MsgTempo 32.5 3).
- La locale courante est récupérée par la lecture de la variable d'environnement LANG dans la fonction getCurrentLocale. Ce n'est sûrement pas comme ça qu'il faut faire ;)
- getCurrentLocale est une fonction qui réalise des effets de bord (elle lit une variable d'environnement), donc je ne devrais pas pouvoir m'en servir dans une fonction pure comme message. Pour cela j'ai un peu triché en utilisant unsafePerformIO qui permet de faire croire au compilateur qu'une fonction est tout de même pure. Cette triche ici n'est pas grave car il n'y a aucune raison que la variable d'environnement change en cours d'exécution du programme, donc je ne risque pas grand chose.

Model.hs

Le type représentant la configuration est intéressant car il n'est pas possible de crée une configuration invalide (selon les critères proposés dans la version originale de TapTempo) :

data Config = Config
  { precision :: RefinedPrecision
  , resetTime :: RefinedResetTime
  , sampleSize :: RefinedSampleSize
  }
  deriving (Show)

C'est la déclaration d'un type Config contenant trois champs, precision, resetTime et sampleSize aux type suivants :

type MaxPrecision = 5
type RefinedPrecision = Refined (FromTo 0 MaxPrecision) Int
type RefinedResetTime = Refined Positive Int
type RefinedSampleSize = Refined Positive Int

Ici on définit un entier MaxPrecision au niveau du type, et les types raffinés d'entiers RefinedPrecision, RefinedResetTime et RefinedSampleSize qui donnent des garanties à la compilation concernant leur valeur, respectivement entre 0 et MaxPrecision pour le premier, et supérieure à 0 pour les deux derniers. `

On définie aussi plusieurs valeurs disponibles lors de l'exécution pour les valeurs par défauts :

defaultResetTime :: RefinedResetTime
defaultResetTime = $$(refineTH 5)

defaultSampleSize :: RefinedSampleSize
defaultSampleSize = $$(refineTH 5)

defaultPrecision :: RefinedPrecision
defaultPrecision = $$(refineTH 0)

La fonction refineTH est exécutée à la compilation pour vérifier les valeurs. En cas de valeur invalide, une erreur de compilation sera générée :

Model.hs:29:23: error:
    * Value is out of range (minimum: 0, maximum: 5)
    * In the Template Haskell splice $$(refineTH 100)
      In the expression: $$(refineTH 100)
      In an equation for `defaultPrecision':
          defaultPrecision = $$(refineTH 100)
   |
29 | defaultPrecision = $$(refineTH 100)

Pour finir, la valeur maxPrecision est définie à partir du type MaxPrecision. C'est assez verbeux, mais nous auront besoin de cette valeur lors de l'exécution.

maxPrecision :: Int
maxPrecision = fromInteger (natVal (Proxy :: Proxy MaxPrecision))

TapTempo.hs

La boucle principale est dans la fonction 'tapTempo` qui se sert de quelques fonctions utilitaires :

  • onReturnPressed qui se charge de lire la ligne de commande jusqu'à exécuter la continuation sur un retour à la ligne ou simplement quitter sur un q.
  • computeBPM qui calcul le rythme en se basant sur une liste de mesure de temps.
  • clipOldSamples, clipNumberOfSamples, tooOld qui se chargent de la maintenance de la liste d'échantillons

La plupart de ces fonctions sont écrite dans l'optique ne pas pouvoir être appelées d'une manière fausse. Par exemple, tooOld qui vérifie que le delta de temps est supérieure à une valeur en seconde n'accepte que des entiers >= 1 par le biais du type Refined Positive Int.

Tout particulièrement, la séquence d'échantillons de temps Seq TimeSpec est utilisé car elle propose des accès en O(1) à sa tête et sa queue, pratique pour calculer la différence, mais inutile sur un projet comme celui-ci ;). Cependant un autre usage intéressant arrive dans la fonction computeBPM :

computeBPM :: Seq TimeSpec -> Maybe Float
computeBPM s@(first :<| (_ :|> last)) = Just ...
computeBPM _ = Nothing

Cette fonction va tout d'abord réaliser une déconstruction sur la séquence en utilisant les opérateurs d'accès en tête et queue :<| et :|>. Si celle-ci passe, c'est qu'il y a au moins une tête et une queue, on peut donc faire le calcul, sinon on passe dans le cas suivant qui renvoie un échec.

Main.hs

Le fichier sans doute le plus complexe du projet parce que j'ai voulu faire une interface ligne de commande très robuste et ultra documentée. En cas de mauvaise saisie, l'interface répond avec des précisions sur ce qui est attendu. Par example :

$ TapTempo --precision 120
option --precision: Value is out of range (minimum: 0, maximum: 5)

Usage: TapTempo [-p|--precision (0..5)] [-r|--reset-time (INT>0)]
                [-s|--sample-size (INT>0)] [-h|--help] [-v|--version]
  Press the <Enter> key and see your rythm

Pour cela j'ai abusé de deux librairies :

  • optparse-applicative chargée de l'analyse de la ligne de commande
  • refined, vue avant dans la Config, chargée de s'assurer que les options sont bien dans les bornes

La description de mon parseur de ligne de commande est donc la suivante:

      <$> option (eitherReader (\x -> refine =<< readEither x))
          ( long "precision"
         <> short 'p'
         <> help (message MsgCLIHelpPrecision)
         <> showDefaultWith (\x -> show (unrefine x))
         <> value defaultPrecision
         <> metavar ("(0.." ++ show maxPrecision ++ ")")
          )
      <*> option (eitherReader (\x -> refine =<< readEither x))
          ( long "reset-time"
         <> short 'r'
         <> help (message MsgCLIHelpResetTime)
         <> showDefaultWith (\x -> show (unrefine x))
         <> value defaultResetTime
         <> metavar "(INT>0)" )
      <*> option (eitherReader (\x -> refine =<< readEither x))
          ( long "sample-size"
         <> short 's'
         <> help (message MsgCLIHelpSampleSize)
         <> showDefaultWith (\x -> show (unrefine x))
         <> value defaultSampleSize
         <> metavar "(INT>0)" )

Il y a beaucoup trop de chose, on va s'intéresser seulement à un sous bloc, car en fait il s'agit de la description du parseur des trois options de Config :

option (eitherReader (\x -> refine =<< readEither x))
    ( long "precision"
   <> short 'p'
   <> help (message MsgCLIHelpPrecision)
   <> showDefaultWith (\x -> show (unrefine x))
   <> value (($$(refineTH defaultPrecision))
   <> metavar ("(0.." ++ show maxPrecision ++ ")")
    )

Ici je m'intéresse à l'option, dont le nom long est "precision" et le nom cours est "p". Son message d'aide est donné par help et un appel à la fonction message de localisation. metavar nous donne une petite documentation qui sera affichée dans l'aide sous la forme --precision (0..n) avec "n" ici qui est correctement remplacé par la vraie borne maxPrecision. Le type de destination est un entier raffiné qui n'a pas de méthode d'affichage par défaut, donc on utilise showDefaultWith qui sert à définir la fonction d'affichage, qui se contente de récupérer la valeur comprise dans le type raffiné.

Pour finir, la première ligne contient la fonction utilisée pour parser eitherReader (\x -> refine =<< readEither x). Celle-ci se contente d'utiliser readEither pour lire un entier et passer celui-ci à la fonction refine qui doit produire en entier raffiné, et qui échouera si son argument ne valide pas les critères de l'entier raffiné.

Conclusion

Je me suis bien amusé. En pratique sur un vrai projet je ferais quelque chose de plus simple, principalement au niveau de la localisation et de l'interface ligne de commande. Par exemple, il existe une librairie, optparse-generic qui est capable de générer automatiquement une interface ligne de commande à partir d'un type (ici ce serait Config). C'est toute la complexité du fichier Main.hs qui vient de disparaître, au prix d'un peu moins de souplesse.

Je ne ferais pas la localisation. Par contre, je continuerais à utiliser des types raffinés car cela apporte une sécurité de développements que je trouve agréable.

  • # Bravo !!

    Posté par  (site web personnel, Mastodon) . Évalué à 6.

    Super intéressant et bien documenté, félicitations !!

    Je m'en vais regarder de suite le code pour encore mieux comprendre tes explications.

    Je continuerais à utiliser des types raffinés

    Je ne peux que te suivre sur ce terrain, ça fait partie de la sécurité et apporte, à mon avis, un côté documentation que l'on oublie souvent.

    Vraiment un grand bravo !

  • # Intérêt de refineTH ?

    Posté par  (site web personnel) . Évalué à 2.

    Déjà bravo pour le journal qui était extrêmement instructif ;-)

    Ensuite je ne comprends pas l'intérêt d'utiliser refineTH pour définir la valeur de "defaultResetTime". Vu que son type "RefinedResetTime" défini déjà les contraintes, j'aurais cru que le compilateur aurait fait la vérification dessus à la compilation automatiquement (un peu comme en C où "int Foo = 3.2" va crasher à la compilation).
    Du coup sans refineTH le ton example de valeur initiale trop grande va compiler puis crasher à la runtime ? Ça me semble étrange…

    • [^] # Re: Intérêt de refineTH ?

      Posté par  (site web personnel) . Évalué à 4.

      Très bonne question ! Il faut bien comprendre que le compilateur GHC n'a AUCUNE notion de type raffinés. Pour lui, un Integer reste un Integer, i.e. un entier de taille infinie.

      La librairie refined propose simplement un type Refined qui, de manière simpliste, peut être vue comme un objet ne contenant qu'un seul membre privé de type Integer. Comme il n'y a pas de conversion implicite en Haskell, un Integer (comme 5) ne peut pas être vu comme un Refined, il faut le convertir.

      On ne peut pas obtenir d'erreur à l’exécution : soit on utilise refineTH lors de la compilation (et l'erreur sera le cas échéant à la compilation), soit on utilise refine à l’exécution et on est forcé de tester le résultat de la conversion.

      Ce type Refined est très limité, il n'accepte pas d'autres opérations (comme l'addition, la soustraction, …) Ainsi il ne peut être vraiment pratique que pour des valeurs constante. On peut imaginer trois exemples de fonction division :

      • Celle qui ne gère pas l'erreur et qui va planter à l’exécution :
      myDiv :: Integer -> Integer -> Integer
      myDiv a b = div a b
      • Celle qui gère l'erreur et renvoie une valeur représentant la réussite ou l'échec, ce qui force l'appelant de la fonction à gérer le cas sur le résultat:
      myDiv :: Integer -> Integer -> Maybe Integer
      myDiv _ 0 = Nothing
      myDiv a b = Just (div a b)
      
      -- plus tard
      
      case myDiv a b of
         Just res -> putStrLn ("C'est bon: " ++ show res)
         Nothing -> putStrLn "Erreur"
      • L'approche qui ne peut pas échouer, mais force l'appelant à fournir le bon type "raffiné" :
      myDiv :: Integer -> Refined (Or (LessThan 0) (GreaterThan 0)) Integer -> Integer
      myDiv a b = div a b
      
      -- plus tard
      
      case refine b of
        Left erreur -> putStrLn "Erreur"
        Just bRefined -> putStrLn ("C'est bon" ++ show (myDiv a bRefined))

      Dans ce dernier cas que je préfère, le type de la fonction est bien plus informatif, et le code de la fonction est plus simple. Et si tu possède déjà un type raffiné, tu peux t'affranchir des tests et ainsi il n'y a pas de coût à l’exécution :

      normalizeByTen :: Integer -> Integer
      normalizeByTen v = myDiv v $$(refineTH 10)

      Qui n'aura pas plus de coût à l’exécution que l'appel à la fonction div.

      On pourrait cependant imaginer une libraire d'un peu plus haut niveau capable d'opérations arithmétiques entre les types. C'est le cas par exemple de Liquid Haskell qui est un analyseur statique de code Haskell. Par exemple, il peut prouver que div x (abs x + 1) est correct car :

      • quelque que soit x, abs x >= 0
      • abs x + 1 >= 1
      • div x (abs x + 1) est défini (car point précédant) et différent de 0.

      Cet exemple est assez simple et on pourrait facilement réaliser un type Haskell qui permet ces opérations. Cependant des cas plus complexes ne sont pas possible à exprimer dans le système de type et demandent donc un outil externe, comme Liquid Haskell.

    • [^] # Re: Intérêt de refineTH ?

      Posté par  (site web personnel, Mastodon) . Évalué à 3.

      (un peu comme en C où "int Foo = 3.2" va crasher à la compilation).

      Euh, pas chez moi :

      $ cat foo.c
      #include <stdio.h>
      int main(int argc, char *argv[])
      {
      int foo = 5.2;
      printf("foo = %d\n", foo);
      return 0;
      }
      $ gcc -Wall foo.c
      $ ./a.out 
      foo = 5
      $ gcc --version
      gcc (GCC) 5.3.0
      

      Après, ça dépend probablement des options de compilation et de la version de gcc. Et j'avoue ne pas avoir vraiment fouillé plus en détail.

      • [^] # Re: Intérêt de refineTH ?

        Posté par  (site web personnel) . Évalué à 4.

        -Wconversion te donnera un warning adapté. Je recommande cette option, malheureusement sur une base de code existante elle génère tellement de warning que c'est des heures de travail pour la fixer.

        A titre d’exemple, c'est mon fil rouge depuis 5 ans dans ma boite. Quand j'ai quelques minutes de libre, j'en fixe une dizaine.

        Notons que visual studio est plus strict par défaut à ce niveau.

  • # Régionalisation

    Posté par  (site web personnel) . Évalué à 2.

    Bravo pour ce portage et surtout pour l'effort concernant la régionalisation !

    Pour le numéro de version qui utilise git, que se passe-t-il si :
    - git n'est pas installé sur la machine ?
    - les sources ne sont pas versionnées par git ?

    • [^] # Re: Régionalisation

      Posté par  (site web personnel) . Évalué à 5.

      Si git n'est pas sur la machine (qui réalise la compilation), alors cela plante à la compilation. Mais pour l'instant j'utilise nix qui se charge de provisionner l'environnement pour moi avec, entre autre, le compilateur Haskell, les quelques librairies et git.

      Si les sources ne sont pas versionnées par git ? Elle le sont… Sinon, en effet, cela n'a pas d’intérêt. Cependant ce code s'adapte très bien à un autre gestionnaire de version ou toute autre solution automatique de récupération du numéro de version.

      Merci pour le commentaire positif. Pour la régionalisation, je voulais le faire car pour être honnête c'est quelque chose qui ne m’intéresse pas du tout et que je n'ai jamais pris la peine de faire sur mes projets plus sérieux (voir même professionnel), donc je voulais me donner l'occasion d'apprendre ;)

Suivre le flux des commentaires

Note : les commentaires appartiennent à celles et ceux qui les ont postés. Nous n’en sommes pas responsables.