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.