Retour au cours

backend / rust

Structs et enums

Leçon 71 exercice

Explication

Ce que vous allez apprendre

  • Définir une struct et lui attacher des méthodes via un bloc impl
  • Utiliser #[derive(...)] pour générer automatiquement des implémentations courantes
  • Créer un enum dont chaque variante peut porter des données différentes
  • Utiliser match pour traiter chaque variante d'un enum de façon exhaustive
  • Comprendre Option<T>, l'enum de la stdlib qui remplace null

Dans quel contexte ?

Un développeur qui vient de Java cherche le mot-clé class pour modéliser un Utilisateur, et découvre à la place struct Utilisateur couplée à un bloc impl séparé pour ses méthodes. Il découvre ensuite un enum Forme dont chaque variante (Cercle, Rectangle, Triangle) porte ses propres champs — une possibilité qu'un simple enum Java ou C ne propose pas nativement, et qui change profondément la façon de modéliser des données en Rust.

D'abord, une struct regroupe des données, un impl leur attache un comportement

struct Utilisateur { nom: String, email: String, age: u32 } déclare uniquement la forme des données. Le comportement associé s'écrit séparément dans un bloc impl Utilisateur { ... }, où fn nouveau(...) -> Self (sans self en paramètre) joue le rôle d'un constructeur/factory, et où fn description(&self) -> String (avec &self) emprunte l'instance pour la lire.

Une fois cette séparation acquise, un raccourci très pratique s'impose

Écrire manuellement Debug, Clone ou PartialEq pour chaque struct serait fastidieux et source d'erreurs. #[derive(Debug, Clone)], placé juste au-dessus de la déclaration, génère automatiquement ces implémentations standard, à condition que tous les champs de la struct les supportent également.

Trait dérivableCe qu'il apporte
DebugPermet {:?} dans println!, essentiel pour déboguer
ClonePermet .clone(), une duplication explicite
PartialEqPermet de comparer deux instances avec ==
PartialOrdPermet de comparer avec <, > (ordre)

Prérequis

Il faut être à l'aise avec l'ownership et le borrowing (leçons précédentes) : la distinction entre une méthode &self (emprunte) et self (consomme) en dépend directement.

Il reste un concept bien plus puissant qu'un simple enum d'autres langages : le type somme

Un enum Rust peut porter des données différentes pour chaque variante, comme Forme::Cercle { rayon: f64 } face à Forme::Rectangle { largeur: f64, hauteur: f64 }. C'est ce qu'on appelle un "type somme" : la valeur est exactement UNE des variantes possibles, jamais un mélange, et chaque variante peut avoir sa propre structure de données interne.

Piège fréquent

Oublier de traiter une variante dans un match sur un enum ne compile tout simplement pas : Rust exige l'exhaustivité (tous les cas couverts) sauf si un _ de secours est présent. C'est une contrainte au début, mais elle garantit qu'ajouter une nouvelle variante à un enum plus tard forcera le compilateur à signaler tous les endroits du code qui doivent être mis à jour en conséquence.

Enfin, l'enum le plus utilisé de tout l'écosystème Rust : Option<T>

Rust n'a pas de null : l'absence potentielle d'une valeur se représente explicitement par Option<T>, qui vaut soit Some(valeur) soit None. Cette approche force le développeur à traiter le cas d'absence explicitement (via match ou des combinateurs), éliminant structurellement toute la classe de bugs "NullPointerException" si fréquente ailleurs.

Bonne pratique

Ajoute systématiquement #[derive(Debug)] à chaque struct et enum que tu écris, même en prototype rapide : ce dérive quasi gratuit permet d'afficher n'importe quelle valeur avec {:?} pendant le débogage, sans jamais avoir à l'implémenter manuellement.

Maintenant que tu sais modéliser des données avec des enums riches, la prochaine leçon approfondit l'outil qui les exploite pleinement : le pattern matching avec match.

Commandes & code

Structs et enums

Les structs modélisent des données, les enums modélisent des choix — souvent combinés.

rust
#[derive(Debug, Clone)] // derive genere automatiquement des implementations utiles
struct Utilisateur {
    nom: String,
    email: String,
    age: u32,
    actif: bool,
}

impl Utilisateur {
    // Fonction associee (pas de &self) : equivalent d'un constructeur / factory
    fn nouveau(nom: &str, email: &str, age: u32) -> Self {
        Self { nom: nom.to_string(), email: email.to_string(), age, actif: true }
    }

    // Methode avec &self : emprunte, ne consomme pas l'instance
    fn description(&self) -> String {
        format!("{} ({}), {} ans", self.nom, self.email, self.age)
    }

    // Methode avec &mut self : peut modifier l'instance
    fn desactiver(&mut self) {
        self.actif = false;
    }
}

// Tuple struct : struct sans noms de champs, utile pour du typage fort leger
struct Point(f64, f64);

// Unit struct : sans donnees, souvent utilisee comme marqueur de trait
struct Marqueur;

// Enum : type somme, chaque variante peut porter des donnees differentes
#[derive(Debug)]
enum Forme {
    Cercle { rayon: f64 },
    Rectangle { largeur: f64, hauteur: f64 },
    Triangle { base: f64, hauteur: f64 },
}

impl Forme {
    fn aire(&self) -> f64 {
        match self {
            Forme::Cercle { rayon } => std::f64::consts::PI * rayon * rayon,
            Forme::Rectangle { largeur, hauteur } => largeur * hauteur,
            Forme::Triangle { base, hauteur } => 0.5 * base * hauteur,
        }
    }
}

// Enum tres celebre de la stdlib : Option<T> pour representer "peut-etre une valeur"
fn diviser(a: f64, b: f64) -> Option<f64> {
    if b == 0.0 { None } else { Some(a / b) }
}

fn main() {
    let mut u = Utilisateur::nouveau("Alice", "alice@example.com", 30);
    println!("{}", u.description());
    u.desactiver();
    println!("{:?}", u); // Debug derive permet {:?}

    let u2 = u.clone(); // Clone derive permet .clone()
    println!("{}", u2.nom);

    let p = Point(3.0, 4.0);
    println!("{} {}", p.0, p.1);

    let formes = vec![
        Forme::Cercle { rayon: 2.0 },
        Forme::Rectangle { largeur: 3.0, hauteur: 4.0 },
        Forme::Triangle { base: 5.0, hauteur: 6.0 },
    ];
    for f in &formes {
        println!("{:?} -> aire = {:.2}", f, f.aire());
    }

    match diviser(10.0, 2.0) {
        Some(r) => println!("resultat: {}", r),
        None => println!("division impossible"),
    }
}

Résumé

  • #[derive(Debug, Clone, ...)] génère automatiquement des implémentations standard sans code manuel.
  • Un enum peut porter des données différentes par variante — bien plus riche qu'un enum C classique.
  • impl sépare les données (struct/enum) de leur comportement (méthodes).
  • Option<T> remplace null : l'absence de valeur est explicite et vérifiée à la compilation.

Exercices pratiques

1 disponible
1

Mission : modeliser un catalogue de formes pour un moteur de rendu

Objectif : Concevoir un enum de formes riche en donnees et une methode exhaustive, puis diagnostiquer une struct mal derivee.

Contexte

L'equipe graphique d'un jeu veut ajouter une nouvelle forme, Ellipse { rayon_x: f64, rayon_y: f64 }, a l'enum Forme vu en cours. Un dev a rapidement ajoute la variante mais oublie de mettre a jour la methode aire, et le code ne compile plus. Un autre dev se plaint separement que println!("{:?}", ma_forme) ne fonctionne pas sur sa propre struct Cercle a lui.

Résoudre l’exercice →