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 unq
. -
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 Blackknight (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 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 G.bleu (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 Guillaum (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 unInteger
, 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 typeInteger
. Comme il n'y a pas de conversion implicite en Haskell, unInteger
(comme5
) ne peut pas être vu comme unRefined
, 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 utiliserefine
à 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 :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 :
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 :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 Tonton Th (Mastodon) . Évalué à 3.
Euh, pas chez moi :
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 Guillaum (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 mzf (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 Guillaum (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.