Journal Portage de TapTempo en Ada

Posté par  (site web personnel, Mastodon) . Licence CC By‑SA.
Étiquettes :
27
26
fév.
2018

Sommaire

Et voilà, à peine développé et déjà un fork :)

Suite au journal de mzf, j'ai décidé de porter le taptempo en Ada.

Pourquoi faire ? Juste pour le plaisir :)
Et puis aussi parce que le logiciel était suffisamment court pour faire un portage rapide et montrer différents aspects d'Ada sur un programme déjà existant en C++.

Après une version Rust, voici donc, comme promis, la version Ada et sa petite explication… Enfin, deux versions.

Première version

La première version que j'ai décidé de vous présenter est un portage direct du code C++ avec quelques ajouts typiquement Ada-esques. C'est facile, le code est .

La structure

Le programme est découpé en plusieurs morceaux:
- la procédure adataptempo faisant office de programme principal
- le package Options, dépendant de la bibliothèque Parse_Args, permettant de gérer les options du programme
- le package TapTempo définissant les types nécessaires ainsi que le fonctionnement complet du taptempo

Les types

C'est par ce dernier package que je vais commencer.
Il définit les types suivants:

   type Tap_Tempo is private;

   subtype Sample_Size is Count_Type;
   type Seconds is new Positive;
   type Precision is range 0 .. 5;

Les types Sample_Size, Seconds et Precision permettent de spécifier les contraintes du problème. On voit là un des avantages par rapport au type size_t, les types étant d'une part, incompatibles entre eux et d'autre part, beaucoup plus contraints.
Cela permet notamment de se passer des tests suivants, ces valeurs n'étant pas dans la plage acceptable

if(this->sampleSize == 0)
    {
        this->sampleSize = 1;
    }

    if(this->resetTimeInSecond == 0)
    {
        this->resetTimeInSecond = 1;
}

Le type Precision se passe d'explication :)

Enfin, le type Tap_Tempo est déclaré privé et donc sa définition n'est pas accessible à l'extérieur du package. Il faut donc définir une sorte de constructeur. C'est ce qui est fait au travers de la fonction Build.

   function Build
     (Size                 : Sample_Size := 5;
      Reset_Time_In_Second : Seconds     := 5;
      Precision_Needed     : Precision   := 0) return Tap_Tempo;

Mais du coup, c'est quoi la définition de notre objet privé ?

   -- Time vector type
   package Time_Vectors is new Ada.Containers.Vectors(Index_Type   => Positive,
                              Element_Type => Time);
   use Time_Vectors;

   type Tap_Tempo is record
      Size                 : Sample_Size := 1;
      Reset_Time_In_Second : Seconds     := 1;
      Precision_Needed     : Precision   := 1;
      Time_Vector          : Time_Vectors.Vector;
   end record;

Une petite explication sur les premières lignes s'impose.
Il s'agit de l'instanciation d'un package générique standard pour la gestion des vecteurs.
Pourquoi vecteur et pas queue ? Parce qu'Ada ne fournit pas en standard de queue simple mais uniquement des queues synchronisées dans le cadre de la programmation multitâches ce qui impose beaucoup trop de contraintes pour notre cas simple.

Ensuite, le Tap_Tempo n'est finalement qu'un simple enregistrement, même pas objet :D

Et c'est sur ce type que l'on définit la procédure Run

   procedure Run (Tempo : in out Tap_Tempo);

On remarquera au passage l'utilisation de in out qui permet de voir que l'on modifiera l'objet dans la procédure, le vecteur en fait.

La procédure Run

Il s'agit d'un simple portage ligne à ligne du code C++ mais quelques petits zooms sur ce code permettront de voir une ou deux spécificités Ada.

Une première spécificité est liée au système de types. En effet, la procédure Get_Immediate renvoie un caractère et non un simple entier. Cela permet donc d'expliciter la clause de sortie de boucle :

            exit when (Key = Ada.Characters.Latin_1.LF);

Personnellement, je préfère ça, sans aucune arrière-pensée, à

} while (i != 10);

Deuxième spécificité qui peut surprendre le développeur C/C++ voire Java, le test permettant de savoir que l'on a trop attendu

            if (not Is_Empty (Tempo.Time_Vector)
             and then Is_Reset_Time_Elapsed(Tempo,Current_Time,First_Element (Tempo.Time_Vector)))

En Ada, dans le cas d'une opération logique, tous les termes sont évalués ce qui pose ici problème car le premier a pour but d'éviter de faire le second test. Finalement, on dit bien ce que l'on fait à savoir si…et alors….

Il y aurait encore des choses à dire sur tout ce package mais bon, ce n'est qu'un journal et il est tard alors je répondrai aux questions si besoin dans les commentaires :D

Du coup, voyons ce que l'on peut faire de plus.

Seconde version

Cette seconde version part d'un constat simple, le taptempo utilise l'entrée standard qui n'est pas protégée pour les accès concurrents. De plus, quel est l'intérêt d'avoir plusieurs instances du taptempo ?
J'aurai tendance à dire aucune… Surtout que ça m'arrange :D

En C++, la technique est d'utiliser le patron de conception du singleton. Il se trouve que celui-ci est très simple à implémenter en Ada.

En effet, contrairement aux namespaces C++, le package Ada est une unité de compilation et d'encapsulation à part entière. Ainsi, il est possible de "cacher" un état dans le corps d'un package et a fortiori, notre file.

package body TapTempo is

   -- Time vector type
   package Time_Vectors is new Ada.Containers.Vectors(Index_Type   => Positive,
                              Element_Type => Time);
   use Time_Vectors;

   Time_Vector          : Time_Vectors.Vector;

Il suffit ensuite de déplacer les paramètres de configuration dans la méthode Run comme suit

procedure Run
     (Size                 : Sample_Size := 5;
      Reset_Time_In_Second : Seconds     := 5;
      Precision_Needed     : Precision   := 0);

Du coup, plus besoin de construction d'un objet Tap_Tempo dans la procédure principale adataptempo mais on a seulement besoin d'appeler Run.
Voilà, il n'y a pas de révolution dans cette seconde version mais le but était juste de montrer l'utilité des packages et leur capacité à fournir de l'encapsulation et donc une portée en plus d'un espace de noms.
Bien sûr, vous trouverez tout ce code ici.

Conclusion

J'ai pris plaisir à faire ce petit portage qui m'a, au passage, permis d'essayer la bibliothèque Parse_Args pour la gestion des options.

En espérant que ce "petit" journal vous ait plu… Place aux commentaires !

  • # Petit oubli

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

    Bon, j'ai oublié de dire que je n'ai pas fait la partie internationalisation, histoire de vous fournir rapidement un truc à vous mettre sous la dent ;)

    Du coup, entre l'absence d'internationalisation et de licence dans chaque fichier, il manque forcément quelques lignes dans les stats qui suivent

    Version 1

     cloc .
          11 text files.
          10 unique files.
           4 files ignored.
    
    github.com/AlDanial/cloc v 1.70  T=0.06 s (127.7 files/s, 3957.3 lines/s)
    -------------------------------------------------------------------------------
    Language                     files          blank        comment           code
    -------------------------------------------------------------------------------
    Ada                              5             39             13            168
    Markdown                         3              9              0             19
    -------------------------------------------------------------------------------
    SUM:                             8             48             13            187
    -------------------------------------------------------------------------------
    

    Version 2

    cloc .
          11 text files.
          10 unique files.
           4 files ignored.
    
    github.com/AlDanial/cloc v 1.70  T=0.05 s (155.1 files/s, 4244.9 lines/s)
    -------------------------------------------------------------------------------
    Language                     files          blank        comment           code
    -------------------------------------------------------------------------------
    Ada                              5             34              9            148
    Markdown                         3              9              0             19
    -------------------------------------------------------------------------------
    SUM:                             8             43              9            167
    -------------------------------------------------------------------------------
    

    Bon, le gain est pas énorme :D

  • # Bravo !

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

    Bravo pour le portage, j'ai appris pas mal de choses sur Ada !

    Concernant la définition des types, que se passe t'il quand l'utilisateur rentre une valeur hors cadre ?
    Je ne connais pas très bien Ada, du coup je n'arrive pas à comprendre où est le code qui permet de mettre par exemple Sample_Size à 1 si celui-ci est 0.
    Une erreur est-elle lancée à la place ?

    • [^] # Re: Bravo !

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

      du coup je n'arrive pas à comprendre où est le code qui permet de mettre par exemple Sample_Size à 1 si celui-ci est 0.
      Une erreur est-elle lancée à la place ?

      Lors de la saisie d'une valeur hors plage, le runtime Ada lance une exception de type CONSTRAINT_ERROR qu'il est tout à fait possible de catcher.
      Ici, il n'est de toutes façons pas possible de saisir, en statique, 0 pour la précision ou le timeout, le compilateur annoncera la levée d'exception dans un warning ces derniers étant des types dont la première valeur est 1.

      Dans le cas de ce programme, le filtrage des valeurs incorrectes est remonté à la gestion des options. Ainsi, le code suivant fournit tout ce qu'il faut pour gérer les valeurs hors plage :

      procedure Prepare_Options(Parser : out Argument_Parser) Is
         begin
            -- Options creation
            Parser.Add_Option(Make_Boolean_Option (False), "help", 'h', Usage =>
                    "display this help message");
            Parser.Add_Option(Options.Precision_Option.Make_Option, Precision_Option_Name, 'p', Usage =>
                    "set the decimal precision of the tempo display, default is 5 and max is " &
                    Precision'Image (Precision'Last));
            Parser.Add_Option(Make_Positive_Option (5), Reset_Time_Option_Name, 'r', Usage =>
                    "set the time in second to reset the computation. default is 5 seconds");
            Parser.Add_Option(Make_Positive_Option (1), Sample_Size_Option_Name, 's', Usage =>
                    "set the number of samples needed to compute the tempo, default is 1 sample");
            Parser.Set_Prologue ("An Ada version of taptempo.");
      end Prepare_Options;

      Dans le cas simple du type Positive, c'est l'appel à Make_Positive_Option qui gère tout seul, enfin dans la lib Parse_Args, les valeurs hors-plage.
      C'est rendu possible par le fait que les types embarquent leur bornes (par exemple Precision'Last retourne la dernière valeur valide du type Precision.

      • [^] # Re: Bravo !

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

        Donc c'est l'avantage des types bornés et la magie de Parse_Args qui le permet.
        Merci pour ces explications !

        • [^] # Re: Bravo !

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

          Oui. Ainsi, la compilation du code suivant

          procedure Test_Bornes is
             type Precision is range 1 .. 5;
          
             ma_petite_precision : Precision := 0;
             ma_grosse_precision : Precision := 6;
          begin
             null;
          end Test_Bornes;

          renvoie le message suivant

          test_bornes.adb:4:39: warning: value not in range of type "Precision" defined at line 2
          test_bornes.adb:4:39: warning: "Constraint_Error" will be raised at run time
          test_bornes.adb:5:39: warning: value not in range of type "Precision" defined at line 2
          test_bornes.adb:5:39: warning: "Constraint_Error" will be raised at run time
          

          Et à l'exécution, comme prévu et prévenu

          raised CONSTRAINT_ERROR : test_bornes.adb:4 range check failed
          
    • [^] # Re: Bravo !

      Posté par  . Évalué à 5.

      Je me joint à cette louange.

      Ce journal est didactique et intéressant. La qualité d'écriture le rend facile et agréable à lire.

      BeOS le faisait il y a 20 ans !

  • # Merci !

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

    Ce langage a l'air intéressant, même si la syntaxe est déroutante pour moi. Ca commence à faire pas mal de fois que j'entends parler en bien de celui-ci, il faudra que je creuse tout ça.

    Sinon, je suppose que je n'ai pas trop le choix, je devrai écrire un journal sur mon port en Rust :p

    • [^] # Re: Merci !

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

      C'est la syntaxe du Pascal. Puis Ada a été créé en 1980, donc c'est un peu verbeux, mais c'est pas ésotérique non plus.

      • [^] # Re: Merci !

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

        Puis Ada a été créé en 1980

        Ce qui explique que ce soit largement passé de mode

        c'est un peu verbeux, mais c'est pas ésotérique non plus

        Heureusement, les IDE modernes permettent la completion.

        • [^] # Re: Merci !

          Posté par  (site web personnel) . Évalué à 4. Dernière modification le 27 février 2018 à 23:02.

          Et le C date de 1972. Passé de mode ? Je ne crois pas, non.
          Le langage Ada a évolué aussi depuis ses débuts. C'est un langage concurrent et orienté objet. C'est pas suranné comme concepts, ça.

          L'âge du langage explique pourquoi un programmeur qui ne connait que la syntax JS peut être surpris, mais c'est plutôt un signe d'ignorance^Wde jeunesse. La syntaxe Pascal, c'est pas non plus celle d'APL… C'est lisible et compréhensible par n'importe qui.

          • [^] # Re: Merci !

            Posté par  (site web personnel, Mastodon) . Évalué à 3. Dernière modification le 27 février 2018 à 23:04.

            Et le C date de 1972. Passé de mode ? Je ne crois pas, non.

            Et c'est des fois dommage quand on voit ce que l'on essaye de lui faire faire.

            Le langage Ada a évolué aussi depuis ses débuts. C'est un langage concurrent et orienté objet. C'est pas suranné comme concepts, ça.

            T'inquiètes, tu prêches un converti et connaisseur mais je passe quand même pour un hurluberlu à faire de l'évangélisation Ada.

            En tout cas, la note même de ce journal montre que ce n'est pas si recherché que ça comme langage au moins sur LinuxFr.

    • [^] # Re: Merci !

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

      Sinon, je suppose que je n'ai pas trop le choix, je devrai écrire un journal sur mon port en Rust :p

      Exactement et personnellement, je l'attends avec impatience :)
      Et surtout n'hésites pas à mettre plein d'explications parce que les macros et les map, ça reste souvent cryptique pour moi :D

      Notamment

      reader
                      .next()
                      .unwrap_or(Ok("q".into()))
                      .map_err(|e| format!("{}", e))
                      .map(|s| s != "q")

      Mais c'est parce que je ne suis pas câblé fonctionnel ;)

      • [^] # Re: Merci !

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

        Je pense que si je fais un journal, je ne vais pas garder ces lignes, c'est un peu moche.

        Pour expliquer vite fait, le résultat de reader.next() est un Option<Result<String, Error>>, c'est-à-dire qu'on pourrait avoir ou pas un résultat (Option est un type optionnel, comme Maybe en Haskell). On n'a pas de résultat quand l'utilisateur fait un CTRL+D sur une ligne vide par exemple.

        Du coup, le unwrap_or sert à dire: "récupère le contenu de l'option, ou prend la valeur Ok("q".into()) comme valeur par défaut si on n'a pas de valeur dedans".

        Donc on a un type Result<String, Error> à ce moment. Il vaut soit Ok(String) si tout s'est bien passé, soit Err(Error) si il y a eu un problème. map_err sert à changer le type dans Err ; et map sert à changer le type de Ok.

        Si on écrivait tout ça en plus verbeux, ça donnerait:

        match reader.next() {
            None => false,
            Some(res) => match res {
                Ok(s) => Ok(s != "q"),
                Err(e) => Err(format!("{}", e)),
            }
        }
        
        • [^] # Re: Merci !

          Posté par  (site web personnel, Mastodon) . Évalué à 2. Dernière modification le 27 février 2018 à 21:34.

          Merci pour l'explication.
          D'ailleurs, je préfère la version verbeuse, je la trouve plus lisible ;)

          En tout cas, fais un journal, on verra la cote de chaque langage à la note de celui-ci :)
          Pour l'instant, c'est C++ qui gagne mais je ne doute pas que vu la popularité actuelle de Rust, tu marques des points.

          • [^] # Re: Merci !

            Posté par  . Évalué à 5. Dernière modification le 28 février 2018 à 18:41.

            D'ailleurs, je préfère la version verbeuse, je la trouve plus lisible ;)

            C'est parce que la pipeline est courte. Il faut voir cette notation pointée de Rust comme le pipe | du shell : reader.next() | unwrap_or | map_err | map. Sur de longues pipelines, c'est plus simple à écrire et il vaut mieux laisser le compilateur inliner le tout plutôt que de le faire à la main (comme dans sa version verbeuse, qui contient d'ailleurs une erreur : le cas None doit retourner Ok(false) de type Result<Bool, String>).

            En OCaml (j'étais bien obligé), on écrirait un code du genre :

            next reader |> Option.default (Ok "q") |> Result.map_both (fun s -> s <> "q") format_error

            Les patterns à base de map sont omniprésents dans le paradigme fonctionnel. Un type paramétrique à un paramètre (comme les options, les listes, les tableaux, les vecteurs…) est une fonction des types dans les types. Ainsi si j'ai une fonction qui transforme les objets du paramètre (disons des int en string), je peux la lifter pour opérer sur le type paramétrique (un peu comme une composition de fonction, si tu veux). Exemples :

            (* un int optionnel devient un string optionnel *)
            Option.map string_of_int (Some 1);;
            - : string option = Some u"1"
            
            (* une liste de ints devients une liste de strings *)
            List.map string_of_int [1; 2; 3];;
            - : string list = [u"1"; u"2"; u"3"]
            
            (* un tableau de ints devient un tableau de strings *)
            Array.map string_of_int [|1; 2; 3|];;
            - : string array = [|u"1"; u"2"; u"3"|]

            Le type Result est lui un type paramétrique à deux paramètres : on peut donc faire un map soit sur le type en paramètre de Ok, soit sur celui en paramètre de Err. Ce qui soit donne deux fonctions map (comme en Rust), soit une fonction map_both comme dans mon exemple en OCaml (ou bimap en Haskell) qui prend deux fonctions en paramètres (une pour chaque type).

            (* la double map pour le type result *)
            let bimap f g = function Ok x -> Ok (f x) | Error e -> Error (g e)
            
            (* le unwrap_or comme en Rust *)
            let unwrap_or d = function None -> d | Some x -> x
            
            (* la pipeline comme en Rust *)
            let pipeline x = 
              x
              |> unwrap_or (Ok "q")
              |> bimap (fun s -> s <> "q") ( Printf.sprintf "Houla y'a eu un truc: %s")
            
            (* exemples de sortie *)
            pipeline None;;
            - : (bool, string) result = Ok false
            
            pipeline (Some (Ok "f"));;
            - : (bool, string) result = Ok true
            
            pipeline (Some (Error "une erreur"));;
            - : (bool, string) result = Error u"Houla y'a eu un truc: une erreur"

            P.S : sinon sympa le journal, et comme depuis les derniers journaux sur la virgule flottante j'ai installé GNAT et GNAT Programming Studio je vais pouvoir regarder un code ADA idiomatique et jouer avec :-) (ravi de voir au passage qu'il y a des développeurs ADA qui comprennent l'encapsulation ;-).

            Sapere aude ! Aie le courage de te servir de ton propre entendement. Voilà la devise des Lumières.

            • [^] # Re: Merci !

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

              Merci pour l'explication.

              Il va vraiment falloir que je soigne mon aversion pour la syntaxe d'Ocaml parce que d'une part, tu as fait l'effort d'installer Gnat donc je peux bien faire l'effort aussi et d'autre part, j'ai vraiment l'impression de passer à coté de tout un pan de l'informatique en négligeant la partie fonctionnelle :D

              Ou alors, je suis finalement trop vieux et je ne peux plus m'adapter ;)

              ravi de voir au passage qu'il y a des développeurs ADA qui comprennent l'encapsulation ;-)

              C'est le côté autoritaire, limite autoritariste, du langage qui finit par prendre le dessus, on ne laisse pas d'accès à n'importe qui ;)
              D'ailleurs je me rends compte que je fais la même chose en C/C++ et Java en collant des accolades partout pour limiter les portées :D

  • # Souvenirs souvenirs

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

    J'ai commencé la programmation avec Ada 83 en DUT sur des VT200 avec vi sous HPUX.

    Les heures à tenter de faire compiler ton programme codé sous Windows 98 + GNAT/Ada 95 sur Ada 83 :-)

  • # Fini !!

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

    Finalement, j'ai pris la fin du programme à bras-le-corps en ajoutant l'internationalisation via ZanyBlue, chose que je n'avais jamais faite.

    Comme je n'allais pas faire un deuxième journal pour ça, je préfère faire un commentaire ce qui permettra aux seuls intéressés d'être au courant :)

    Comment ça marche

    Comme souvent en Ada, on préfère la compilation à la gestion au runtime.
    Du coup, le point d'entrée pour l'internationalisation, c'est le fichier de properties qui contient les clés à insérer dans les zones de texte.

    Par exemple:

    displayHelp=Affiche ce message d''aide
    resetTime=r\u00E8gle le temps en secondes pour mettre le calcul \u00E0 z\u00E9ro. La valeur par d\u00E9faut est de 5 secondes
    sampleSize=r\u00E8gle le nombre d''\u00E9chantillons n\u00E9cessaires au calcul du tempo, la valeur par d\u00E9faut est 1
    precision=r\u00E8gle la pr\u00E9cision en d\u00E9cimales de l''affichage du tempo, la valeur par d\u00E9faut est 5 et le max est {0,integer}
    prologue=Version Ada de taptempo.

    La syntaxe de ce fichier de propriétés est tout ce qu'il y a de plus standard pour qui en a déjà écrit en Java.
    La seule chose vraiment notable vient de la ligne

    precision=r\u00E8gle la pr\u00E9cision en d\u00E9cimales de l''affichage du tempo, la valeur par d\u00E9faut est 5 et le max est {0,integer}

    Elle permet juste de préciser que l'on insérera une donnée dynamique, {0, mais qu'elle sera de type entière, integer}.

    Pourquoi faire cela ? Car ZanyBlue vient avec un compilateur de fichier de propriétés qui permet de générer du code Ada et donc de typer les différents accesseurs.

    Cela permet donc d'appeler notre substitution via, par exemple dans options.adb,

    Format_resetTime

    ou pour la précision

    Format_precision (+Precision'Last)

    Le code généré fournit donc des fonctions permettant de formater une chaine à partir d'une clé mais elle fournit aussi des procédures se substituant aux entrées-sorties classiques d'Ada.
    Ainsi, l'affichage du premier et du dernier message s'appellent tout simplement via

    Print_hitKey;
    Print_byeBye;

    Ces appels possèdent un paramètre optionnel With_NL permettant de préciser si l'on doit retourner à la ligne ensuite.

    Print_tempo (With_NL => False);

    Voilà, au final, c'était quasiment bête comme chou ;)

    Comme d'habitude, c'est

    • [^] # Re: Fini !!

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

      Bravo pour avoir été jusqu'au bout du portage :-)

      Comme souvent en Ada, on préfère la compilation à la gestion au runtime.

      Est-ce que cela signifie qu'il faut recompiler le programme pour avoir une autre langue ?

      • [^] # Re: Fini !!

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

        Bravo pour avoir été jusqu'au bout du portage :-)

        Merci.
        C'était important pour moi d'aller au bout car cela m'a forcé à apprendre à utiliser de nouvelles bibliothèques.

        Est-ce que cela signifie qu'il faut recompiler le programme pour avoir une autre langue ?

        Je ne suis pas encore un expert de ZanyBlue mais tel que je l'ai utilisé, les données texte sont embarquées dans le programme.
        Du coup, oui, il faut recompiler le programme pour avoir une nouvelle langue. Mais comme les interfaces ne changent pas et que gprbuild gère ça assez correctement, je pense que cela ne recompilera pas grand chose.

        Sinon, si je trouve un autre moyen plus dynamique, je le noterai ici bien que ce journal finisse par disparaître dans les flots des suivants ;)

Suivre le flux des commentaires

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