Amélioration de notre projet d'entrée/sortie

Grâce à ces nouvelles connaissances sur les itérateurs, nous pouvons améliorer le projet d'entrée/sortie du chapitre 12 en utilisant des itérateurs pour rendre certains endroits du code plus clairs et plus concis. Voyons comment les itérateurs peuvent améliorer notre implémentation de la fonction Config::new et de la fonction rechercher.

Supprimer l'appel à clone à l'aide d'un itérateur

Dans l'encart 12-6, nous avions ajouté du code qui prenait une slice de String et qui créait une instance de la structure Config en utilisant les indices de la slice et en clonant les valeurs, permettant ainsi à la structure Config de posséder ces valeurs. Dans l'encart 13-24, nous avons reproduit l'implémentation de la fonction Config::new telle qu'elle était dans l'encart 12-23 à la fin du chapitre 12 :

Fichier : src/lib.rs

use std::env;
use std::error::Error;
use std::fs;

pub struct Config {
    pub recherche: String,
    pub nom_fichier: String,
    pub sensible_casse: bool,
}

impl Config {
    pub fn new(args: &[String]) -> Result<Config, &'static str> {
        if args.len() < 3 {
            return Err("il n'y a pas assez d'arguments");
        }

        let recherche = args[1].clone();
        let nom_fichier = args[2].clone();

        let sensible_casse = env::var("MINIGREP_INSENSIBLE_CASSE").is_err();

        Ok(Config {
            recherche,
            nom_fichier,
            sensible_casse,
        })
    }
}

pub fn run(config: Config) -> Result<(), Box<dyn Error>> {
    let contenu = fs::read_to_string(config.nom_fichier)?;

    let resultats = if config.sensible_casse {
        rechercher(&config.recherche, &contenu)
    } else {
        rechercher_insensible_casse(&config.recherche, &contenu)
    };

    for ligne in resultats {
        println!("{}", ligne);
    }

    Ok(())
}

pub fn rechercher<'a>(recherche: &str, contenu: &'a str) -> Vec<&'a str> {
    let mut resultats = Vec::new();

    for ligne in contenu.lines() {
        if ligne.contains(recherche) {
            resultats.push(ligne);
        }
    }

    resultats
}

pub fn rechercher_insensible_casse<'a>(
    recherche: &str,
    contenu: &'a str,
) -> Vec<&'a str> {
    let recherche = recherche.to_lowercase();
    let mut resultats = Vec::new();

    for ligne in contenu.lines() {
        if ligne.to_lowercase().contains(&recherche) {
            resultats.push(ligne);
        }
    }

    resultats
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn sensible_casse() {
        let recherche = "duct";
        let contenu = "\
Rust:
safe, fast, productive.
Pick three.
Duct tape.";

        assert_eq!(vec!["safe, fast, productive."], rechercher(recherche, contenu));
    }

    #[test]
    fn case_insensitive() {
        let recherche = "rUsT";
        let contenu = "\
Rust:
safe, fast, productive.
Pick three.
Trust me.";

        assert_eq!(
            vec!["Rust:", "Trust me."],
            rechercher_insensible_casse(recherche, contenu)
        );
    }
}

Encart 13-24 : reproduction de la fonction Config::new de la fin du chapitre 12

À ce moment-là, nous avions dit de ne pas s'inquiéter des appels inefficaces à clone parce que nous les supprimerions à l'avenir. Et bien, ce moment est venu !

Nous avions besoin de clone ici parce que nous avons une slice d'éléments String dans le paramètre args, mais la fonction new ne possède pas args. Pour renvoyer la propriété d'une instance de Config, nous avons dû cloner les valeurs des champs recherche et nom_fichier de Config afin que cette instance de Config puisse prendre possession de ces valeurs.

Avec nos nouvelles connaissances sur les itérateurs, nous pouvons changer la fonction new pour prendre possession d'un itérateur passé en argument au lieu d'emprunter une slice. Nous utiliserons les fonctionnalités des itérateurs à la place du code qui vérifie la taille de la slice et qui utilise les indices des éléments précis. Cela clarifiera ce que la fonction Config::new fait car c'est l'itérateur qui accédera aux valeurs.

Une fois que Config::new prend possession de l'itérateur et cesse d'utiliser les opérations avec les indices et d'emprunter les données, nous pouvons déplacer les valeurs String de l'iterator dans Config plutôt que de faire appel à clone et de créer par conséquent de nouvelles allocations.

Utiliser directement l'itérateur retourné

Ouvrez le fichier src/main.rs de votre projet d'entrée/sortie, qui devrait ressembler à ceci :

Fichier : src/main.rs

use std::env;
use std::process;

use minigrep::Config;

fn main() {
    let args: Vec<String> = env::args().collect();

    let config = Config::new(&args).unwrap_or_else(|err| {
        eprintln!("Problème rencontré lors de l'interprétation des arguments : {}", err);
        process::exit(1);
    });

    // -- partie masquée ici --

    if let Err(e) = minigrep::run(config) {
        eprintln!("Erreur applicative : {}", e);

        process::exit(1);
    }
}

Nous allons changer le début de la fonction main que nous avions dans l'encart 12-24 pour le code dans l'encart 13-25. Ceci ne compilera pas encore jusqu'à ce que nous mettions également à jour Config::new.

Fichier : src/main.rs

use std::env;
use std::process;

use minigrep::Config;

fn main() {
    let config = Config::new(env::args()).unwrap_or_else(|err| {
        eprintln!("Problème rencontré lors de l'interprétation des arguments : {}", err);
        process::exit(1);
    });

    // -- partie masquée ici --

    if let Err(e) = minigrep::run(config) {
        eprintln!("Erreur applicative : {}", e);

        process::exit(1);
    }
}

Encart 13-25 : on passe directement la valeur de retour de env::args à Config::new.

La fonction env::args retourne un itérateur ! Plutôt que de collecter les valeurs de l'itérateur dans un vecteur et de passer ensuite une slice à Config::new, nous passons maintenant la possession de l'itérateur de env::args directement à Config::new.

Ensuite, nous devons mettre à jour la définition de Config::new. Dans le fichier src/lib.rs de votre projet d'entrée/sortie, modifions la signature de Config::new pour qu'elle ressemble à l'encart 13-26. Ceci ne compilera pas encore car nous devons mettre à jour le corps de la fonction.

Fichier : src/lib.rs

use std::env;
use std::error::Error;
use std::fs;

pub struct Config {
    pub recherche: String,
    pub nom_fichier: String,
    pub sensible_casse: bool,
}

impl Config {
    pub fn new(mut args: env::Args) -> Result<Config, &'static str> {
        // -- partie masquée ici --
        if args.len() < 3 {
            return Err("il n'y a pas assez d'arguments");
        }

        let recherche = args[1].clone();
        let nom_fichier = args[2].clone();

        let sensible_casse = env::var("MINIGREP_INSENSIBLE_CASSE").is_err();

        Ok(Config {
            recherche,
            nom_fichier,
            sensible_casse,
        })
    }
}

pub fn run(config: Config) -> Result<(), Box<dyn Error>> {
    let contenu = fs::read_to_string(config.nom_fichier)?;

    let resultats = if config.sensible_casse {
        rechercher(&config.recherche, &contenu)
    } else {
        rechercher_insensible_casse(&config.recherche, &contenu)
    };

    for ligne in resultats {
        println!("{}", ligne);
    }

    Ok(())
}

pub fn rechercher<'a>(recherche: &str, contenu: &'a str) -> Vec<&'a str> {
    let mut resultats = Vec::new();

    for ligne in contenu.lines() {
        if ligne.contains(recherche) {
            resultats.push(ligne);
        }
    }

    resultats
}

pub fn rechercher_insensible_casse<'a>(
    recherche: &str,
    contenu: &'a str,
) -> Vec<&'a str> {
    let recherche = recherche.to_lowercase();
    let mut resultats = Vec::new();

    for ligne in contenu.lines() {
        if ligne.to_lowercase().contains(&recherche) {
            resultats.push(ligne);
        }
    }

    resultats
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn sensible_casse() {
        let recherche = "duct";
        let contenu = "\
Rust:
safe, fast, productive.
Pick three.
Duct tape.";

        assert_eq!(vec!["safe, fast, productive."], rechercher(recherche, contenu));
    }

    #[test]
    fn case_insensitive() {
        let recherche = "rUsT";
        let contenu = "\
Rust:
safe, fast, productive.
Pick three.
Trust me.";

        assert_eq!(
            vec!["Rust:", "Trust me."],
            rechercher_insensible_casse(recherche, contenu)
        );
    }
}

Encart 13-26 : mise à jour de la signature de Config::new pour recevoir un itérateur

La documentation de la bibliothèque standard de la fonction env::args indique que le type de l'itérateur qu'elle renvoie est std::env::Args. Nous avons mis à jour la signature de la fonction Config::new pour que le paramètre args ait le type std::env::Args au lieu de &[String]. Etant donné que nous prenons possession de args et que nous allons muter args en itérant dessus, nous pouvons ajouter le mot-clé mut dans la spécification du paramètre args pour le rendre mutable.

Utilisation des méthodes du trait Iterator au lieu des indices

Corrigeons ensuite le corps de Config::new. La documentation de la bibliothèque standard explique aussi que std::env::Args implémente le trait Iterator, donc nous savons que nous pouvons appeler la méthode next dessus ! L'encart 13-27 met à jour le code de l'encart 12-23 afin d'utiliser la méthode next :

Fichier : src/lib.rs

use std::env;
use std::error::Error;
use std::fs;

pub struct Config {
    pub recherche: String,
    pub nom_fichier: String,
    pub sensible_casse: bool,
}

impl Config {
    pub fn new(mut args: env::Args) -> Result<Config, &'static str> {
        args.next();

        let recherche = match args.next() {
            Some(arg) => arg,
            None => return Err("nous n'avons pas de chaîne de caractères"),
        };

        let nom_fichier = match args.next() {
            Some(arg) => arg,
            None => return Err("nous n'avons pas de nom de fichier"),
        };

        let sensible_casse = env::var("MINIGREP_INSENSIBLE_CASSE").is_err();

        Ok(Config {
            recherche,
            nom_fichier,
            sensible_casse,
        })
    }
}

pub fn run(config: Config) -> Result<(), Box<dyn Error>> {
    let contenu = fs::read_to_string(config.nom_fichier)?;

    let resultats = if config.sensible_casse {
        rechercher(&config.recherche, &contenu)
    } else {
        rechercher_insensible_casse(&config.recherche, &contenu)
    };

    for ligne in resultats {
        println!("{}", ligne);
    }

    Ok(())
}

pub fn rechercher<'a>(recherche: &str, contenu: &'a str) -> Vec<&'a str> {
    let mut resultats = Vec::new();

    for ligne in contenu.lines() {
        if ligne.contains(recherche) {
            resultats.push(ligne);
        }
    }

    resultats
}

pub fn rechercher_insensible_casse<'a>(
    recherche: &str,
    contenu: &'a str,
) -> Vec<&'a str> {
    let recherche = recherche.to_lowercase();
    let mut resultats = Vec::new();

    for ligne in contenu.lines() {
        if ligne.to_lowercase().contains(&recherche) {
            resultats.push(ligne);
        }
    }

    resultats
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn sensible_casse() {
        let recherche = "duct";
        let contenu = "\
Rust:
safe, fast, productive.
Pick three.
Duct tape.";

        assert_eq!(vec!["safe, fast, productive."], rechercher(recherche, contenu));
    }

    #[test]
    fn case_insensitive() {
        let recherche = "rUsT";
        let contenu = "\
Rust:
safe, fast, productive.
Pick three.
Trust me.";

        assert_eq!(
            vec!["Rust:", "Trust me."],
            rechercher_insensible_casse(recherche, contenu)
        );
    }
}

Encart 13-27 : changement du corps de Config::new afin d'utiliser les méthodes d'itération

Rappelez-vous que la première valeur de ce qui est retourné par env::args est le nom du programme. Nous voulons ignorer cette valeur et passer à la suivante, donc d'abord nous appelons une fois next et nous ne faisons rien avec sa valeur de retour. Ensuite, nous appelons next pour obtenir la valeur que nous voulons mettre dans le champ recherche de Config. Si next renvoie un Some, nous utilisons un match pour extraire sa valeur. S'il retourne None, cela signifie que pas assez d'arguments ont été fournis, si bien que nous quittons aussitôt la fonction en retournant une valeur Err. Nous procédons de même pour la valeur nom_fichier.

Rendre le code plus clair avec des adaptateurs d'itération

Nous pouvons également tirer parti des itérateurs dans la fonction rechercher de notre projet d'entrée/sortie, qui est reproduite ici dans l'encart 13-28, telles qu'elle était dans l'encart 12-19 à la fin du chapitre 12 :

Fichier : src/lib.rs

use std::error::Error;
use std::fs;

pub struct Config {
    pub recherche: String,
    pub nom_fichier: String,
}

impl Config {
    pub fn new(args: &[String]) -> Result<Config, &'static str> {
        if args.len() < 3 {
            return Err("il n'y a pas assez d'arguments");
        }

        let recherche = args[1].clone();
        let nom_fichier = args[2].clone();

        Ok(Config { recherche, nom_fichier })
    }
}

pub fn run(config: Config) -> Result<(), Box<dyn Error>> {
    let contenu = fs::read_to_string(config.nom_fichier)?;

    Ok(())
}

pub fn rechercher<'a>(recherche: &str, contenu: &'a str) -> Vec<&'a str> {
    let mut resultats = Vec::new();

    for ligne in contenu.lines() {
        if ligne.contains(recherche) {
            resultats.push(ligne);
        }
    }

    resultats
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn un_resultat() {
        let recherche = "duct";
        let contenu = "\
Rust:
sécurité, rapidité, productivité.
Obtenez les trois en même temps.";

        assert_eq!(vec!["sécurité, rapidité, productivité."], rechercher(recherche, contenu));
    }
}

Encart 13-28 : La mise en oeuvre de la fonction rechercher de l'encart 12-19

Nous pouvons écrire ce code de façon plus concise en utilisant des méthodes des adaptateurs d'itération. Ce faisant, nous évitons ainsi d'avoir le vecteur mutable resultats. Le style de programmation fonctionnelle préfère minimiser la quantité d'états modifiables pour rendre le code plus clair. Supprimer l'état mutable pourrait nous aider à faire une amélioration future afin que la recherche se fasse en parallèle, car nous n'aurions pas à gérer l'accès concurrent au vecteur resultats. L'encart 13-29 montre ce changement :

Fichier : src/lib.rs

use std::env;
use std::error::Error;
use std::fs;

pub struct Config {
    pub recherche: String,
    pub nom_fichier: String,
    pub sensible_casse: bool,
}

impl Config {
    pub fn new(mut args: std::env::Args) -> Result<Config, &'static str> {
        args.next();

        let recherche = match args.next() {
            Some(arg) => arg,
            None => return Err("nous n'avons pas de chaîne de caractères"),
        };

        let nom_fichier = match args.next() {
            Some(arg) => arg,
            None => return Err("nous n'avons pas de nom de fichier"),
        };

        let sensible_casse = env::var("MINIGREP_INSENSIBLE_CASSE").is_err();

        Ok(Config {
            recherche,
            nom_fichier,
            sensible_casse,
        })
    }
}

pub fn run(config: Config) -> Result<(), Box<dyn Error>> {
    let contenu = fs::read_to_string(config.nom_fichier)?;

    let resultats = if config.sensible_casse {
        rechercher(&config.recherche, &contenu)
    } else {
        rechercher_insensible_casse(&config.recherche, &contenu)
    };

    for ligne in resultats {
        println!("{}", ligne);
    }

    Ok(())
}

pub fn rechercher<'a>(recherche: &str, contenu: &'a str) -> Vec<&'a str> {
    contenu
        .lines()
        .filter(|ligne| ligne.contains(recherche))
        .collect()
}

pub fn rechercher_insensible_casse<'a>(
    recherche: &str,
    contenu: &'a str,
) -> Vec<&'a str> {
    let recherche = recherche.to_lowercase();
    let mut resultats = Vec::new();

    for ligne in contenu.lines() {
        if ligne.to_lowercase().contains(&recherche) {
            resultats.push(ligne);
        }
    }

    resultats
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn sensible_casse() {
        let recherche = "duct";
        let contenu = "\
Rust:
safe, fast, productive.
Pick three.
Duct tape.";

        assert_eq!(vec!["safe, fast, productive."], rechercher(recherche, contenu));
    }

    #[test]
    fn case_insensitive() {
        let recherche = "rUsT";
        let contenu = "\
Rust:
safe, fast, productive.
Pick three.
Trust me.";

        assert_eq!(
            vec!["Rust:", "Trust me."],
            rechercher_insensible_casse(recherche, contenu)
        );
    }
}

Encart 13-29 : utilisation des méthodes des adaptateurs d'itération dans l'implémentation de la fonction rechercher

Souvenez-vous que le but de la fonction rechercher est de renvoyer toutes les lignes dans contenu qui contiennent recherche. Comme dans l'exemple de filter dans l'encart 13-19, nous pouvons utiliser l'adaptateur filter pour garder uniquement les lignes pour lesquelles ligne.contains(recherche) renvoie true. Nous collectons ensuite les lignes correspondantes dans un autre vecteur avec collect. C'est bien plus simple ! N'hésitez pas à faire le même changement pour utiliser les méthodes d'itération dans la fonction rechercher_insensible_casse.

Logiquement la question suivante est de savoir quel style utiliser dans votre propre code et pourquoi : l'implémentation originale de l'encart 13-28 ou la version utilisant l'itérateur dans l'encart 13-29. La plupart des développeurs Rust préfèrent utiliser le style avec l'itérateur. C'est un peu plus difficile à comprendre au début, mais une fois que vous avez compris les différents adaptateurs d'itération et ce qu'ils font, les itérateurs peuvent devenir plus faciles à comprendre. Au lieu de jongler avec différentes boucles et de construire de nouveaux vecteurs, ce code se concentre sur l'objectif de haut niveau de la boucle. Cette abstraction permet d'éliminer une partie du code trivial, de sorte qu'il soit plus facile de dégager les concepts propres à ce code, comme le filtrage de chaque élément de l'itérateur qui est appliqué.

Mais ces deux implémentations sont-elles réellement équivalentes ? L'hypothèse intuitive pourrait être que la boucle de plus bas niveau sera plus rapide. Intéressons nous donc maintenant à leurs performances.