Journal Port de taptempo en Rust

Posté par . Licence CC by-sa
13
3
mar.
2018

Sommaire

Comme promis, voici un petit journal sur mon port de taptempo en Rust. Je n'ai pas porté le mécanisme d'internationalisation, puisque finalement on peut le faire comme dans le code C++, avec gettext. Ce n'est pas le plus intéressant du projet, et il n'existe pas de mécanisme d'internationalisation que je trouve vraiment satisfaisant et idiomatique pour le moment.

Description du Rust

Pour ceux qui ne connaissent pas le langage, la façon la plus simple de le décrire est de dire que c'est un croisement entre le C et le Haskell. Comme le C, il est bas-niveau (on peut faire en Rust ce qu'on ferait en C: programmation de micro-contrôleurs, d'OS, etc.) mais il tire du Haskell le fait d'être "orienté expression", sa saveur fonctionnelle, la richesse de son système de types.

Ce qui fait sa particularité et son immense avantage, c'est qu'il est memory safe et thread safe. Le langage garantit qu'il n'y a pas de pointeur invalide ou de "data race", le tout sans ramasse-miettes.

Préparation du projet

Un autre point est que comme beaucoup de langages modernes, le systèmes de dépendances (appelées crates en Rust) est très simple. On rajoute une ligne dans le manifeste du projet (appelé Cargo.toml) et le gestionnaire de build télécharge la dépendance et l'inclus dans le build. C'est tellement idiomatique et facile qu'il y a certaines dépendances qui n'ajoutent qu'une petite fonction ou macro utilitaire. Voici les outils que j'ai sélectionnés:

  • Ce projet utilise les options de lignes de commande : le crate indispensable pour ceci est StructOpt qui ajoute des macros procédurales permettant de récupérer les paramètres entrés en ligne de commande très facilement.
  • Ensuite, j'ajoute un crate pour avoir une queue circulaire (puisque c'est la structure de données que nous avons en interne). En l’occurrence, je n'ai pas trouvé de crate satisfaisant, et donc j'ai écrit le mien (il n'est pas encore publié puisqu'il est encore en chantier, mais ça viendra ;) ).

Gestion des paramètres

J'ai ajouté une nouvelle structure que j'appelle de façon originale Params. Vous verrez au-dessus la ligne suivante:

#[derive(Debug, StructOpt)]

qui permet de faire dériver notre struct des traits Debug et le fameux StructOpt. Le trait Debug devrait être implémenté sur toutes les structures de données puisqu'il permet de leur donner une représentation textuelle. Pratique pour le débug dans la console ou dans un log. Le trait StructOpt va permettre la génération du code pour la gestion des paramètres. Quand on tape ce genre de lignes, en interne une macro est invoquée qui va parser l'AST de la struct et en faire ce qu'on veut. Une macro procédurale est un générateur de code.

Du coup, je vais ajouter une donnée dans ma struct:

precision: usize,

et au-dessus, je vais donner des informations à StructOpt: le code court et long pour ajouter le paramètre (-p et --precision), la valeur par défaut, la documentation, etc. Tout est dans la doc du crate.

J'implémente ensuite un trait pour dire que notre structure a des valeurs par défaut. Je crée les données avec les infos passées en ligne de commande, et ensuite je modifie les chiffres en fonction des bornes.

Contrairement à Ada, on ne peut pas faire de type numérique borné pour le moment. Le système de type n'est pas achevé (le langage est encore jeune), mais quand ça viendra, ce type existera ! D'ici fin 2018 ça devrait être fait.

La structure App

Notre structure principale va ressembler à l'objet C++ originel. J'implémente le trait pour donner une valeur par défaut comme pour StructOpt (c'est vaguement un équivalent du constructeur par défaut en C++).

Ensuite, je suppose que je n'ai pas besoin de décrire dans le détail toutes les fonctions dans App, le nom est explicite et ça ressemble au code d'origine. Je vais juste aborder quelques spécificités du langage.

En Rust, une opération qui échoue retourne un type Result qui peut avoir deux valeurs: Ok avec l'objet attendu ou Err avec le type d'erreur qui s'est produite. Ça permet de gérer élégamment les erreurs sans mécanisme d'exceptions.

Le langage est "orienté expressions", donc on peut écrire des choses du genre :

fn must_continue(reader: &mut Lines<StdinLock>) -> Result<bool, IoError> {
    match reader.next() {
        None => Ok(false),
        Some(r) => r.map(|s| s != "q")
    }
}

Cette fonction doit se lire ainsi :
- Si on n'a pas de nouvelle ligne (l'utilisateur a tapé CTRL + D), l'opération a réussi et on ne doit pas continuer: Ok(false).
- Si on a une ligne, on prend le résultat, et avec map on transforme le Ok(String) en Ok(bool) en comparant la résultat avec "q".

La macro print_now

Comme dans la plupart des langages, la sortie standard est buffurisée et donc rien n'est affiché tant qu'il n'y a pas de retour à la ligne. Du coup, j'ai écrit une petite macro qui vide les buffers après avoir appelé print.

Ça permet de voir le fonctionnement des macros (classiques cette fois-ci, en opposition aux macros procédurales dont j'ai parlé plus haut): contrairement aux macros du C/C++, celles du Rust sont sémantiques, donc on doit faire matcher avec des choses qui ont du sens, et non pas "aveuglément". En l’occurrence, je récupère des expressions ($e:expr) ; Le ,* permet de dire qu'on veut en récupérer 0 ou plus séparées par des virgules.

La syntaxe peut sembler un peu ésotérique, mais le mécanisme est puissant et sécurisé.

Le main

Le main est simple, il va créer un objet de type App et lancer la méthode run puis vérifier le retour d'erreur.

Conclusion

J'espère que je vous ai donné envie de découvrir un peu plus ce langage qui est selon moi l'alternative la plus crédible au C. C'est un sentiment incroyable de faire un développement bas-niveau et de savoir que si le code compile, on ne tombe pas sur des erreurs non prévues au runtime (du genre segfault). Ceci permet de faire des réusinages massifs sans crainte, puisque le compilateur sait si on a fait quelque chose d'invalide au niveau de la mémoire.

En tout cas, j'ai moi-même progressé avec ce projet, et je suis parti pour publier au moins 3 crates différents. Le Rust est beaucoup basé sur le principe de l'ouverture du code (pour être publié en ligne, un crate doit être open-source) et ça fait toujours plaisir d'apporter sa pierre à l'édifice.

  • # Je viens de comprendre

    Posté par . Évalué à -3.

    En fait TapTempo c'est le nouveau johnny.

    • [^] # Re: Je viens de comprendre

      Posté par . Évalué à 0.

      Non, c’est le nouveau noir.

      • [^] # Re: Je viens de comprendre

        Posté par (page perso) . Évalué à 10.

        Je pense lancer Metaptempo, qui prend un port au hasard et le lance, ou si l'option --all est utilisée lance tous les ports en parallèle pour comparer leur précision. Évidemment la création de ports de Metaptempo nécessiterait un Metataptempo. Voire un HakunaMetataptempo.

        • [^] # Re: Je viens de comprendre

          Posté par . Évalué à 1.

          Merci Benoit pour ton dévouement à Linuxfr, on ne le dit pas assez.

        • [^] # Re: Je viens de comprendre

          Posté par . Évalué à 3.

          Vu le nombre de portages en cours je pense qu’il serait avantageux de louer un ordinateur quantique afin de ne pas interférer dans l’exécution en parallèle, vu qu’on va manquer de cœurs :/

          • [^] # Re: Je viens de comprendre

            Posté par . Évalué à 3. Dernière modification le 04/03/18 à 16:12.

            on trouve des serveurs ARM à 96 cœurs, il y a encore de la marge.

  • # Super !

    Posté par (page perso) . Évalué à 5.

    Cool ! Je l'attendais avec impatience ce journal :)

    Sur le plan technique, je reste toujours dubitatif sur la notation

    Some(r) => r.map(|s| s != "q")

    avec son |s| qui donne l'impression d'une variable sortie ex nihilo mais je commence à comprendre un peu mieux suite au commentaire de Kantien… Même si c'est pas encore cristal clear :D

    D'autre part, dans

    let (first, last) = (self.hits.front().unwrap(), self.hits.back().unwrap());
            let elapsed_time = last.duration_since(*first);

    Le fait que first et last ne soient pas déclarés avec un type me dérange toujours même si la ligne d'après me fait comprendre qu'il s'agit d'un pointeur vers un temps. Du coup, je trouve que ça limite la visibilité et que si on se retrouvait avec un tel code à l'autre bout du programme, ce serait plus difficile à comprendre.

    Gloabalement, ce n'est pas non plus totalement incompréhensible pour un dino comme moi qui est resté bloqué sur du procédural/objet :D

    Encore merci

    • [^] # Re: Super !

      Posté par . Évalué à 5. Dernière modification le 04/03/18 à 09:53.

      Le fait que first et last ne soient pas déclarés avec un type me dérange toujours même si la ligne d'après me fait comprendre qu'il s'agit d'un pointeur vers un temps.

      J'imagine qu'on a le choix d'ajouter des types pour aider la lecture, meme si le compilateur n'en a pas besoin pour inferer les types correctement. Comme ici en Scala:

          def f(): (Int, String) = (3, "Bonjour")
      
          val (trois: Int, msg: String) = f()
      
      • [^] # Re: Super !

        Posté par . Évalué à 3.

        Finalement, j'ai un peu cherche et voila le meme exemple en Rust (que vous pouvez executer en ligne ici: https://play.rust-lang.org/), c'est un peu plus verbeux que le Scala ^_^ :

        fn main() {
            fn f() -> (i16, String) {
                return (3, "Bonjour".to_string());
            }
        
            let (trois, message): (i16, String)= f();
            println!("trois: {}, message {}", trois, message)
        }
        

        Pour ceux qui ne sont pas habitue a l'inference de type, le typage dans

            let (trois, message): (i16, String)= f();
        

        est facultatif et si vous vous trompez le compilateur vous corrige. i8 a la place de i16 ne compile pas par exemple. C'est vraiment tres pratique.

        • [^] # Re: Super !

          Posté par . Évalué à 2.

          Pour la traduction c'est presque ça. On n'a pas besoin du return dans la fonction f. On écrira plutôt:

          fn f() -> (i16, String) {
              (3, "Bonjour".into())
          }
          

          Sinon, oui, c'est plus verbeux que Scala, Ocaml, Haskell, etc. puisque les concepteurs du langage ont fait le choix d'utiliser une syntaxe C-esque.

          Ce commentaire est libre de droit, vous pouvez le réutiliser comme bon vous semble.

    • [^] # Re: Super !

      Posté par . Évalué à 4.

      |s| s != "q"
      fonction anonyme de paramètre "s" (et de valeur s!=q). C’est juste plus compact et léger que les déclarations de fonctions habituelles :)
      Map est juste une fonction qui en attend une autre en paramètre. Le « map » classique s’applique sur une liste et applique la fonction sur tous les élément de la liste. C’est à dire qu’à partir d’une fonction d’un type A vers un type B ( A -> B ) map permet de former une fonction qui à partir d’une liste de A ([A] ou Liste A) retourne une liste de B : [A] -> B.

      Haskell, avec la notion de foncteur, a généralisé cette idée avec la notion de « foncteur » et fmap : un « truc » qui transforme une fonction de A -> B en une fonction « Truc A -> Truc B » est le fmap du foncteur truc ( https://hackage.haskell.org/package/base-4.10.1.0/docs/Control-Monad.html#v:fmap )
      Corrigez moi si je me trompe mais en l’occurrence « map » est le « fmap » du std::option de rust : il permet d’appliquer une fonction A -> B qui n’a pas à se soucier de si son A peut être null ou que sais-je en une fonction « option A -> option B » (« Truc A -> Truc B » avec Truc = option) qui va gérer d’elle même les cas « null » dans d’autres langage.

      Et effectivement, on peux trouver son code ici : https://doc.rust-lang.org/src/core/option.rs.html#402-407

          pub fn map<U, F: FnOnce(T) -> U>(self, f: F) -> Option<U> {
              match self {
                  Some(x) => Some(f(x)),
                  None => None,
              }
          }

      f est une fonction qui renvoie un U et map(f) renvoie un option.

      L’idée du « Maybe » en haskell c’est que tu écris tes fonctions sans te soucier des cas d’erreurs ou des éventuels « null » quand rien ne peut arriver, comme si tu écrivais du code avec exceptions presque (ou des fonctions qui prennent un A et retournent un « peut être B » ( voir https://en.wikibooks.org/wiki/Haskell/Understanding_monads#Motivation:_Maybe ). Et le langage t’aides à les composer comme il faut avec des opérateurs et du sucre syntaxique éventuellement. C’est un cas particulier de « monade ». Dans un monade des opérations sont abstraites de la même manière que « fmap » est abstraite dans le début de mon commentaire.

    • [^] # Re: Super !

      Posté par . Évalué à 1.

      J'ai oublié de préciser que |s| s != q est une closure, c'est-à-dire une fonction anonyme. La syntaxe est un peu étrange, mais les paramètres de la fonction se mettent entre deux barres verticales.

      Sinon, le compilateur est capable de tout inférer, donc on n'a pas besoin d'écrire les types de variable. Mais comme l'a dit un autre commentaire, on peut toujours écrire les types si on considère que le code sera plus clair. En l'occurrence, ça me semble plutôt intuitif que front et back retournent une référence sur le début et la fin de la queue. De façon générale (comme dans les autres langages fonctionnels comme Haskell ou Ocaml), on écrit de petites fonctions et on n'annote les variable que dans les (rares) cas où le compilateur a des problèmes d'inférence (typiquement, quand seul le retour d'une fonction est générique).

      Pour rebondir sur le dernier commentaire, il ne faut pas avoir peur du Rust : finalement ça ressemble quand même beaucoup aux langages objets : on a des structures sur lesquelles on implémente des méthodes ; ce qu'on peut voir comme une classe. En revanche, je ne suis pas spécialiste de l'histoire de l'informatique, mais il me semble que les langages fonctionnels sont plus vieux que les langages objets (en tout cas suffisamment vieux pour que n'importe quel dino connaisse :p). Le Lisp existe depuis 1958 selon Wikipédia.

      Ce commentaire est libre de droit, vous pouvez le réutiliser comme bon vous semble.

  • # Dépendances

    Posté par . Évalué à 6.

    C'est tellement idiomatique et facile qu'il y a certaines dépendances qui n'ajoutent qu'une petite fonction ou macro utilitaire.

    Oui, ça je comprends que ça ressemble à NPM. Mais ça m'inquiète en fait. Et c'est assez bien analysé par Lars Wirzenius.

    Donc est-ce que mes craintes sont fondées ? Est-ce qu'on va finir avec des applications à moitié libres, parce que l'autre moitié sera composée de centaines de dépendances aux licences incompatibles ou au code source manquant ?

    Ça ressemble à un troll, mais j'aime énormément le concept de Rust, et je n'aimerais pas que son écosystème me dissuade de l'utiliser :(

    • [^] # Re: Dépendances

      Posté par . Évalué à 2.

      Je pense que la comparaison est bonne, oui, ça ressemble à NPM dans le sens où on a plein de petits paquets interdépendants. En fait, c'est même considéré comme idiomatique de découper les grosses libs en crates plus petits, voir par exemple le graphe des dépendances de piston_window qui est un crate pour développer des jeux vidéos.

      Il existe cependant quelques différences importantes :
      - La bibliothèque de crates, crates.io, est officielle et maintenue par la communauté Rust, tandis que NPM n'est qu'un gestionnaire de paquets parmi d'autres, et n'est pas lié à ECMAScript.
      - On ne peut pas modifier ou supprimer un crate publié sous une version. Si je publie mon crate foobar dans sa version 0.1, je ne peut plus le modifier ou le supprimer. Si je veux changer mon crate, je dois publier une version 0.2, sachant que la version 0.1 sera toujours disponible pour la rétrocompatibilité.

      L'autre problème est celui des licences, et effectivement, ça peut être assez labyrinthique. C'est à chaque développeur de faire attention à ce qu'il utilise et à ne pas violer de licence. Mais finalement, le problème est le même quelle que soit la façon de procéder. Quand on inclut une bibliothèque dans un projet C++, on doit aussi faire attention à la licence, même si le téléchargement et la compilation de celle-ci ne se fait pas automatiquement à travers l'outil de build.

      Ce commentaire est libre de droit, vous pouvez le réutiliser comme bon vous semble.

      • [^] # Re: Dépendances

        Posté par . Évalué à 2.

        Disons qu'en C ou C++, atteindre plus d'une vingtaine de dépendances, c'est vraiment pas souvent. C'est vrai que c'est peut-être un frein au développement. Mais ça rend les choses un peu plus claires aussi.

        En bref : ça manque d'outil d'analyse de dépendances ;)

        • [^] # Re: Dépendances

          Posté par . Évalué à 4.

          Cet outil existe en Rust, il s'appelle cargo-tree:

          $ cargo tree
          taptempo v0.1.0 (file:///home/boiethios/Programmation/tempotap)                     
          [dependencies]
          ├── circ-queue v0.0.1 (https://gitlab.com/Boiethios/circ-queue.git#886bcbc0)
          └── structopt v0.2.4
              [dependencies]
              ├── clap v2.30.0
              │   [dependencies]
              │   ├── bitflags v1.0.1
              │   ├── textwrap v0.9.0
              │   │   [dependencies]
              │   │   └── unicode-width v0.1.4
              │   └── unicode-width v0.1.4 (*)
              └── structopt-derive v0.2.4
                  [dependencies]
                  ├── proc-macro2 v0.2.3
                  │   [dependencies]
                  │   └── unicode-xid v0.1.0
                  ├── quote v0.4.2
                  │   [dependencies]
                  │   └── proc-macro2 v0.2.3 (*)
                  └── syn v0.12.13
                      [dependencies]
                      ├── proc-macro2 v0.2.3 (*)
                      ├── quote v0.4.2 (*)
                      └── unicode-xid v0.1.0 (*)
          

          Ce commentaire est libre de droit, vous pouvez le réutiliser comme bon vous semble.

Suivre le flux des commentaires

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