Bases du langage

Présentation

Rust est un langage de programmation compilé, à typage statique fort, créé par Graydon Hoare chez Mozilla et dont la première version stable (1.0) est sortie en 2015. Il est aujourd’hui maintenu par la Rust Foundation.

Son objectif principal est d’offrir les performances du C et du C++ tout en garantissant la sûreté mémoire à la compilation, sans ramasse-miettes (garbage collector).

Ses principales caractéristiques sont :

  • Sûreté mémoire garantie : le compilateur vérifie, via le système d’ownership, qu’aucun accès invalide à la mémoire n’est possible (pas de pointeur nul, pas de use after free, pas de dépassement de tampon).
  • Pas de ramasse-miettes : la mémoire est libérée de façon déterministe, à la fin de la portée de la valeur qui la détient.
  • Concurrence sans data race : les mêmes règles qui garantissent la sûreté mémoire empêchent, à la compilation, deux threads d’écrire simultanément dans la même donnée. C’est ce que la communauté appelle la fearless concurrency.
  • Abstractions sans coût (zero-cost abstractions) : les itérateurs, les génériques ou les traits sont résolus à la compilation et n’induisent aucun surcoût à l’exécution.
  • Immutabilité par défaut : une variable ne peut pas être modifiée sauf si on le demande explicitement.
  • Absence de null et d’exceptions : l’absence de valeur et l’échec sont représentés par des types de l’énumération standard, Option<T> et Result<T, E>, que le compilateur oblige à traiter.
Info

Rust est utilisé aussi bien pour des outils système (le noyau Linux accepte des pilotes en Rust depuis la version 6.1) que pour du développement web back-end, de l’embarqué, du WebAssembly ou de l’outillage (les outils front-end modernes comme SWC ou Rolldown sont écrits en Rust).

Installation

L’installation se fait via rustup, le gestionnaire officiel de chaînes d’outils (toolchains).

​
curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh

Télécharger et exécuter rustup-init.exe depuis rustup.rs.

L’installation fournit plusieurs binaires :

  • rustc : le compilateur
  • cargo : le gestionnaire de paquets et de build
  • rustup : le gestionnaire de versions de la toolchain
  • rustfmt : le formateur de code
  • clippy : le linter
Commandes utiles de rustup
rustup update              # Met à jour la toolchain
rustup show                # Affiche la toolchain active
rustup default stable      # Définit le canal par défaut (stable, beta, nightly)
rustup component add clippy rustfmt
rustup doc                 # Ouvre la documentation hors-ligne
Note

Rust suit un cycle de publication de 6 semaines sur trois canaux : stable, beta et nightly. Les évolutions majeures du langage sont regroupées dans des éditions (2015, 2018, 2021, 2024) qui sont déclarées par projet. Une édition ne casse pas la compatibilité : des crates d’éditions différentes peuvent cohabiter dans un même binaire.

Cargo

cargo est l’outil central de l’écosystème : il gère la compilation, les dépendances, les tests et la documentation.

cargo new mon_projet       # Crée un projet binaire
cargo new ma_lib --lib     # Crée un projet bibliothèque
cargo build                # Compile en mode debug (dans target/debug)
cargo build --release      # Compile avec optimisations (dans target/release)
cargo run                  # Compile puis exécute
cargo check                # Vérifie la compilation sans produire de binaire (rapide)
cargo test                 # Lance les tests
cargo doc --open           # Génère et ouvre la documentation du projet
cargo clippy               # Analyse statique
cargo fmt                  # Formate le code

Un projet créé par cargo new a la structure suivante :

mon_projet
├── Cargo.toml
├── Cargo.lock
└── src
    └── main.rs

Le fichier Cargo.toml décrit le projet et ses dépendances :

[package]
name = "mon_projet"
version = "0.1.0"
edition = "2024"

[dependencies]
serde = { version = "1.0", features = ["derive"] }
Tip

Les dépendances, appelées crates, sont publiées sur crates.io et leur documentation est générée automatiquement sur docs.rs.

Structure d’un programme

Le point d’entrée d’un exécutable est la fonction main.

fn main() {
    println!("Hello, world !");
}
Info

println! n’est pas une fonction mais une macro, ce qu’indique le point d’exclamation. Les macros sont développées à la compilation, ce qui permet ici de vérifier le format et les arguments dès la compilation.

Afficher et journaliser

Les macros d’affichage

Toutes reposent sur la même syntaxe de formatage et se distinguent par leur destination.

MacroDestinationUsage
print! / println!Sortie standardCe que le programme produit
eprint! / eprintln!Sortie d’erreurDiagnostics, avertissements, progression
format!Une StringConstruire une chaîne sans l’afficher
write! / writeln!Un flux quelconqueFichiers, tampons, implémentations de Display
dbg!Sortie d’erreurDébogage temporaire
println!("Traitement terminé");
eprintln!("Attention : fichier de configuration absent");

let message = format!("{} éléments traités", 42);
Tip

La distinction entre les deux sorties n’est pas cosmétique : elle permet de rediriger les résultats sans y mêler les diagnostics.

mon_programme > resultats.txt        # Seule la sortie standard est capturée

Tout ce qui n’est pas le résultat utile du programme — progression, avertissements, erreurs — appartient à la sortie d’erreur.

dbg! affiche le fichier, la ligne, l’expression et sa valeur, puis renvoie la valeur — ce qui permet de l’intercaler dans une expression sans rien réécrire :

let x = dbg!(2 + 3) * 4;
// [src/main.rs:2:13] 2 + 3 = 5

Contrairement à println!, il est fait pour être retiré : sa présence dans un dépôt signale un oubli, et Clippy le signale via dbg_macro.

La syntaxe {}

C’est le point central. Une paire d’accolades est un emplacement que la macro remplit, selon une syntaxe qui suit toujours la forme {argument:spécification}.

Désigner l’argument
let nom = "Alice";
let age = 30;

println!("{nom} a {age} ans");            // Capture directe de la variable (Rust >= 1.58)
println!("{} a {} ans", nom, age);        // Positionnel implicite, dans l'ordre
println!("{0} a {1} ans, {0} encore", nom, age);   // Positionnel explicite, réutilisable
println!("{p} a {a} ans", p = nom, a = age);       // Nommé

La capture directe {nom} ne fonctionne qu’avec un nom de variable, jamais avec une expression : {utilisateur.nom} ou {v[0]} ne compilent pas. Il faut alors passer l’expression en argument, ou la lier au préalable.

println!("{}", utilisateur.nom);          // Correct
let n = &utilisateur.nom;
println!("{n}");                          // Correct également

Pour afficher une accolade littérale, on la double :

println!("{{}} est un emplacement vide");     // Affiche : {} est un emplacement vide
{} ou {:?} : deux traits différents

C’est la distinction la plus structurante.

FormeTraitDestinataire
{}DisplayL’utilisateur — lisible, sans détail d’implémentation
{:?}DebugLe développeur — structure interne
{:#?}DebugIdem, sur plusieurs lignes indentées
#[derive(Debug)]
struct Point { x: i32, y: i32 }

let p = Point { x: 1, y: 2 };

println!("{p:?}");      // Point { x: 1, y: 2 }
println!("{p:#?}");     // Point {
                        //     x: 1,
                        //     y: 2,
                        // }
println!("{p}");        // Erreur : Point n'implémente pas Display

Debug s’obtient par #[derive(Debug)] et devrait figurer sur presque tous les types. Display, lui, s’écrit à la main : c’est un choix éditorial, que le compilateur ne peut pas deviner. Les deux traits sont détaillés dans Les traits.

Warning

Un type sans Display ne peut pas s’afficher avec {} — c’est le cas de Vec, HashMap, Option et de toutes les collections, qui n’ont que Debug. L’erreur E0277 doesn't implement std::fmt::Display se corrige presque toujours en passant à {:?}.

La spécification de format

Après le : viennent, dans l’ordre, le remplissage et l’alignement, le signe, la largeur, la précision et le type.

// Largeur et alignement
println!("[{:8}]", "abc");        // [abc     ]  gauche par défaut pour le texte
println!("[{:>8}]", "abc");       // [     abc]  à droite
println!("[{:^8}]", "abc");       // [  abc   ]  centré
println!("[{:*^8}]", "abc");      // [**abc***]  avec caractère de remplissage
println!("[{:8}]", 42);           // [      42]  droite par défaut pour les nombres

// Précision
println!("{:.2}", 3.14159);       // 3.14
println!("{:.3}", "abcdefgh");    // abc   — sur du texte, c'est une troncature

// Signe et remplissage par zéros
println!("{:+}", 42);             // +42
println!("{:08.3}", 3.14159);     // 0003.142

// Bases et notation
println!("{:b}", 42);             // 101010
println!("{:o}", 42);             // 52
println!("{:x} {:X}", 255, 255);  // ff FF
println!("{:#x} {:#b}", 255, 5);  // 0xff 0b101   — le # ajoute le préfixe
println!("{:e}", 1234.5);         // 1.2345e3

La largeur et la précision peuvent elles-mêmes être des arguments, en les suffixant de $ :

let largeur = 10;
let precision = 3;
println!("{:largeur$.precision$}", 3.14159);      // Nommé
println!("{:1$.2$}", 3.14159, largeur, precision); // Positionnel

C’est ce qui permet d’aligner un tableau dont la largeur des colonnes n’est connue qu’à l’exécution.

Le formatage emprunte toujours

Point essentiel au regard des règles de propriété : les macros de formatage prennent leurs arguments par référence. Afficher une valeur ne la consomme donc jamais.

let message = String::from("Bonjour");
println!("{message}");
println!("{message}");        // Toujours disponible : rien n'a été déplacé

Toutes les méthodes des traits de formatage prennent &self, ce qui est cohérent : afficher quelque chose ne devrait pas le détruire. Le mécanisme est développé dans Ownership et emprunt.

Journalisation

println! convient à un script ou à un exemple. Dès qu’un programme tourne en production, il lui manque l’essentiel : niveaux de gravité, horodatage, filtrage à l’exécution, sortie structurée.

Deux écosystèmes coexistent.

log est une façade minimale : la bibliothèque émet des messages, l’application choisit l’implémentation qui les traite — env_logger, simplelog, fern.

use log::{debug, error, info, trace, warn};

info!("serveur démarré sur le port {port}");
warn!("nouvelle tentative dans {delai} ms");
error!("échec de la connexion : {e}");
RUST_LOG=debug cargo run        # Filtrage sans recompiler

tracing va plus loin en introduisant la notion de span : un intervalle de temps auquel des champs sont attachés, ce qui permet de suivre une requête à travers plusieurs fonctions, y compris en asynchrone.

use tracing::{info, instrument};

#[instrument]                                  // Trace automatiquement les arguments
async fn traiter(id: u64) {
    info!(utilisateur = %id, "traitement démarré");
}

La syntaxe des champs se lit ainsi : % emploie Display, ? emploie Debug, et l’absence de préfixe suppose le type natif.

BesoinChoix
Bibliothèquelog — laisse le choix de l’implémentation à l’appelant
Application simplelog + env_logger
Application asynchrone, tracé distribuétracing + tracing-subscriber
Note

Les cinq niveaux sont communs aux deux écosystèmes, du plus fin au plus grave : trace, debug, info, warn, error. Le filtrage se fait à l’exécution par la variable d’environnement RUST_LOG, et les messages sous le seuil retenu ne coûtent presque rien — leurs arguments ne sont même pas évalués.

Commentaires

// Commentaire sur une ligne

/*
  Commentaire sur
  plusieurs lignes
*/

/// Commentaire de documentation, décrit l'élément qui suit.
/// Il accepte du Markdown et est repris par `cargo doc`.
fn ma_fonction() {}

//! Commentaire de documentation, décrit l'élément qui le contient
//! (placé en tête de fichier, il documente le module).

Variables et mutabilité

Une variable est déclarée avec let et est immutable par défaut. Le mot-clé mut la rend modifiable.

let x = 5;
x = 6;          // Erreur de compilation : cannot assign twice to immutable variable

let mut y = 5;
y = 6;          // OK

Le type est généralement inféré, mais peut être annoté explicitement :

let age: u32 = 30;
Shadowing

Redéclarer une variable avec let crée une nouvelle variable qui masque la précédente. Contrairement à mut, cela permet de changer de type.

let valeur = "42";
let valeur = valeur.parse::<i32>().unwrap();   // valeur est maintenant un i32
Constantes
const MAX_POINTS: u32 = 100_000;   // Toujours immutable, type obligatoire, inlinée
static NOM: &str = "Rust";         // Emplacement mémoire unique, durée de vie du programme

Types de données

Types scalaires

CatégorieTypesExemples
Entiers signési8, i16, i32, i64, i128, isize-42, 1_000_000
Entiers non signésu8, u16, u32, u64, u128, usize42, 0xff, 0b1010, b'A'
Flottantsf32, f643.14, 2.5e3
Booléenbooltrue, false
Caractèrechar'a', 'é', '🦀'
Note

Le type par défaut est i32 pour les entiers et f64 pour les flottants. Un char occupe 4 octets et représente un scalaire Unicode, pas un octet.

Types composés

// Tuple : taille fixe, types hétérogènes
let point: (i32, i32, &str) = (3, 5, "origine");
let (x, y, nom) = point;      // Déstructuration
println!("{}", point.0);      // Accès par index

// Tableau : taille fixe, types homogènes, alloué sur la pile
let jours = ["Lun", "Mar", "Mer"];
let zeros: [i32; 5] = [0; 5];  // [0, 0, 0, 0, 0]
println!("{}", jours[0]);

// Vecteur : taille variable, alloué sur le tas
let mut nombres: Vec<i32> = Vec::new();
nombres.push(1);
let autres = vec![1, 2, 3];

Chaînes de caractères

Rust distingue deux types de chaînes, toutes deux encodées en UTF-8 :

  • &str : une tranche de chaîne, empruntée et de taille fixe. C’est le type des littéraux.
  • String : une chaîne allouée sur le tas, redimensionnable et possédée.
let litteral: &str = "Hello";
let mut possedee: String = String::from("Hello");
possedee.push_str(", world !");

let conversion: String = litteral.to_string();
let emprunt: &str = &possedee;      // Déréférencement automatique
Warning

Une String n’est pas indexable par entier (chaine[0] ne compile pas) : en UTF-8, un caractère peut occuper plusieurs octets. Il faut passer par .chars(), .bytes() ou une tranche d’octets &chaine[0..4].

Fonctions

fn additionner(a: i32, b: i32) -> i32 {
    a + b       // Pas de point-virgule : c'est l'expression de retour
}

fn afficher(message: &str) {
    println!("{message}");
}

Le type des paramètres et du retour est toujours obligatoire. Le mot-clé return existe mais n’est utilisé que pour les retours anticipés.

Info

Rust distingue les instructions (statements), qui ne renvoient pas de valeur et se terminent par un point-virgule, des expressions, qui en renvoient une. Un bloc { } est lui-même une expression, ce qui permet d’écrire :

let y = {
    let x = 3;
    x + 1       // Le bloc vaut 4
};

Structures de contrôle

Conditions

if est une expression, ce qui remplace l’opérateur ternaire.

let nombre = 6;

if nombre % 2 == 0 {
    println!("Pair");
} else if nombre % 3 == 0 {
    println!("Divisible par 3");
} else {
    println!("Impair");
}

let etiquette = if nombre % 2 == 0 { "pair" } else { "impair" };
Note

La condition doit obligatoirement être un bool : contrairement au C, if nombre { ... } ne compile pas.

Boucles

Rust propose trois formes de boucles, sans équivalent du do...while :

// loop : infinie, et seule forme pouvant renvoyer une valeur via break
let mut compteur = 0;
let resultat = loop {
    compteur += 1;
    if compteur == 10 {
        break compteur * 2;
    }
};

// while
while compteur != 0 {
    compteur -= 1;
}

// for : parcours d'un itérateur
let jours = ["Lun", "Mar", "Mer"];

for jour in jours {
    println!("{jour}");
}

for i in 0..5 {          // 0 à 4 (0..=5 pour inclure 5)
    println!("{i}");
}

for (index, jour) in jours.iter().enumerate() {
    println!("{index} : {jour}");
}

Filtrage par motif

match compare une valeur à une série de motifs. Il est exhaustif : le compilateur refuse le code si un cas n’est pas traité.

let note = 15;

let mention = match note {
    0..=9 => "Insuffisant",
    10..=11 => "Passable",
    12..=13 => "Assez bien",
    14..=15 => "Bien",
    _ => "Très bien",      // Motif par défaut
};

Pour ne traiter qu’un seul motif, if let est plus concis :

if let Some(valeur) = resultat_optionnel {
    println!("{valeur}");
}
Info

Les étiquettes de boucle, les blocs étiquetés, le type divergent ! et la sortie anticipée dans une chaîne d’itérateurs sont traités dans Contrôle de flux et expressions. La syntaxe complète des motifs est détaillée dans Déstructuration et patterns.

Ownership et emprunt

C’est le concept central du langage, celui qui permet de se passer d’un ramasse-miettes. Il repose sur trois règles vérifiées à la compilation :

  1. Chaque valeur a une variable qui la possède : son owner.
  2. Il ne peut y avoir qu’un seul owner à la fois.
  3. Quand l’owner sort de sa portée, la valeur est libérée.
let s1 = String::from("Hello");
let s2 = s1;                  // La propriété est déplacée (move) de s1 vers s2
println!("{s1}");             // Erreur : borrow of moved value
Info

Les types de taille fixe stockés sur la pile (entiers, flottants, booléens, char, tuples de ces types) implémentent le trait Copy : ils sont copiés au lieu d’être déplacés. L’affectation let a = b; reste donc valide pour un i32.

Pour utiliser une valeur sans en prendre la propriété, on l’emprunte avec une référence &.

fn longueur(s: &String) -> usize {
    s.len()
}

let s = String::from("Hello");
let n = longueur(&s);         // s est emprunté, pas déplacé
println!("{s} fait {n} caractères");   // s est toujours utilisable

Un emprunt mutable &mut permet de modifier la valeur empruntée :

fn ajouter(s: &mut String) {
    s.push_str(" !");
}

let mut s = String::from("Hello");
ajouter(&mut s);
Warning

La règle d’emprunt est stricte : à un instant donné, on peut avoir soit un nombre illimité de références immutables, soit une seule référence mutable, jamais les deux. C’est cette règle qui élimine les data races à la compilation.

Gestion de l’absence et des erreurs

Rust n’a ni null ni exceptions. Ces deux cas sont modélisés par des énumérations de la bibliothèque standard, que match oblige à traiter.

enum Option<T> {
    Some(T),
    None,
}

enum Result<T, E> {
    Ok(T),
    Err(E),
}
// Option : une valeur peut être absente
let nombres = vec![1, 2, 3];
let element: Option<&i32> = nombres.get(10);
match element {
    Some(v) => println!("Trouvé : {v}"),
    None => println!("Aucun élément"),
}

// Result : une opération peut échouer
let nombre: Result<i32, _> = "42".parse::<i32>();
match nombre {
    Ok(n) => println!("Converti : {n}"),
    Err(e) => println!("Erreur : {e}"),
}

L’opérateur ? propage automatiquement l’erreur à la fonction appelante, ce qui évite d’imbriquer les match :

fn lire_config(chemin: &str) -> Result<String, std::io::Error> {
    let contenu = std::fs::read_to_string(chemin)?;   // Retourne l'Err immédiatement
    Ok(contenu)
}
Tip

Les méthodes unwrap() et expect("message") extraient la valeur directement mais provoquent un panic! en cas d’absence ou d’erreur. Elles sont pratiques pour un prototype ou un test, mais à éviter en production.

Ressources