Structures, énumérations et méthodes

Rust n’a pas de classes. La modélisation des données repose sur deux constructions — les structures et les énumérations — auxquelles on attache des comportements via des blocs impl. L’héritage n’existe pas ; le partage de comportement passe par les traits.

Les structures

Trois formes existent, selon que les champs sont nommés, anonymes, ou absents.

// Structure classique : champs nommés
struct Utilisateur {
    nom: String,
    courriel: String,
    actif: bool,
    connexions: u64,
}

// Structure tuple : champs anonymes, accessibles par index
struct Point(f64, f64);
struct Metres(f64);

// Structure unité : aucun champ
struct Marqueur;

La structure tuple sert principalement à créer un type distinct autour d’une valeur existante — ce que l’on appelle le patron newtype. Metres(f64) et Secondes(f64) ne sont alors pas interchangeables, alors que deux f64 le seraient.

La structure unité est utile pour implémenter un trait sur un type sans donnée, par exemple un marqueur ou une stratégie.

Instancier

let mut utilisateur = Utilisateur {
    nom: String::from("Alice"),
    courriel: String::from("alice@exemple.fr"),
    actif: true,
    connexions: 0,
};

utilisateur.connexions += 1;      // Nécessite que la variable soit déclarée mut
Note

La mutabilité s’applique à la variable entière, jamais à un champ isolé. Il n’existe pas d’équivalent d’un champ final ou readonly : soit toute l’instance est modifiable, soit aucune partie ne l’est.

Deux raccourcis reviennent souvent. Le premier, l’initialisation abrégée, quand la variable porte déjà le nom du champ :

fn creer(nom: String, courriel: String) -> Utilisateur {
    Utilisateur {
        nom,                // Au lieu de nom: nom
        courriel,
        actif: true,
        connexions: 0,
    }
}

Le second, la syntaxe de mise à jour, pour reprendre les champs d’une autre instance :

let utilisateur2 = Utilisateur {
    courriel: String::from("bob@exemple.fr"),
    ..utilisateur          // Tous les autres champs viennent de `utilisateur`
};
Warning

La syntaxe de mise à jour déplace les champs non Copy. Ici, utilisateur.nom étant une String, utilisateur n’est plus utilisable dans son ensemble après l’opération — voir Ownership et emprunt. Le .. doit toujours figurer en dernier, et ne se termine pas par une virgule.

Les blocs impl

Le comportement est défini séparément des données, dans un ou plusieurs blocs impl.

impl Utilisateur {
    // Fonction associée : pas de self, appelée sur le type
    fn nouveau(nom: String, courriel: String) -> Self {
        Self { nom, courriel, actif: true, connexions: 0 }
    }

    // Méthode : &self, emprunt en lecture
    fn est_actif(&self) -> bool {
        self.actif
    }

    // Méthode : &mut self, emprunt en écriture
    fn enregistrer_connexion(&mut self) {
        self.connexions += 1;
    }

    // Méthode : self, consomme l'instance
    fn desactiver(mut self) -> Self {
        self.actif = false;
        self
    }
}

L’appel se fait avec :: pour les fonctions associées, et . pour les méthodes :

let mut u = Utilisateur::nouveau("Alice".into(), "alice@exemple.fr".into());
u.enregistrer_connexion();
let u = u.desactiver();
assert!(!u.est_actif());

Choisir la forme du receveur

ReceveurSignificationQuand l’employer
&selfEmprunt en lectureLe cas courant : consulter sans modifier
&mut selfEmprunt en écritureModifier l’instance en place
selfConsommationConversions, chaînage de constructeurs, transformations définitives
(aucun)Fonction associéeConstructeurs, fabriques, utilitaires liés au type
Tip

Self, avec une majuscule, désigne le type courant à l’intérieur d’un bloc impl. L’employer plutôt que le nom réel évite d’avoir à modifier le corps du bloc lors d’un renommage.

Par convention, le constructeur principal s’appelle new — ici francisé en nouveau. Ce n’est pas un mot-clé, simplement un usage, et rien n’empêche d’en fournir plusieurs (from_str, with_capacity…).

Plusieurs blocs impl peuvent coexister pour un même type, ce qui permet de regrouper les méthodes par thème ou de séparer celles qui dépendent de contraintes génériques.

Les énumérations

Une énumération liste les valeurs possibles d’un type. Contrairement à ses équivalents dans beaucoup de langages, chaque variante peut transporter des données, de forme différente d’une variante à l’autre.

enum Message {
    Quitter,                            // Aucune donnée
    Deplacer { x: i32, y: i32 },        // Champs nommés
    Ecrire(String),                     // Une valeur
    Couleur(u8, u8, u8),                // Plusieurs valeurs
}

Les blocs impl s’appliquent aussi aux énumérations :

impl Message {
    fn decrire(&self) -> String {
        match self {
            Message::Quitter => String::from("fin"),
            Message::Deplacer { x, y } => format!("déplacement en {x}, {y}"),
            Message::Ecrire(texte) => format!("texte : {texte}"),
            Message::Couleur(r, v, b) => format!("couleur #{r:02x}{v:02x}{b:02x}"),
        }
    }
}

Le filtrage se fait par match, dont la syntaxe complète est traitée dans Déstructuration et patterns.

Rendre les états invalides irreprésentables

C’est l’usage le plus précieux des énumérations : encoder dans le type ce qu’une combinaison de champs ne peut qu’espérer.

// Chaque champ est indépendant : quatre combinaisons sur huit sont absurdes
struct Connexion {
    en_cours: bool,
    etablie: bool,
    erreur: Option<String>,
}

// Les états impossibles n'existent tout simplement pas
enum Connexion {
    EnCours { depuis: Instant },
    Etablie { session: Session },
    Echouee { raison: String },
}

Le compilateur devient alors un allié : l’exhaustivité du match garantit qu’aucun état n’est oublié, et l’ajout d’une variante fait échouer la compilation partout où le nouveau cas doit être traité.

Info

Option<T> et Result<T, E> ne sont rien d’autre que des énumérations de la bibliothèque standard, définies exactement selon ce principe. Rien n’empêche d’en écrire de semblables pour son propre domaine.

Les attributs derive

Les types définis ainsi n’ont, par défaut, presque aucun comportement : ils ne s’affichent pas, ne se comparent pas et ne se copient pas. L’attribut derive demande au compilateur de générer ces implémentations.

#[derive(Debug, Clone, PartialEq)]
struct Point {
    x: f64,
    y: f64,
}

let p = Point { x: 1.0, y: 2.0 };
println!("{p:?}");                      // Fourni par Debug
assert_eq!(p, p.clone());               // Fournis par PartialEq et Clone
AttributCe qu’il apporte
DebugL’affichage de diagnostic {:?} — à mettre presque partout
CloneLa duplication explicite par .clone()
CopyLa copie implicite, réservée aux types entièrement sur la pile
PartialEq, EqLes comparaisons == et !=
PartialOrd, OrdLes comparaisons d’ordre et le tri
HashL’emploi comme clé de HashMap ou HashSet
DefaultUne instance par défaut via Type::default()

La dérivation n’est possible que si tous les champs la supportent : une structure contenant une String ne peut pas dériver Copy. Ces attributs ne sont que des implémentations de traits écrites pour vous, sujet développé dans Les traits.

Le patron constructeur

Rust n’a ni paramètres nommés, ni valeurs par défaut, ni surcharge de fonctions. Au-delà de trois ou quatre paramètres, le patron constructeur (builder) prend le relais, en s’appuyant sur les méthodes qui consomment self pour permettre le chaînage.

#[derive(Default)]
struct RequeteBuilder {
    url: String,
    delai: Option<u64>,
    entetes: Vec<(String, String)>,
}

impl RequeteBuilder {
    fn nouvelle(url: &str) -> Self {
        Self { url: url.to_string(), ..Default::default() }
    }

    fn delai(mut self, secondes: u64) -> Self {
        self.delai = Some(secondes);
        self
    }

    fn entete(mut self, cle: &str, valeur: &str) -> Self {
        self.entetes.push((cle.to_string(), valeur.to_string()));
        self
    }

    fn construire(self) -> Requete {
        Requete {
            url: self.url,
            delai: self.delai.unwrap_or(30),
            entetes: self.entetes,
        }
    }
}

let requete = RequeteBuilder::nouvelle("https://exemple.fr")
    .delai(5)
    .entete("Accept", "application/json")
    .construire();

Visibilité

Tout est privé par défaut, y compris les champs d’une structure publique. Le mot-clé pub ouvre l’accès :

pub struct Configuration {
    pub hote: String,       // Lisible et modifiable de l'extérieur
    port: u16,              // Privé : accessible uniquement dans le module
}

impl Configuration {
    pub fn port(&self) -> u16 {       // Accesseur en lecture seule
        self.port
    }
}

Garder les champs privés et exposer des méthodes permet de préserver les invariants du type — c’est ce qui rend possible, par exemple, la garantie qu’une String contient toujours de l’UTF-8 valide.

À retenir

  • Les données (struct, enum) et le comportement (impl) sont déclarés séparément.
  • Le receveur — &self, &mut self, self ou rien — est le premier choix de conception d’une méthode.
  • Une énumération dont les variantes portent des données permet de rendre les états invalides irreprésentables.
  • derive fournit gratuitement les comportements courants ; Debug mérite d’être présent presque partout.
  • Il n’y a pas d’héritage : le partage de comportement passe par les traits.

Ressources