Développement Rust - Première Journée
Une base pratique pour bien écrire des applications Rust dès le premier commit. Pas un manuel. Se concentre sur les différences avec les autres langages, les décisions de première journée qui influencent tout le reste, et le petit ensemble de crates qui couvrent la plupart des applications réelles.
Quand l'utiliser
- Démarrer un nouveau projet Rust (CLI, service, bibliothèque)
- Venir à Rust depuis Python, JavaScript, Go, Java/C# ou C++
- Choisir entre types possédés/empruntés, pointeurs intelligents, objets traits vs génériques
- Choisir une stratégie de gestion des erreurs (
anyhowvsthiserror) - Décider quelles crates utiliser
- Configurer un
Cargo.tomlminimal mais opiné, clippy et rustfmt
Configuration de première journée
# 1. Installer la toolchain (rustup est le gestionnaire de toolchain)
curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh
# 2. Confirmer les composants (rustfmt et clippy sont livrés avec stable, rust-src active les fonctionnalités IDE)
rustup component add rustfmt clippy rust-src
# 3. Créer un projet
cargo new my-app # binaire (src/main.rs)
cargo new --lib my-lib # bibliothèque (src/lib.rs)
# 4. La boucle de développement (mémorisez ces quatre)
cargo check # vérification rapide des types, sans génération de code
cargo run # construire et exécuter (binaire)
cargo test # construire et exécuter les tests (y compris doctests)
cargo clippy --all-targets # lint, y compris tests/ et benches/ (à exécuter avant un push)
cargo fmt # formater
# 5. Gérer les dépendances sans éditer Cargo.toml à la main
cargo add tokio --features full
cargo remove tokio
cargo update # recalculer Cargo.lock dans les plages semver existantes
cargo update ne se déplace que dans les plages de versions déjà dans Cargo.toml. Passer une version majeure (1.x à 2.0) nécessite une modification de Cargo.toml ou cargo add <crate>@2.
rust-analyzer est obligatoire. C'est le serveur de langage que chaque éditeur utilise (VS Code, Zed, Neovim, Helix, RustRover utilise son propre moteur mais est comparable). Dans VS Code, installez l'extension rust-analyzer et définissez rust-analyzer.check.command à "clippy" pour obtenir un retour de lint à la sauvegarde.
Vous voulez un observateur de fichiers plus tard ? cargo install --locked bacon, puis exécutez bacon dans votre projet. Pas nécessaire le premier jour.
Le modèle mental de Rust en 5 idées
Rust échange deux choses que vous tenez pour acquises dans la plupart des langages (un ramasse-miettes et des exceptions) contre des garanties à la compilation concernant la mémoire, les accès concurrents et la gestion des erreurs. La forme du langage découle de cet échange.
1. La propriété : chaque valeur a exactement un propriétaire
Pensez aux valeurs comme à des objets physiques. Un livre, un fichier, une connexion réseau. À tout moment, une variable la possède. Vous pouvez :
- La déplacer :
let b = a;donne la propriété àb.an'existe plus. - L'emprunter de manière immuable :
&apermet à d'autres de la regarder. Plusieurs lecteurs autorisés. - L'emprunter de manière mutable :
&mut apermet à une personne de la modifier. Accès exclusif. - La cloner :
a.clone()fait une copie profonde. Les deux gardent la leur.
Quand le propriétaire sort de la portée, la valeur est libérée (mémoire libérée, fichier fermé, verrou relâché). Pas de GC, pas de free manuel. C'est le RAII, imposé par le compilateur.
2. Aliasage XOR mutabilité
À tout moment, une donnée a soit :
- une référence mutable (
&mut T), ou - un nombre quelconque de références immuables (
&T),
jamais les deux en même temps. Cette seule règle élimine les accès concurrents et la plupart des bugs d'utilisation après libération. Le borrow checker l'applique. Quand il se plaint, il vous dit que votre histoire de propriété des données n'est pas claire, pas que le langage est difficile.
3. Les erreurs sont des valeurs, pas des exceptions
Il n'y a pas de try/catch. Les fonctions qui peuvent échouer retournent Result<T, E>. Les fonctions qui ne retournent rien d'utile retournent Option<T>. Le compilateur vous force à gérer les deux. L'opérateur ? propage les erreurs dans la pile d'appels avec un seul caractère :
fn read_config() -> Result<Config, anyhow::Error> {
let text = std::fs::read_to_string("config.toml")?; // ? = retour anticipé en cas d'Err
let config = toml::from_str(&text)?;
Ok(config)
}
Il n'y a pas de null. Option<T> est None ou Some(value). Le compilateur ne vous laissera pas oublier le cas None.
4. Les traits ne sont pas des interfaces Java
Un trait définit un comportement. Les types implémentent les traits. Jusque là, familier. Les différences :
- La dispatch statique est la valeur par défaut. Quand vous écrivez
fn f<T: Display>(x: T), le compilateur génère une copie séparée defpour chaqueTconcret avec lequel vous l'appelez (monomorphisation, comme les templates C++). Surcharge à l'exécution nulle. - La dispatch dynamique est optionnelle via
dyn Trait(généralementBox<dyn Trait>ou&dyn Trait). Une consultation de vtable par appel. - Pas d'héritage. Les traits composent. Si vous vous surprenez à utiliser
Derefpour "étendre" un type, arrêtez-vous et utilisez la composition ou une énumération. - Règle de l'orphelin : vous pouvez
impl VotreTrait pour TypeDUnAutreouimpl TraitDeUnAutre pour VotreType, mais pas les deux en même temps. Cela maintient la résolution des dépendances saine.
5. Le borrow checker est un oracle de conception
L'erreur la plus courante chez les nouveaux venus est de traiter les erreurs du compilateur comme des obstacles à faire taire. Ce n'est pas le cas. Presque chaque erreur de borrow check révèle un vrai problème de qui possède quoi. Quand vous êtes bloqué, la question est rarement "comment faire compiler ça" et presque toujours "quelle est la relation de propriété réelle que je veux ici ?" Lisez l'erreur. Le compilateur est inhabituellement instructif.
Les 3 questions pour chaque signature de fonction
Avant d'écrire une fonction, demandez-vous : doit-elle posséder, lire ou modifier l'entrée ?
fn consume(s: String) // possède : la fonction prend la responsabilité, l'appelant la perd
fn read(s: &str) // lit : la fonction regarde, l'appelant garde
fn modify(s: &mut String) // modifie : la fonction change sur place
Valeurs par défaut qui fonctionnent 90% du temps :
- Paramètres de fonction : préférer
&stràString,&[T]àVec<T>(ce sont des tranches, acceptent les appelants possédés et empruntés). - Retours de fonction : retourner des types possédés (
String,Vec<T>). Retourner des références implique des durées de vie ; évitez jusqu'à en avoir besoin. - Champs de structure : préférer les types possédés (
String,Vec<T>). Stocker&strdans une structure est le piège le plus courant pour les débutants et il cascade des annotations de durée de vie dans chaque type qui contient votre structure.
Tableau de décision de première journée
Réponses en une ligne aux choix qui se présentent en premier.
| Décision | Par défaut | Quand choisir l'autre |
|---|---|---|
String vs &str (champ de structure) | String | Presque jamais &str jusqu'à ce que vous ayez une vraie raison et compreniez les durées de vie |
String vs &str (paramètre de fonction) | &str | Utilisez String seulement si vous devez posséder/stocker à l'intérieur |
Vec<T> vs &[T] (paramètre) | &[T] | Vec<T> seulement si vous devez posséder |
Box<T> vs Rc<T> vs Arc<T> | Box<T> (propriétaire unique, tas) | Arc<T> pour la propriété partagée entre threads. Évitez Rc<T> par défaut ; utilisez Arc<T> pour ne pas avoir à refactoriser quand vous passez en asynchrone |
RefCell<T> vs Mutex<T> | Mutex<T> (ou RwLock<T>) | Même raison : fonctionne en asynchrone/threads, alors que RefCell non |
Option<T> vs Result<T, E> | Option<T> pour "pas de valeur", Result<T, E> pour "a échoué pour une raison" | Si l'absence a une signification que l'appelant doit gérer, Result |
dyn Trait vs impl Trait / <T: Trait> | Générique (<T: Trait> ou impl Trait) - dispatch statique | Box<dyn Trait> quand vous avez besoin d'une collection hétérogène (Vec<Box<dyn Animal>>) |
| Erreurs dans le code applicatif | anyhow::Result<T> partout | - |
| Erreurs dans le code de bibliothèque | Enumération dérivée de thiserror | Jamais Box<dyn Error> dans les API de bibliothèque publiques - force les appelants à faire un downcast |
&self vs &mut self vs self | &self pour les getters, &mut self pour les setters, self pour les builders/opérations consommatrices | - |
| Disposition des modules | Modules en ligne jusqu'à ce qu'un fichier devienne long, puis diviser | Un module = un fichier est un instinct Java/C#, pas un instinct Rust |
Idiomes à intérioriser tôt
Ceux-ci apparaissent dans presque tous les programmes Rust. Apprenez-les en première semaine.
? pour la propagation des erreurs. Remplace neuf lignes de match par un caractère.
let body = reqwest::get(url).await?.text().await?;
Chaînes d'itérateurs plutôt que des boucles manuelles. Compilent dans le même code machine que des boucles écrites à la main (LLVM inline les fermetures). Le Rust idiomatique est de style fonctionnel.
let active_emails: Vec<String> = users
.iter()
.filter(|u| u.active)
.map(|u| u.email.clone())
.collect();
Exhaustivité de match. Ajoutez une nouvelle variante à une énumération et chaque match qui ne la gère pas devient une erreur de compilation. Utilisez cela. C'est l'un des outils de refactorisation les plus puissants de tous les langages.
Laissez le compilateur piloter les refactorisations. La même astuce fonctionne avec les structures : ajoutez un champ obligatoire sans Default et chaque constructeur existant devient une erreur de compilation - une liste de travail exhaustive et gratuite de tous les sites à mettre à jour.
if let et let else pour le cas courant de branche unique.
if let Some(name) = user.name { println!("salut {name}"); }
let Some(name) = user.name else { return Err(anyhow!("pas de nom")); };
// `name` est dans la portée à partir d'ici, pas d'imbrication
From / Into pour les conversions de types. Implémentez From, obtenez Into gratuitement. ? utilise From pour convertir automatiquement les types d'erreurs.
Combinateurs sur Option / Result. Utilisez .map, .and_then, .unwrap_or, .unwrap_or_else, .ok_or avant d'utiliser match.
Macros de dérivation. #[derive(Debug, Clone, PartialEq)] vous donne 80% du code standard gratuitement. Ajoutez #[derive(Serialize, Deserialize)] pour le JSON.
Sugar récent. Fonctionnalités stabilisées qui valent la peine d'être connues, les plus récentes en premier : bool::ok_or et ok_or_else (1.98) transforment une condition directement en Result (ready.ok_or(Error::NotReady)?). cfg_select! est un match à la compilation sur des prédicats cfg, remplaçant la plupart des utilisations de la crate cfg-if, et les gardes if let fonctionnent sur les branches match (match x { Some(v) if let Ok(n) = v.parse::<i32>() => ... }) - tous les deux en 1.95. assert_matches! et debug_assert_matches! (1.96) affirment sur un motif plutôt que sur l'égalité, ce qui est l'assertion naturelle pour une énumération. Deux plus anciens que vous verrez constamment et ne devez pas prendre pour exotiques : let-chains (if let Some(x) = a && x > 3 { ... }), stable dans l'édition 2024 depuis 1.88, et fermetures asynchrones (async || { ... }, avec la famille de limites AsyncFn), stables depuis 1.85.
Venant de X, voici ce qui vous mord
De Python ou JavaScript :
let b = a;pour une valeur sur le tas (commeString,Vec) déplace la valeur.an'est plus utilisable. Utilisez&apour emprunter oua.clone()pour copier.- Pas de null.
Option<T>est imposé. - Pas d'exceptions.
Result<T, E>et?. Le compilateur ne vous laissera pas ignorer les erreurs. - Pas d'héritage. Composition + traits + énumérations.
- Les variables sont immuables par défaut. Ajoutez
mutpour muter. Pareil pour les références :&vs&mut. - Redéclarer un nom est autorisé et idiomatique :
let input = input.trim();occulte l'ancienne liaison par une nouvelle, éventuellement d'un type différent. Ce n'est pas une mutation, et c'est ainsi que le code Rust convertit une valeur sur place sans inventerinput2. - Les types d'entiers sont explicites et l'indexation nécessite
usize. Ils débordent différemment selon la construction : debug "inclut des vérifications de débordement d'entier qui font paniquer votre programme à l'exécution", tandis que--releaseles supprime et effectue un "complément à deux avec enveloppement". Une suite de tests qui passe peut encore envelopper en production, donc utilisezchecked_*/saturating_*/wrapping_*là où l'arithmétique peut vraiment atteindre la limite, plutôt que de compter sur la panique en debug pour la trouver.strict_addet ses amis (stables depuis 1.91) paniquent en cas de débordement dans toutes les constructions, etoverflow-checks = truesous[profile.release]conserve le comportement de debug en production. Etasentre types d'entiers tronque silencieusement (300_u32 as u8donne44) - utilisezu8::try_from(x)?partout où la valeur pourrait ne pas tenir. - Une
Stringn'est pas indexable.s[0]ne compile pas - voirreferences/ownership-and-types.mdpour quoi utiliser à la place.
De Go :
- Les erreurs comme valeurs - même instinct, mais utilisez
?au lieu deif err != nil. - Pas de
nil.Option<T>. - Pas de GC et pas de goroutines : propriété + emprunt, async/await avec
tokio. Le modèle asynchrone est coopératif (awaitest un point d'abandon explicite), pas préemptif. interface{}devient des traits. Par défaut, des génériques pour la dispatch statique ;Box<dyn Trait>seulement quand vous en avez besoin.- La liaison statique est la valeur par défaut. Les binaires sont plus gros mais autonomes.
panic!doit être réservé aux bugs irrécupérables dans le code applicatif ; ne l'utilisez pas comme un "log et continue" à la Go.
De Java ou C# :
- Les traits ne sont pas des interfaces avec dispatch virtuel par défaut.
<T: Trait>est monomorphisé.dyn Traitest la version dynamique optionnelle. - Pas de références nulles.
Option<T>. - Pas d'exceptions.
Result<T, E>et?. - Pas d'héritage de classe. Utilisez des énumérations pour les types sommes, des traits pour le comportement partagé.
- Pas de GC : la propriété et l'emprunt décident des durées de vie.
Arc<T>est ce qu'il y a de plus proche d'une référence Java. - Les génériques sont monomorphisés, pas effacés par type.
De C++ :
- Comme le RAII, mais le borrow checker l'applique à la compilation.
- Pas de constructeurs de copie/déplacement.
Cloneest explicite etCopyest un trait marqueur pour les copies binaires bon marché. - Pas de comportement indéfini dans le code sûr (en théorie).
&est un emprunt vérifié à la compilation, pas un pointeur brut. Les pointeurs bruts existent (*const T,*mut T) mais nécessitentunsafepour être déréférencés.- Les pointeurs intelligents sont
Box<T>(unique_ptr),Rc<T>(shared_ptr, mono-thread),Arc<T>(shared_ptr, thread-safe). - Les macros sont hygiéniques. Les macros procédurales (derive, attribut, fonctionnelles) sont comment
serde,tokio::main, etc. fonctionnent.
La courte liste de crates
Celles-ci couvrent la plupart des applications réelles. Ajoutez-les selon les besoins ; elles ne sont pas toutes obligatoires.
| Crate | Ce qu'elle vous apporte |
|---|---|
serde + serde_json | Sérialisation. #[derive(Serialize, Deserialize)] et vous avez terminé |
tokio | Exécution asynchrone. #[tokio::main], tokio::spawn, E/S asynchrones |
anyhow | Type d'erreur applicatif. anyhow::Result<T>, bail!, context() |
thiserror | Énumérations d'erreurs de bibliothèque. #[derive(thiserror::Error)] |
clap | Analyse des arguments en ligne de commande. #[derive(Parser)] |
axum | Serveur Web. Basé sur tokio + tower. Exprime la composition comme des types |
reqwest | Client HTTP. Bloquant et async |
sqlx | Accès à la base de données. Vérifié à la compilation contre le schéma réel. PostgreSQL, MySQL, SQLite |
tracing | Log structuré et propagation de spans pour les contextes async tokio |
regex | Expressions régulières. Compilées. Regex::new(r"...") |
chrono | Types de date/heure : DateTime<Utc>, NaiveDate, Duration |
uuid | Identifiants uniques universels : Uuid::new_v4(), serde features |
tokio-stream | Adapte les récepteurs tokio et autres sources en flux Stream |
futures | Utilitaires Stream (StreamExt) et extensions combinatories |
rand | Génération de nombres aléatoires |
csv | Écriture/lecture de CSV, avec support de la sérialisation |
Références rapides
Lire un fichier
use std::fs;
// Lossy - remplace les octets non UTF-8 par U+FFFD
let text = fs::read_to_string("path")?;
// Binaire
let bytes = fs::read("path")?;
// Ligne par ligne
for line in fs::read_to_string("path")?.lines() {
// ...
}
Écrire un fichier
use std::fs;
// Écrase le fichier existant
fs::write("output.json", &json_string)?;
// Écriture bufferisée (performante pour les écritures multiples)
use std::io::Write;
let mut file = fs::File::create("output.txt")?;
file.write_all(b"hello\n")?;
Si vous écrivez fréquemment des lignes, enveloppez le fichier dans std::io::BufWriter :
use std::io::{BufWriter, Write}
let file = fs::File::create("large.log")?;
let mut writer = BufWriter::new(file);
writer.write_all(b"ligne\n")?;
Appel HTTP (reqwest)
// GET
let resp = reqwest::get("https://api.example.com/data").await?;
let body = resp.text().await?;
// POST avec JSON
let client = reqwest::Client::new();
let resp = client
.post("https://api.example.com/submit")
.json(&payload)
.send()
.await?;
SQL avec sqlx
use sqlx::postgres::PgPoolOptions;
let pool = PgPoolOptions::new()
.max_connections(5)
.connect("postgres://user:pass@localhost/db")
.await?;
// Récupérer un enregistrement
let user: (i64, String) = sqlx::query_as("SELECT id, name FROM users WHERE id = $1")
.bind(user_id)
.fetch_one(&pool)
.await?;
// Plusieurs enregistrements dans un struct
#[derive(sqlx::FromRow)]
struct User { id: i64, name: String, email: String }
let users: Vec<User> = sqlx::query_as::<_, User>("SELECT id, name, email FROM users")
.fetch_all(&pool)
.await?;
Helpers Option/Result
// Transformer Option en Result avec une erreur personnalisée
let val = opt.ok_or(anyhow!("valeur manquante"))?;
let val = opt.ok_or_else(|| anyhow!("valeur manquante"))?; // paresseux
// Valeur par défaut
let val = opt.unwrap_or(default);
let val = opt.unwrap_or_else(|| compute_default());
// Carte de conversion dans Option/Result
let val = opt.map(|x| x * 2);
let val = res.map_err(|e| format!("échec : {e}"));
// and_then - chaînage des opérations qui retournent Option/Result
let val = opt.and_then(|x| x.parse::<i32>().ok());
// Iter sur l'intérieur
for item in opt.iter() { /* s'exécute seulement si Some */ }
Tester avec des assertions de motifs
use std::assert_matches::assert_matches;
let res: Result<i32, _> = Ok(42);
assert_matches!(res, Ok(42) | Err(_));
Surcharge d'opérateurs (modèles courants)
use std::ops::{Add, Mul};
#[derive(Debug, Clone, PartialEq)]
pub struct Point {
x: f64,
y: f64,
}
impl Add for Point {
type Output = Self;
fn add(self, rhs: Self) -> Self::Output {
Self { x: self.x + rhs.x, y: self.y + rhs.y }
}
}
impl Mul<f64> for Point {
type Output = Self;
fn mul(self, rhs: f64) -> Self::Output {
Self { x: self.x * rhs, y: self.y * rhs }
}
}
Les fonctions de trait se prennent par valeur. Si vous devez aussi supporter &Point + Point, Point + &Point, etc., implémentez les combinaisons explicitement ou utilisez le paquet impl_ops.
Ajouter un middleware à axum
use axum::{
Router,
middleware::{self, Next},
response::Response,
http::Request,
};
async fn log_request(req: Request<axum::body::Body>, next: Next) -> Result<Response, axum::response::Response> {
println!("{} {}", req.method(), req.uri());
let res = next.run(req).await;
println!("status: {}", res.status());
Ok(res)
}
let app = Router::new()
.route("/", get(handler))
.layer(middleware::from_fn(log_request));
#[tokio::test] pour les tests asynchrones
#[cfg(test)]
mod tests {
use super::*;
#[tokio::test]
async fn test_fetch_user() {
let pool = test_db().await;
let user = get_user(&pool, 1).await.unwrap();
assert_eq!(user.name, "Alice");
}
async fn test_db() -> PgPool {
// utilisez testcontainers ou une URL de BD de test
}
}
Ajouter sérialisation à un struct
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Config {
pub host: String,
pub port: u16,
#[serde(default = "default_timeout")]
pub timeout_secs: u64,
}
fn default_timeout() -> u64 { 30 }
// Vers fichier JSON
fs::write("config.json", serde_json::to_string_pretty(&config)?)?;
// Depuis fichier JSON
let config: Config = serde_json::from_str(&fs::read_to_string("config.json")?)?;
Marquer les tests d'intégration
#[cfg(test)]
mod tests {
use super::*;
// Les tests coûteux/externes obtiennent leur propre tag
#[test]
#[ignore = "nécessite une base de données en cours d'exécution"]
fn test_full_pipeline() {
// ...
}
// Alternative : utiliser un module avec #![cfg(feature = "integration_tests")]
// et exécuter avec cargo test --features integration_tests
}
Modèle de configuration d'application lire-depuis-env
#[derive(serde::Deserialize)]
pub struct AppConfig {
pub database_url: String,
pub host: String,
pub port: u16,
pub log_level: String,
}
impl AppConfig {
pub fn from_env() -> Result<Self, config::ConfigError> {
// Utilisez la crate `config` pour les fichiers .env, toml, etc.
config::Config::builder()
.add_source(config::Environment::default())
.build()?
.try_deserialize()
}
}
Présence de & dans les signatures de fonction
Si une fonction prend &self, elle lit l'instance. Vous pouvez appeler la méthode sur n'importe quel propriétaire ou emprunt :
struct Counter { count: i32 }
impl Counter {
fn current(&self) -> i32 { self.count }
fn inc(&mut self) { self.count += 1 }
}
let mut c = Counter { count: 0 };
let r = &c;
r.current(); // ✅ &self fonctionne sur &Counter
// r.inc(); // ❌ &mut self nécessite &mut Counter
c.inc(); // ✅ appel sur le propriétaire
De manière générale : la présence de & ne détermine pas si l'appelant passe une référence ou une valeur. Ce qui compte est si le type que vous passez à la fonction est une référence (&T) ou une valeur (T).