Aller au contenu

Bonnes pratiques Rust

Rust impose une discipline de programmation plus stricte que la plupart des langages, mais cette rigueur se traduit par un code correctement fiable en production. Les bonnes pratiques Rust consistent autant a exploiter les garanties du compilateur qu'a maîtriser les idiomes du langage pour produire un code lisible, performant et maintenable.


Conventions de style

Rust dispose d'un formateur officiel (rustfmt) et d'un linter (Clippy) qui font consensus dans toute la communauté. Leur utilisation est considérée obligatoire dans tout projet serieux.

Convention Règle
Fonctions snake_case
Types / Traits CamelCase
Constantes SCREAMING_SNAKE_CASE
Modules snake_case
Macros snake_case!
Fichiers snake_case.rs
Lignes 100 caractères maximum (configurable dans rustfmt.toml)
# rustfmt.toml — configuration minimale
max_width          = 100
edition            = "2024"
imports_granularity = "Crate"   # Regroupe les imports par crate
group_imports      = "StdExternalCrate"  # std, puis externes, puis locaux
# Formater tout le projet
cargo fmt

# Verifier le formatage sans modifier (pour CI)
cargo fmt --check

# Clippy — linter avec corrections automatiques
cargo clippy --all-targets --all-features

# Appliquer les corrections automatiques de Clippy
cargo clippy --fix

Ownership et borrowing — comprendre le borrow checker

Le borrow checker est la piece centrale de Rust. Comprendre ses règles evite la frustration et produit un code correct.

/// Regles fondamentales du borrow checker :
/// 1. Une seule reference mutable OU plusieurs references immuables — jamais les deux
/// 2. Les references ne peuvent pas survivre a la valeur qu'elles referencent

fn exemple_ownership() {
    // MOVE : le String est transfere — s1 n'est plus valide
    let s1 = String::from("bonjour");
    let s2 = s1; // s1 est moved dans s2
    // println!("{}", s1); // ERREUR : value borrowed here after move

    // CLONE : copie profonde explicite — s3 et s4 sont independants
    let s3 = String::from("monde");
    let s4 = s3.clone(); // Copie explicite — cout visible dans le code
    println!("{} {}", s3, s4); // OK : les deux sont valides

    // COPY : les types primitifs implementent Copy — pas de move
    let x = 5;
    let y = x;  // x est copie (pas moved) car i32 : Copy
    println!("{} {}", x, y); // OK
}

fn exemple_borrowing() {
    let mut donnees = vec![1, 2, 3];

    // Reference immuable — lecture seule
    let premier = &donnees[0];
    println!("Premier : {}", premier);
    // donnees.push(4); // ERREUR ici : donnees emprunte de facon immuable

    // Apres utilisation de premier, le borrow se termine
    donnees.push(4); // OK : plus de borrow actif

    // Reference mutable — une seule a la fois
    let ref_mut = &mut donnees;
    ref_mut.push(5);
    // let autre_ref = &donnees; // ERREUR : deja emprunte de facon mutable
}

Idiomes Rust

Opérateur ? — propagation d'erreurs

use std::fs;
use std::io;
use std::num::ParseIntError;
use thiserror::Error;

#[derive(Error, Debug)]
enum ErreurConfig {
    #[error("Lecture fichier : {0}")]
    Io(#[from] io::Error),
    #[error("Format invalide : {0}")]
    Parse(#[from] ParseIntError),
}

// ? propage automatiquement l'erreur — equivalent a match { Err(e) => return Err(e.into()) }
fn lire_port(chemin: &str) -> Result<u16, ErreurConfig> {
    let contenu = fs::read_to_string(chemin)?;  // io::Error -> ErreurConfig via From
    let port: u16 = contenu.trim().parse()?;     // ParseIntError -> ErreurConfig via From
    Ok(port)
}

From / Into — conversions sans coût

/// Newtype wrappant un port reseau
struct Port(u16);

impl From<u16> for Port {
    fn from(valeur: u16) -> Self {
        Port(valeur)
    }
}

// Into est implemente automatiquement des lors que From l'est
fn demarrer(port: impl Into<Port>) {
    let port = port.into(); // Conversion ergonomique
    println!("Demarrage sur le port {}", port.0);
}

// Les deux formes fonctionnent
demarrer(Port(8080));
demarrer(8080u16);       // Into<Port> est gratuit

Builder pattern

/// Builder pour un objet de configuration complexe
#[derive(Debug)]
pub struct ConfigServeur {
    hote:          String,
    port:          u16,
    timeout_ms:    u64,
    max_connexions: usize,
}

/// Builder — les champs obligatoires sont dans new(), les optionnels ont des defaults
pub struct ConfigServeurBuilder {
    hote:          String,
    port:          u16,
    timeout_ms:    u64,
    max_connexions: usize,
}

impl ConfigServeurBuilder {
    pub fn new(hote: impl Into<String>, port: u16) -> Self {
        Self {
            hote: hote.into(),
            port,
            timeout_ms: 5000,       // valeur par defaut
            max_connexions: 100,    // valeur par defaut
        }
    }

    pub fn timeout_ms(mut self, ms: u64) -> Self {
        self.timeout_ms = ms;
        self  // Retourne self pour chaining
    }

    pub fn max_connexions(mut self, n: usize) -> Self {
        self.max_connexions = n;
        self
    }

    pub fn construire(self) -> ConfigServeur {
        ConfigServeur {
            hote: self.hote,
            port: self.port,
            timeout_ms: self.timeout_ms,
            max_connexions: self.max_connexions,
        }
    }
}

// Utilisation — API fluide
let config = ConfigServeurBuilder::new("0.0.0.0", 3000)
    .timeout_ms(10_000)
    .max_connexions(500)
    .construire();

Newtype pattern

/// Newtype — empeche de confondre des types semantiquement differents
struct UserId(u64);
struct OrderId(u64);

// Cette fonction ne peut pas recevoir un OrderId par erreur
fn charger_utilisateur(id: UserId) -> String {
    format!("Utilisateur #{}", id.0)
}

// charger_utilisateur(OrderId(42)); // ERREUR de compilation — types differents
charger_utilisateur(UserId(42));     // OK

Anti-patterns courants

Clone abuse

// MAUVAIS : clone inutile — le compilateur peut travailler avec des references
fn longueur_mauvaise(s: String) -> usize {  // Prend ownership inutilement
    s.clone().len()  // Clone inutile
}

// BON : emprunt immutable — pas d'allocation
fn longueur(s: &str) -> usize {  // &str : reference vers n'importe quelle chaine
    s.len()
}

unwrap en production

// MAUVAIS : panique si None ou Err — inacceptable en production
let port: u16 = config.get("port").unwrap().parse().unwrap();

// BON : gestion explicite avec ? et types d'erreurs descriptifs
fn lire_port_config(config: &std::collections::HashMap<String, String>)
    -> Result<u16, ErreurConfig>
{
    let valeur = config.get("port")
        .ok_or_else(|| ErreurConfig::Invalide("cle 'port' absente".into()))?;
    let port = valeur.parse::<u16>()
        .map_err(|_| ErreurConfig::Invalide(format!("port invalide : {}", valeur)))?;
    Ok(port)
}

Arc excessif

// MAUVAIS : Arc partout par habitude, meme quand la duree de vie est connue
fn traiter_mauvais(donnees: Arc<Vec<u8>>) -> usize {
    donnees.len()  // Arc inutile — une reference suffit
}

// BON : reference simple quand la duree de vie est garantie
fn traiter(donnees: &[u8]) -> usize {
    donnees.len()  // Slice reference — zero overhead, fonctionne avec Vec, &[u8], etc.
}

Gestion des erreurs

Rust distingue deux scenarios : les erreurs recuperables (Result<T, E>) et les erreurs irrécuperables (panic!).

Cas Outil recommande
Bibliotheque thiserror — types d'erreurs structures
Application anyhow — boite d'erreurs flexible
Conversion entre erreurs impl From<ErreurSource> for ErreurCible
Erreur logique impossible unreachable!() ou expect("raison claire")
// bibliotheque : thiserror pour des types d'erreurs explicites
use thiserror::Error;

#[derive(Error, Debug)]
pub enum ErreurParsing {
    #[error("Ligne vide")]
    LigneVide,
    #[error("Format inconnu : {champ}")]
    FormatInconnu { champ: String },
    #[error("Valeur hors limites : {valeur} (max={max})")]
    HorsLimites { valeur: i64, max: i64 },
}

// application : anyhow pour propager sans boilerplate
use anyhow::{Context, Result};

fn charger_configuration(chemin: &str) -> Result<Config> {
    let contenu = std::fs::read_to_string(chemin)
        .with_context(|| format!("Lecture de {}", chemin))?;  // Contexte enrichi
    let config: Config = toml::from_str(&contenu)
        .context("Parsing TOML de la configuration")?;
    Ok(config)
}

Performance — zero-cost abstractions

// Les iterateurs Rust sont aussi rapides qu'une boucle manuelle
// Le compilateur les optimise vers le meme code machine

let donnees: Vec<i32> = (0..1_000_000).collect();

// Boucle manuelle
let mut somme = 0i64;
for &v in &donnees {
    if v % 2 == 0 {
        somme += v as i64;
    }
}

// Iterateurs — identique en performance, plus expressif
let somme_iter: i64 = donnees.iter()
    .filter(|&&v| v % 2 == 0)
    .map(|&v| v as i64)
    .sum();

// Parallelisation avec rayon — ajoute .par_iter()
// [dev-dependencies] rayon = "1"
use rayon::prelude::*;
let somme_parallel: i64 = donnees.par_iter()
    .filter(|&&v| v % 2 == 0)
    .map(|&v| v as i64)
    .sum();

Allocation awareness

// Preferer &str a String quand possible — pas d'allocation heap
fn afficher(message: &str) {          // OK — reference, zero allocation
    println!("{}", message);
}

// Preferer &[T] a Vec<T> pour les slices en lecture seule
fn somme(valeurs: &[i32]) -> i32 {   // Fonctionne avec Vec<i32> et [i32; N]
    valeurs.iter().sum()
}

// String::with_capacity evite les reallocations incrementales
fn construire_csv(lignes: &[Vec<String>]) -> String {
    let mut s = String::with_capacity(lignes.len() * 64); // Estimation initiale
    for ligne in lignes {
        s.push_str(&ligne.join(","));
        s.push('\n');
    }
    s
}

Clippy comme professeur

Clippy dispose de plus de 700 lints categorises en groupes (correctness, perf, style, pedantic, nursery). Activer #![warn(clippy::pedantic)] dans un projet enseigne les idiomes Rust avances. Le groupe clippy::perf détecté automatiquement les allocations evitables et les patterns sous-optimaux.