Journal PullRequest d'une application en Rust

Posté par  (site web personnel) . Licence CC By‑SA.
18
16
mar.
2024

Sommaire

Si vous avez la flemme de lire, n'hésitez pas à aller directement à la fin. Je viens d'écrire un premier vrai programme en Rust et j'aimerais avoir des retours dessus. C'est l'objectif de ce journal.

Le commencement

J'utilise BackupPC pour sauvegarder mes données. BackupPC est un logiciel de sauvegarde qui se connecte à différents ordinateurs en SSH et utilise rsync pour sauvegarder les données. Il fonctionne parfaitement avec des ordinateurs Linux et un peu moins bien sur des ordinateurs Windows où il faut installer un rsyncd/Cygwin (les données ne sont pas protégées par SSH).

Par ailleurs, je développe mon propre logiciel de sauvegarde. C'est un challenge personnel que je me suis donné pour répondre à mes propres besoins (et aussi pour le plaisir).

Mon premier prototype est écrit en TypeScript et utilise rsync couplé avec Btrfs pour faire des sauvegardes incrémentales. Malheureusement, quelques problèmes liés à l'utilisation de Btrfs m'ont fait abandonner ce prototype (problème avec la création d'un grand nombre de snapshots et un système de fichier un peu trop plein). J'en parle dans un article sur mon blog.

Je me suis donc tourné vers l'écriture de mon propre pool de stockage. J'ai donc fait un second prototype, toujours en TypeScript, pour tester mon idée. Je suis content du résultat, mais les limites du moteur JavaScript ne font de ce prototype qu'un prototype. Là aussi j'en parle dans un autre article de mon blog.

Je vais donc réécrire la partie la plus importante de ce programme en Rust. Pourquoi Rust ? Dans mon enfance, j'ai adoré faire du C/C++. Je me souviens d'avoir programmé un petit IDE en C++ avec Qt. Lors du développement en C++, il m'arrivait parfois de me retrouver avec des fuites de mémoire, des segmentation faults, ainsi que des problèmes de concurrence d'accès aux données.

C++ m'a appris énormément sur le fonctionnement d'une machine, la gestion de la mémoire, la gestion du multithreading, …. Tout le monde devrait commencer par ce langage :D.

Rust est un langage qui a été conçu pour éviter ces problèmes. Après lecture de la documentation, j'ai adoré le concept. Du coup, j'ai décidé que ce serait une très bonne idée d'apprendre à l'utiliser. (Surtout qu'on en entend beaucoup parler en ce moment).

Vous trouvez que je digresse beaucoup ? C'est possible.

Retournons à notre programme.

Je me suis dit qu'avant d'écrire la gestion de mon pool de stockage en Rust, je souhaitais faire un script qui me permette de migrer le contenu de mon pool de stockage de BackupPC vers mon nouveau pool de stockage. Et de le faire en Rust. Pour cela, j'ai besoin de comprendre comment fonctionne le pool de stockage de BackupPC. De plus, faire du reverse engineering de BackupPC sera amusant (il y a le code source donc ce n'est pas trop compliqué).

Description du pool de stockage de BackupPC

La documentation de BackupPC décrit déjà pas mal de choses:

Ensuite, pour avoir les détails, il faut aller lire le code source en C qui sert de liaison avec le code en Perl. En effet, BackupPC est écrit en Perl et utilise un rsync modifié pour stocker les fichiers dans le pool. La bibliothèque sert donc au rsync modifié et au code Perl pour lire et écrire dans le pool.

Le format des fichiers compressés

Voici la description du fichier compressé d'après la documentation (traduction libre):

Le format de fichier compressé est généré par Compress::Zlib::deflate avec une modification mineure, mais importante.
Comme Compress::Zlib::inflate gonfle entièrement son argument en mémoire, il pourrait prendre de grandes quantités de mémoire s'il gonflait un fichier très compressé. Par exemple, un fichier de 200 Mo de 0x0 bytes se compresse à environ 200 Ko. Si Compress::Zlib::inflate était appelé avec ce seul tampon de 200 Ko, il aurait besoin d'allouer 200 Mo de mémoire pour retourner le résultat.

BackupPC surveille comment un fichier se compresse efficacement. Si un gros fichier a une compression très élevée (ce qui signifie qu'il utilisera trop de mémoire lorsqu'il sera gonflé), BackupPC appelle la méthode flush(), qui termine proprement la compression en cours. BackupPC commence alors une autre définition et ajoute simplement le fichier de sortie. Ainsi, le format de fichier compressé de BackupPC est une ou plusieurs définitions/flushes concaténées. Les ratios spécifiques que BackupPC utilise sont que si un morceau de 6 Mo se compresse à moins de 64 Ko, alors un flush sera effectué.

Donc, pour notre cas, il faut qu'on puisse lire un fichier compressé comme une suite de fichiers compressés concaténés les uns à la suite des autres.

Le format des fichiers d'attributs

Dans la documentation on y décrit que dans la version 4, on retrouve un fichier attrib_33fe8f9ae2f5cedbea63b9d3ea767ac0 dans les différents dossiers du PC. Le nom du
fichier contient le hash du fichier d'attributs que l'on peut retrouver dans le pool de stockage.

Il faut donc, pour accéder à un fichier du pool de stockage, aller dans le dossier __TOPDIR__/pc et lire le nom du fichier d'attribut pour retrouver le hash du fichier compressé correspondant au nom du dossier que l'on veut lire. Enfin, il faut lire le fichier compressé pour obtenir les données."

Le contenu du fichier d'attribut n'est pas décrit. Mais on peut le retrouver dans le fichier bpc_attribs.c.

Le fichier d'attribut est encodé en binaire. C'est une suite de varint (entier encodé sur un nombre variable d'octet). On peut décrire le fichier comme suite :

0x17565353 # Numéro magique

# Pour chaque fichier
varint longueur du nom du fichier
string nom du fichier
varint nombre d'entrées de type xattr
varint type de fichier
varint date du fichier
varint mode UNIX du fichier
varint uid du fichier
varint gid du fichier
varint taille du fichier
varint inode du fichier sauvegardé
varint niveau de compression du fichier
varint nombre de liens physiques sur le fichier
varint longueur du hash du fichier
buffer hash du fichier (MD5 utilisé pour le retrouver dans le pool)

# Pour chaque entrée de type xattr
varint longueur du nom de l'entrée
buffer nom de l'entrée
varint longueur de la valeur de l'entrée
buffer valeur de l'entrée

Il ne me reste donc plus qu'à reproduire tout cela en Rust.

Le développement en Rust

Pour développer le programme, j'ai posé quelques limites. Je me concentre uniquement sur la lecture des fichiers du pool qui sont en version 4. Ma version de BackupPC est en version 4, et j'ai migré tout le pool de stockage de la version 3. Je n'ai donc pas de données pour tester mon programme.

Les fichiers compressés

J'ai donc commencé par faire un programme qui, à partir d'un hash, est capable de décompresser un fichier de ce pool. Pour décompresser un fichier BackupPC compressé avec zlib, j'ai souhaité utiliser la bibliothèque flate2, qui est l'alternative à la bibliothèque standard zlib de Rust.

La bibliothèque flate2 permet de décompresser un fichier en utilisant la notion de buffer Rust. La version simple pour décompresser un fichier du pool est donc:

use flate2::bufread::ZlibDecoder;
use std::fs::File;

fn main() {
    let f = File::open("33fe8f9ae2f5cedbea63b9d3ea767ac0").unwrap();
    let b = BufReader::new(f);
    let mut z = ZlibEncoder::new(b);
    let mut buffer = Vec::new();
    z.read_to_end(&mut buffer).unwrap();
    println!("{:?}", buffer);
}

Malheureusement, ce code ne fonctionne pas. En effet, le fichier compressé est un ensemble de fichiers compressés. Pour un fichier relativement long (plusieurs Mo), il est possible que le fichier compressé soit compressé en plusieurs morceaux. De plus, il faut éviter de lire l'ensemble du fichier compressé en mémoire pour éviter de consommer toute la mémoire.

Lire le fichier bpc_fileZIO.c permet de comprendre comment sont encodés les fichiers compressés.

Lors de la lecture d'un fichier compressé, si la lecture de la compression s'arrête alors il faut reprendre la suite. On peut voir aussi le bout de code suivant:

// https://github.com/backuppc/backuppc-xs/blob/master/bpc_fileZIO.c#L219C15-L237C18
if ( fd->strm.next_in[0] == 0xd6 || fd->strm.next_in[0] == 0xd7 ) {
    /*
     * Flag 0xd6 or 0xd7 means this is a compressed file with
     * appended md4 block checksums for rsync.  Change
     * the first byte back to 0x78 and proceed.
     */
    fd->strm.next_in[0] = 0x78;
} else if ( fd->strm.next_in[0] == 0xb3 ) {
    /*
     * Flag 0xb3 means this is the start of the rsync
     * block checksums, so consider this as EOF for
     * the compressed file.  Also seek the file so
     * it is positioned at the 0xb3.
     */
    fd->eof = 1;
    /* TODO: check return status */
    lseek(fd->fd, -fd->strm.avail_in, SEEK_CUR);
    fd->strm.avail_in = 0;
}

Soit, si en début de flux, on trouve 0xd6 ou 0xd7, il faut changer le premier octet en 0x78 et continuer la lecture. En fin de flux, si on trouve 0xb3, on considère que c'est la fin du fichier compressé.

La bibliothèque flate2 ne permet pas de faire cela simplement. Il a donc fallu que je lise le code de flate2 pour comprendre comment je pouvais agir pour lire un fichier compressé de BackupPC.

Le code suivant de la bibliothèque flate2 permet de voir que flate2 a besoin, dans son constructeur, d'un BufRead pour lire un fichier compressé. Il utilise pour cela la méthode fill_buf pour remplir un tampon et la méthode consume pour consommer le tampon.

impl<R: Read> Read for BufReader<R> {
    fn read(&mut self, buf: &mut [u8]) -> io::Result<usize> {
        // If we don't have any buffered data and we're doing a massive read
        // (larger than our internal buffer), bypass our internal buffer
        // entirely.
        if self.pos == self.cap && buf.len() >= self.buf.len() {
            return self.inner.read(buf);
        }
        let nread = {
            let mut rem = self.fill_buf()?;
            rem.read(buf)?
        };
        self.consume(nread);
        Ok(nread)
    }
}

J'ai donc commencé par écrire un adaptateur qui vient s'insérer entre le fichier et le décompresseur. Cet adaptateur a pour but de reproduire le comportement et de remplacer les octets en début de fichier.

struct InterpretAdapter<R: BufRead> {
    inner: R,
    first: bool,
    temp: Option<Vec<u8>>,
}
...
impl<R: BufRead> BufRead for InterpretAdapter<R> {
    fn fill_buf(&mut self) -> io::Result<&[u8]> {
        if self.temp.is_none() {
            let buf = self.inner.fill_buf()?;
            let mut buf = buf.to_vec();

            if self.first && !buf.is_empty() {
                self.first = false;

                if buf[0] == 0xd6 || buf[0] == 0xd7 {
                    buf[0] = 0x78;
                } else if buf[0] == 0xb3 {
                    // EOF
                    buf = Vec::new();
                }
            }

            self.temp = Some(buf);
        }

        Ok(self.temp.as_ref().unwrap())
    }

    fn consume(&mut self, amt: usize) {
        if amt > 0 {
            self.temp = None;
            self.inner.consume(amt);
        }
    }
}

Cet adaptateur vient s'insérer entre le BufReader et le ZLibDecoder. Du point de vue du ZLibDecoder, il doit se comporter comme un BufReader et donc répondre aux méthodes fill_buf et consume. Dans ces méthodes, je dois retourner un buffer. Je ne peux pas retourner le buffer du BufReader après une modification car ce dernier est une référence et est en
lecture seule. Je dois donc copier le contenu (je n'ai pas trouvé de meilleure manière de faire cela).

Ensuite, pour créer le Reader utilisé pour lire le fichier compressé, on boucle. On décompresse le contenu et si le décodeur retourne 0, on passe à un nouveau décodeur pour lire le morceau suivant.

pub struct BackupPCReader<R: Read> {
    decoder: Option<ZlibDecoder<InterpretAdapter<BufReader<R>>>>,
}

fn read(&mut self, buf: &mut [u8]) -> io::Result<usize> {
    loop {
        let decoder = self.decoder.as_mut();
        match decoder {
            None => return Ok(0),
            _ => {}
        }

        let decoder_read_result = decoder.unwrap().read(buf);

        let count = match decoder_read_result {
            Ok(count) => count
            Err(e) => {
                return Err(e);
            }
        };

        if count != 0 {
            return Ok(count);
        }

        if count == 0 {
            let decoder = self.decoder.take();
            match decoder {
                Some(decoder) => {
                    let mut reader = decoder.into_inner();
                    if reader.fill_buf()?.len() == 0 {
                        return Ok(0);
                    }
                    reader.reset();

                    self.decoder = Some(ZlibDecoder::new(reader));
                }
                None => {}
            }
        }
    }
}

Les fichiers d'attributs

Je me suis ensuite attelé au décodage des fichiers d'attributs. En réécrivant le code, j'ai découvert que certains nombres étaient mal encodés et donc causaient des erreurs de décodage, que j'ai dû gérer avec plus de souplesse.

Pour lire un Varint, j'ai pu ajouter un trait au Read:

pub trait VarintRead: Read {
    fn read_varint(&mut self) -> io::Result<u64> {
        let mut result = 0;
        let mut shift = 0;

        loop {
            let mut buf: [u8; 1] = [0u8; 1];
            self.read_exact(&mut buf)?;

            let byte = buf[0];
            let val = (byte & 0x7F) as u64;
            if shift >= 64 || val << shift >> shift != val {
                eprintln!("Varint too large: probably corrupted data");
                return Err(io::Error::new(
                    io::ErrorKind::InvalidData,
                    "Varint too large: probably corrupted data",
                ));
            }

            result |= val << shift;
            if byte & 0x80 == 0 {
                return Ok(result);
            }
            shift += 7;
        }
    }
}

Dans certains cas, je me retrouve donc avec le message d'erreur Varint too large: probably corrupted data. Quand j'ai ce message pour le fichier, je peux confirmer sur BackupPC que le fichier est également corrompu:

Corrupted file

BackupPC a aussi ce petit problème de décodage :) donc tout va bien. Mais par contre, la question est : est-ce que mes données sont corrompues ou est-ce que c'est un bug de BackupPC ?

Le driver fuse

L'étape suivante a été d'écrire un driver FUSE qui mélange tout cela. La lecture des fichiers d'attributs pour recréer la structure du système de fichier (qui sont dans le pool et compressés). Et la lecture des fichiers compressés pour les données.

Pour faire la partie du système de fichier, j'ai utilisé la librairie fuser. Le cœur ayant été écrit. La partie système de fichier consiste surtout à décoder le chemin fourni par FUSE, à lire le fichier d'attributs, et à lire le fichier compressé.

Pour faire ce décodage, dans le fichier view.rs,
j'ai ajouté des tests unitaires. Ces tests unitaires m'ont permis d'itérer plus rapidement pour construire le système de fichier.

Une fois le système de fichier construit, il m'a fallu le tester avec des tests réels. Le premier test a été de parcourir tout le système de fichier pour voir si la partie attribut fonctionnait bien. Pour cela, j'ai utilisé le programme filelight. Ce programme permet de parcourir le système de fichier et de voir la taille des fichiers.

Enfin, pour tester que j'étais capable de lire tous les fichiers du système de fichier, j'ai écrit un petit script sh.

#!/bin/bash

# Fichiers de sortie
output_file="md5sums.txt"
error_file="errors.txt"

# Parcourir tous les fichiers du système de fichiers
find /home/phoenix/tmp/test -type f -print0 | while IFS= read -r -d '' file; do
    # Essayer de calculer le hash MD5 du fichier
    if md5sum "$file" >> "$output_file"; then
        echo "Processed $file"
    else
        # Si le fichier ne peut pas être lu, écrire le nom du fichier dans le fichier d'erreur
        echo "Failed to process $file" >> "$error_file"
    fi
done

Développer avec Rust

Ce que j'ai apprécié avec Rust

En écrivant ce petit programme Rust, je me suis senti protégé. J’apprécie de pouvoir écrire du code bas niveau sans les soucis de devoir gérer la mémoire comme en C.

La documentation de Rust est bien faite et permet de progresser rapidement.

Je trouve que syntaxiquement le Rust est très lisible (tant qu'on n'utilise pas les durées de vie).

Ce que j'ai trouvé étrange avec Rust

L'idée de Rust est qu'à la compilation, on ne peut pas avoir de problème de concurrence, de fuite mémoire, de segmentation.

Mais en cherchant comment faire certaines opérations sur internet, je me retrouve avec du code contenant le mot unsafe. Je me suis refusé à l'utiliser mais pourquoi faire un langage qui se veut sûr et qui permet de faire des choses unsafe ?

De la même manière, j'ai l'impression que Rust nous oblige souvent à utiliser le clonage d'objet. Parfois j'aurais préféré ne pas cloner l'objet mais faire une référence (pour des questions de performances), mais là on se retrouve à gérer des durées de vie. Parfois je n'ai pas trouvé comment éviter le clonage simplement.

La syntaxe des durées de vie est complexe et rend le code peu lisible. Là aussi, je trouve dommage que Rust ne soit pas capable de gérer ces durées de vie tout seul.

Pour une partie du développement, j'ai fait des tests unitaires. L'écriture des tests unitaires n'a pas été simple (surtout quand on est habitué à des bibliothèques comme Jest en JavaScript).
Faire un mock des interfaces et des autres fichiers n'est pas quelque chose de simple et n'est pas natif à Rust. J'ai dû utiliser une bibliothèque dédiée (mockall).

Les mocks m'ont forcé à créer des structures alors qu'à l'origine j'avais des méthodes. Est-ce une bonne pratique de faire des structures pour tout ? Je suis parti sur des méthodes statiques mais la gestion des mocks sur les méthodes statiques ne permet pas de faire des tests unitaires en parallèle dans différents tests. Pour lancer les tests unitaires, je suis obligé de lancer un test à la fois:

RUST_TEST_THREADS=1 cargo test

De plus, je trouve étrange (par habitude) de devoir mettre les tests dans le même fichier que le code applicatif.

 Ma PR

L'idée de ce billet, c'est de vous proposer de relire mon code. De me dire ce que vous en pensez. En effet, débutant en Rust, j'aimerais prendre les bons réflexes dès le début. J'ai donc besoin de vos retours pour m'améliorer.

Voici le lien vers la PR : https://github.com/phoenix741/backuppc_pool_reader/pull/1.

N’hésitez pas à commenter directement sur la PR, critiquer le code et surtout conseiller sur comment l'améliorer. Si vous ne souhaitez pas commenter sur Github, le code se trouve aussi sur mon Gitea : https://gogs.shadoware.org/phoenix/backuppc_pool/pulls/1.

Autre question, si parmi vous il y a des personnes qui font des PR publiques et qui n'utilisent pas GitHub. Qu'utilisez-vous ?

Je me permets de publier ce billet sur mon blog en partie plus tard (les parties intéressantes :))

  • # Gestion mémoire etc.

    Posté par  . Évalué à 10 (+9/-0).

    L'idée de Rust est qu'à la compilation, on ne peut pas avoir de problème de concurrence, de fuite mémoire, de segmentation.

    Mais en cherchant comment faire certaines opérations sur internet, je me retrouve avec du code contenant le mot unsafe. Je me suis refusé à l'utiliser mais pourquoi faire un langage qui se veut sûr et qui permet de faire des choses unsafe ?

    Dans un certain nombre de situations, qu'on le veuille ou pas, il est absolument nécessaire de faire des choses qui ne peuvent pas être vérifiées à la compilation.

    Par exemple :

    • Appeler une fonction dans une bibliothèque C (i.e., faire de la FFI) Le compilateur ne peut pas vérifier qu'elle est bien appelée ni que l'interaction avec le modèle de gestion mémoire de la bibliothèque est correcte.
    • Sauter un bound check (vérification que i est entre 0 et t.len() quand on fait t[i]) dans une boucle appelée des millions de fois, quand on est sûr que l'indice est bien dans les bornes.
    • Faire des appels à l'allocateur de mémoire et travailler avec ce qu'il donne, pour implémenter une structure de données.

    unsafe est l'une des fonctionnalités les plus intéressantes du langage. Ça permet d'écrire quand même toutes ces choses en Rust, plutôt que dans des langages qui font très peu de vérifications comme le C, et quand on veut auditer la sécurité du code, il suffit de se concentrer sur les blocs unsafe plutôt que de regarder absolument tout. Par ailleurs, il y a des outils pour vérifier à l'exécution que le unsafe ne déclenche pas d'undefined behavior (Miri) et il y a des projets de recherche pour créer des outils de preuve formelle de sûreté de code unsafe (RustBelt).

    Sans unsafe, la bibliothèque standard std ne pourrait pas être elle-même écrite en Rust. Le noyau Linux n'aurait pas pu incorporer du code Rust. Et tu ne pourrais pas utiliser dans ton code la bibliothèque C fuse.

    La syntaxe des durées de vie est complexe et rend le code peu lisible. Là aussi, je trouve dommage que Rust ne soit pas capable de gérer ces durées de vie tout seul.

    Je ne trouve pas la syntaxe complexe. Les paramètres de lifetime sont déclarés exactement de la même manière que les paramètres de type, en mettant juste un ' devant.

    « Gérer les durées de vie tout seul »… difficile de savoir ce que tu entends par là. Si tu veux dire inférer les lifetimes dans les signatures, c'est tout à fait possible, de même que l'inférence de types — en fait il n'y a pas de distinction, c'est la même chose que l'inférence de types. Et le compilateur le fait bel et bien à l'intérieur d'une fonction. Mais il y a un choix de la part des concepteurs du langage d'exiger toujours que le type d'une fonction soit déclaré explicitement, pour qu'il soit facile de comprendre comment utiliser une fonction sans avoir à lire son code.

    Cela dit, l'élision des lifetimes allège très souvent les déclarations (par exemple fn id(x: &str) -> &str { x } au lieu de fn<'a> id(x: &'a str) -> &'a str { x }, les règles sont ici).

    De la même manière, j'ai l'impression que Rust nous oblige souvent à utiliser le clonage d'objet. Parfois j'aurais préféré ne pas cloner l'objet mais faire une référence (pour des questions de performances), mais là on se retrouve à gérer des durées de vie. Parfois je n'ai pas trouvé comment éviter le clonage simplement.

    Il faut un tout petit peu d'expérience, mais on trouve rarement des cas où le compilateur force vraiment un clonage non-nécessaire.

    Je n'y ai passé que quelques minutes, mais en parcourant les appels clone(), j'en ai déjà trouvé un certain nombre qui peuvent s'éviter simplement :

    diff --git a/src/filesystem.rs b/src/filesystem.rs
    index f416c02..2a2530d 100644
    --- a/src/filesystem.rs
    +++ b/src/filesystem.rs
    @@ -49,7 +49,7 @@ pub struct BackupPCFileAttribute {
     impl BackupPCFileAttribute {
         pub fn from_file_attribute(file: FileAttributes, child_ino: u64) -> Self {
             BackupPCFileAttribute {
    -            name: file.name.clone(),
    +            name: file.name,
                 attr: FileAttr {
                     ino: child_ino,
                     size: file.size,
    diff --git a/src/util.rs b/src/util.rs
    index 28b7b4f..3c4705c 100644
    --- a/src/util.rs
    +++ b/src/util.rs
    @@ -133,9 +133,6 @@ pub fn mangle(path_um: &str) -> String {
     ///
     /// A new iterable with only unique values
     pub fn unique<T: Eq + Hash + Clone>(iterable: impl IntoIterator<Item = T>) -> Vec<T> {
    -    let mut seen = HashSet::new();
    -    iterable
    -        .into_iter()
    -        .filter(|e| seen.insert(e.clone()))
    -        .collect()
    +    let unique_elts : HashSet<T> = HashSet::from_iter(iterable);
    +    unique_elts.into_iter().collect()
     }
    diff --git a/src/view.rs b/src/view.rs
    index 7a6abac..6b1d59d 100644
    --- a/src/view.rs
    +++ b/src/view.rs
    @@ -69,12 +69,12 @@ impl BackupPCView {
                 .filter_map(|share| {
                     let share_array = sanitize_path(&share);
                     if path.starts_with(&share_array) {
    -                    selected_share = Some(share.clone());
                         share_size = share_array.len();
    +                    selected_share = Some(share);
                         None
                     } else if path.eq(&share_array) {
    -                    selected_share = Some(share.clone());
                         share_size = share_array.len();
    +                    selected_share = Some(share);
                         None
                     } else if share_array.starts_with(path) {
                         Some(share_array[path.len()..][0].to_string())
    • [^] # Re: Gestion mémoire etc.

      Posté par  (site web personnel) . Évalué à 3 (+1/-0).

      Merci de tes commentaires.

      Je viens de tester les deux premiers, et cela passe…. Merci :) Probablement que c'est un reste de mes essais infructueux précédents.

      Par contre, dans le dernier exemple, j'ai l'erreur suivante:

         Compiling backuppc_pool v0.1.0 (/home/phoenix/Developpement/Shadoware.Org/Software/backuppc_pool)
      error[E0505]: cannot move out of `share` because it is borrowed
        --> src/view.rs:72:43
         |
      69 |             .filter_map(|share| {
         |                          ----- binding `share` declared here
      70 |                 let share_array = sanitize_path(&share);
         |                                                 ------ borrow of `share` occurs here
      71 |                 if path.starts_with(&share_array) {
      72 |                     selected_share = Some(share);
         |                                           ^^^^^ move out of `share` occurs here
      73 |                     share_size = share_array.len();
         |                                  ----------- borrow later used here
      
      error[E0505]: cannot move out of `share` because it is borrowed
        --> src/view.rs:76:43
         |
      69 |             .filter_map(|share| {
         |                          ----- binding `share` declared here
      70 |                 let share_array = sanitize_path(&share);
         |                                                 ------ borrow of `share` occurs here
      ...
      76 |                     selected_share = Some(share);
         |                                           ^^^^^ move out of `share` occurs here
      77 |                     share_size = share_array.len();
         |                                  ----------- borrow later used here
      
      For more information about this error, try `rustc --explain E0505`.
      error: could not compile `backuppc_pool` (bin "backuppc_pool") due to 2 previous errors
      

      L'erreur est probablement corrigeable autrement pour éviter le clone.

      Il faut aussi que je m’intéresse plus à la partie unsafe et au lifetime.

      Sur la partie lifetime, j'ai beau comprendre à quoi cela sert et comment ça marche en théorie, je ne suis pas encore à l'aise avec. (Quand je parlerai de le deviner automatiquement, je pensais à l’élision qui pour le coup n'est pas vraiment du devinage). Je vais creuser un peu plus cette partie.

      En tout cas je suis avide d'apprendre des experts en Rust, donc n’hésitez pas à me faire des retours :)

      • [^] # Re: Gestion mémoire etc.

        Posté par  . Évalué à 5 (+4/-0).

        Par contre, dans le dernier exemple, j'ai l'erreur suivante:

        Tu as oublié l'inversion des lignes selected_share = Some(share); et share_size = share_array.len();. Il faut d'abord finir d'utiliser share_array avant d'en transférer la propriété vers selected_share.

        Il faut aussi que je m’intéresse plus à la partie unsafe et au lifetime.

        La partie « unsafe » n'est pas utile pour 95% de la programmation en Rust. Mieux vaut réserver ça aux experts du langage qui en ont vraiment besoin et qui savent s'en servir correctement (comme les contributeurs à std), parce que c'est compliqué. Écrire du Rust unsafe correct est bien plus difficile qu'écrire du C correct. Il faut une compréhension fine de la sémantique du langage. Armin Ronacher a écrit un post de blog que je trouve intéressant à ce sujet.

        Les lifetimes, par contre, font partie des bases du langage.

        Sur la partie lifetime, j'ai beau comprendre à quoi cela sert et comment ça marche en théorie, je ne suis pas encore à l'aise avec. (Quand je parlerai de le deviner automatiquement, je pensais à l’élision qui pour le coup n'est pas vraiment du devinage). Je vais creuser un peu plus cette partie.

        Je ne sais pas si ça va t'aider, mais pour ma part j'ai compris comment fonctionne le système quand j'ai réalisé les choses suivantes, que je ne trouve pas très bien expliquées dans la plupart des tutoriels.

        D'abord, tout le monde parle du borrow checker et comme c'est magique, mais ce n'est pas le borrow checker qui fait tout. Par exemple, cette fonction ne compile pas : fn<'a>(x: &'a str) -> &'static str { x }. Et heureusement qu'elle ne compile pas, vu qu'on ne peut pas prendre une référence valable seulement pendant une durée déterminée et la transformer impunément en une référence valable pour toute l'exécution du programme ! Mais ce n'est pas le borrow checker qui vérifie ça, c'est juste le système de typage. Tout ce que fait le borrow checker, c'est l'inférence des lifetimes implicites à l'intérieur d'une fonction. Il ne remplace pas le système de typage.

        En fait, une bonne façon de comprendre les lifetimes, c'est de les voir comme des types fantômes. On peut imaginer une variante du langage qui aurait de l'héritage entre structs (à la programmation orientée objet), et où les références seraient implémentées comme ceci :

        struct Ref<T, U> { // correspond à « &'T U »
          ptr: *U, // le pointeur vers la valeur de type U
          token: T, // un « jeton de validité » de type T
        }

        Le code

        fn convert<'a, 'b: 'a>(x: &'b str) -> &'a str {
          x
        }

        est traduit en quelque chose comme ceci dans ce langage imaginaire :

        fn convert<T, U subtype of T>(x: Ref<U, str>) -> Ref<T, str> {
          let Ref { ptr, token } = x;
          Ref {
            ptr, // ptr est déjà de type *str, pas besoin de conversion
            token as T, // token est du type U, qui est sous-type de T, donc on peut le convertir en T
          }
        }

        et le code

        { // 'a
          let s = String::from("foo");
          let s_ref = &s; // référence valable pour 'a
          { // 'b
            let s_ref2 = s_ref; // référence valable pour 'b
          }
        }

        se traduit par

        {
          struct ValidityA;
        
          let s = String::from("foo");
          let s_ref = Ref { ptr: addr_of!(s), token: ValidityA };
        
          {
            struct ValidityB inherits ValidityA;
        
            let s_ref2 = convert::<ValidityA, ValidityB>(s_ref);
          }
        }

        L'héritage entre structs n'existe pas, ma syntaxe struct ValidityB inherits ValidityA est imaginaire, mais tu vois l'idée. (En fait, le sous-typage existe en Rust uniquement à cause des lifetimes.)

        Bref, fondamentalement, les lifetimes sont juste des paramètres de type comme les autres. En théorie, rien n'empêcherait que tous les lifetimes soient implémentés via des paramètres de type normaux. Le fait d'avoir une syntaxe différente est surtout une question de lisibilité.

    • [^] # Re: Gestion mémoire etc.

      Posté par  (Mastodon) . Évalué à 3 (+1/-0).

      À propos de unsafe, quand tu dis :

      Sauter un bound check (vérification que i est entre 0 et t.len() quand on fait t[i]) dans une boucle appelée des millions de fois, quand on est sûr que l'indice est bien dans les bornes.

      C'est très souvent faisable sans passer par unsafe.

      Le compilateur va générer un bound check uniquement dans les cas où il ne peut pas déterminer à la compilation si l'accès risque d'être hors bornes. Si on ajoute une vérification explicite qui lui permet d'être sûr qu'aucun accès hors bornes n'est possible, il n'y aura pas de bound check.

      Une autre façon est de modifier la boucle pour utiliser un itérateur, quand c'est possible.

      • [^] # Re: Gestion mémoire etc.

        Posté par  . Évalué à 1 (+0/-0).

        Oui, tu as absolument raison, j'aurais dû le préciser. Il y a quand même un certain nombre de cas où le compilateur ne peut pas déduire que l'indice est dans les bornes, comme quand on stocke des indices dans un tableau à l'intérieur d'un autre tableau, mais ce n'est pas si courant.

  • # Clippy est un bon début

    Posté par  . Évalué à 4 (+4/-0).

    Bonjour, pour commencer je te conseille d'utiliser clippy :
    A collection of lints to catch common mistakes and improve your Rust code.

    ça ne fait pas tout, mais c'est un très bon compagnon pour apprendre, corriger des erreurs de "débutant", suivre les bonnes pratiques, etc.

    Certaine corrections peuvent même être faites automatiquement, mais je conseille plutôt de faire les corrections à la main, pour mieux apprendre.

    • [^] # Re: Clippy est un bon début

      Posté par  (site web personnel) . Évalué à 4 (+2/-0).

      pour commencer je te conseille d'utiliser clippy :

      à ne pas confondre avec clippy

    • [^] # Re: Clippy est un bon début

      Posté par  . Évalué à 3 (+3/-0).

      Je plussoie pour clippy.

      Pour apprendre le rust, je le lançais en "pedantic", il faudra quand même trier ce qui est important dans la sortie: cargo clippy --all -- -W clippy::all -W clippy::pedantic.

      En lecture (très) rapide, je vois un footgun assez réccurent sur l'usage des adaptateurs en *_or(todo!()) (unwrap_or, ok_or, …): il vaut mieux utiliser les adptateurs *_or_else(|| todo!()) dès que ce que tu veux mettre dans le todo!() a des effets de bords ou fait des allocations. Plus d'infos ici: https://rust-lang.github.io/rust-clippy/master/index.html#/or_fun_call

      • [^] # Re: Clippy est un bon début

        Posté par  . Évalué à 1 (+1/-0).

        Ah cool l'option clippy::pedantic, je ne connaissais pas.

        Sinon l'option --profile=test est pratique pour que check et/ou clippy donne aussi des indications sur le code de test (ce qui n'est pas le cas par défaut).

      • [^] # Re: Clippy est un bon début

        Posté par  . Évalué à 1 (+0/-0).

        Pour reprendre ce que je disais sur les fonctions en *_or un des exemples que j'avais vu était sur ce type de fonctions:

        let file = attributes
        .into_iter()
        .find(|f| f.name.eq(*filename))
        .ok_or(std::io::Error::new(
        std::io::ErrorKind::NotFound,
        format!("File not found (not in attributs): {}", path.join("/")),
        ))?;

        L'expression passée au ok_or() va être évaluée à chaque fois, y compris quand le résultat était un Ok. Pour s'en prémunir, il vaut mieux par la version ok_or_else qui prend un closure dès que le paramètre d'un ok_or (ou d'un unwrap_or) est non trivial.

        Si tu veux t'en convaincre, voici un exemple spécifiquement forgé:

        fn main() {
        let mut v = vec![];
        Some(1).unwrap_or({
        v.push("foo");
        1
        });
        Some(1).unwrap_or_else(|| {
        v.push("bar");
        1
        });
        println!("{v:?}");
        assert_eq!(v, vec!["foo"])
        }

Envoyer un commentaire

Suivre le flux des commentaires

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