Un exemple de programme qui utilise des structures

Pour comprendre dans quels cas nous voudrions utiliser des structures, écrivons un programme qui calcule l'aire d'un rectangle. Nous commencerons en utilisant de simples variables, puis on remaniera le code jusqu'à utiliser des structures à la place.

Créons un nouveau projet binaire avec Cargo nommé rectangles qui prendra la largeur et la hauteur en pixels d'un rectangle et qui calculera l'aire de ce rectangle. L'encart 5-8 montre un petit programme qui effectue cette tâche d'une certaine manière dans le src/main.rs de notre projet.

Fichier: src/main.rs

fn main() {
    let largeur1 = 30;
    let hauteur1 = 50;

    println!(
        "L'aire du rectangle est de {} pixels carrés.",
        aire(largeur1, hauteur1)
    );
}

fn aire(largeur: u32, hauteur: u32) -> u32 {
    largeur * hauteur
}

Encart 5-8 : calcul de l'aire d'un rectangle défini par les variables distinctes largeur et hauteur

Maintenant, lancez ce programme avec cargo run :

$ cargo run
   Compiling rectangles v0.1.0 (file:///projects/rectangles)
    Finished dev [unoptimized + debuginfo] target(s) in 0.42s
     Running `target/debug/rectangles`
L'aire du rectangle est de 1500 pixels carrés.

Ce code arrive à déterminer l'aire du rectangle en appelant la fonction aire avec chaque dimension, mais on peut faire mieux pour clarifier ce code et le rendre plus lisible.

Le problème de ce code se voit dans la signature de aire :

fn main() {
    let largeur1 = 30;
    let hauteur1 = 50;

    println!(
        "L'aire du rectangle est de {} pixels carrés.",
        aire(largeur1, hauteur1)
    );
}

fn aire(largeur: u32, hauteur: u32) -> u32 {
    largeur * hauteur
}

La fonction aire est censée calculer l'aire d'un rectangle, mais la fonction que nous avons écrite a deux paramètres, et il n'est pas précisé nulle part dans notre programme à quoi sont liés les paramètres. Il serait plus lisible et plus gérable de regrouper ensemble la largeur et la hauteur. Nous avons déjà vu dans la section “Le type tuple du chapitre 3 une façon qui nous permettrait de le faire : en utilisant des tuples.

Remanier le code avec des tuples

L'encart 5-9 nous montre une autre version de notre programme qui utilise des tuples.

Fichier : src/main.rs

fn main() {
    let rect1 = (30, 50);

    println!(
        "L'aire du rectangle est de {} pixels carrés.",
        aire(rect1)
    );
}

fn aire(dimensions: (u32, u32)) -> u32 {
    dimensions.0 * dimensions.1
}

Encart 5-9 : Renseigner la largeur et la hauteur du rectangle dans un tuple

D'une certaine façon, ce programme est meilleur. Les tuples nous permettent de structurer un peu plus et nous ne passons plus qu'un argument. Mais d'une autre façon, cette version est moins claire : les tuples ne donnent pas de noms à leurs éléments, donc il faut accéder aux éléments du tuple via leur indice, ce qui rends plus compliqué notre calcul.

Le mélange de la largeur et la hauteur n'est pas important pour calculer l'aire, mais si on voulait afficher le rectangle à l'écran, cela serait problématique ! Il nous faut garder à l'esprit que la largeur est l'élément à l'indice 0 du tuple et que la hauteur est l'élément à l'indice 1. Cela complexifie le travail de quelqu'un d'autre de le comprendre et s'en souvenir pour qu'il puisse l'utiliser. Comme on n'a pas exprimé la signification de nos données dans notre code, il est plus facile de faire des erreurs.

Remanier avec des structures : donner plus de sens

On utilise des structures pour rendre les données plus expressives en leur donnant des noms. On peut transformer le tuple que nous avons utilisé en une structure nommée dont ses éléments sont aussi nommés, comme le montre l'encart 5-10.

Fichier : src/main.rs

struct Rectangle {
    largeur: u32,
    hauteur: u32,
}

fn main() {
    let rect1 = Rectangle { largeur: 30, hauteur: 50 };

    println!(
        "L'aire du rectangle est de {} pixels carrés.",
        aire(&rect1)
    );
}

fn aire(rectangle: &Rectangle) -> u32 {
    rectangle.largeur * rectangle.hauteur
}

Encart 5-10 : Définition d'une structure Rectangle

Ici, on a défini une structure et on l'a appelée Rectangle. Entre les accolades, on a défini les champs largeur et hauteur, tous deux du type u32. Puis dans main, on crée une instance de Rectangle de largeur 30 et de hauteur 50.

Notre fonction aire est désormais définie avec un unique paramètre, nommé rectangle, et dont le type est une référence immuable vers une instance de la structure Rectangle. Comme mentionné au chapitre 4, on préfère emprunter la structure au lieu d'en prendre possession. Ainsi, elle reste en possession de main qui peut continuer à utiliser rect1 ; c'est pourquoi on utilise le & dans la signature de la fonction ainsi que dans l'appel de fonction.

La fonction aire accède aux champs largeur et hauteur de l'instance de Rectangle. Notre signature de fonction pour aire est enfin explicite : calculer l'aire d'un Rectangle en utilisant ses champs largeur et hauteur. Cela explique que la largeur et la hauteur sont liées entre elles, et cela donne des noms descriptifs aux valeurs plutôt que d'utiliser les valeurs du tuple avec les indices 0 et 1. On gagne en clarté.

Ajouter des fonctionnalités utiles avec les traits dérivés

Cela serait pratique de pouvoir afficher une instance de Rectangle pendant qu'on débogue notre programme et de voir la valeur de chacun de ses champs. L'encart 5-11 essaye de le faire en utilisant la macro println! comme on l'a fait dans les chapitres précédents. Cependant, cela ne fonctionne pas.

Fichier : src/main.rs

struct Rectangle {
    largeur: u32,
    hauteur: u32,
}

fn main() {
    let rect1 = Rectangle {
        largeur: 30,
        hauteur: 50
    };

    println!("rect1 est {}", rect1);
}

Encart 5-11 : Tentative d'afficher une instance de Rectangle

Lorsqu'on compile ce code, on obtient ce message d'erreur qui nous informe que Rectangle n'implémente pas le trait std::fmt::Display :

error[E0277]: `Rectangle` doesn't implement `std::fmt::Display`

La macro println! peut faire toutes sortes de formatages textuels, et par défaut, les accolades demandent à println! d'utiliser le formatage appelé Display, pour convertir en texte destiné à être vu par l'utilisateur final. Les types primitifs qu'on a vus jusqu'ici implémentent Display par défaut puisqu'il n'existe qu'une seule façon d'afficher un 1 ou tout autre type primitif à l'utilisateur. Mais pour les structures, la façon dont println! devrait formater son résultat est moins claire car il y a plus de possibilités d'affichage : Voulez-vous des virgules ? Voulez-vous afficher les accolades ? Est-ce que tous les champs devraient être affichés ? À cause de ces ambiguïtés, Rust n'essaye pas de deviner ce qu'on veut, et les structures n'implémentent pas Display par défaut pour l'utiliser avec println! et les espaces réservés {}.

Si nous continuons de lire les erreurs, nous trouvons cette remarque utile :

   = help: the trait `std::fmt::Display` is not implemented for `Rectangle`
   = note: in format strings you may be able to use `{:?}` (or {:#?} for pretty-print) instead

Le compilateur nous informe que dans notre chaîne de formatage, on est peut-être en mesure d'utiliser {:?} (ou {:#?} pour un affichage plus élégant).

Essayons cela ! L'appel de la macro println! ressemble maintenant à println!("rect1 est {:?}", rect1);. Insérer le sélecteur :? entre les accolades permet d'indiquer à println! que nous voulons utiliser le formatage appelé Debug. Le trait Debug nous permet d'afficher notre structure d'une manière utile aux développeurs pour qu'on puisse voir sa valeur pendant qu'on débogue le code.

Compilez le code avec ce changement. Zut ! On a encore une erreur, nous informant cette fois-ci que Rectangle n'implémente pas std::fmt::Debug :

error[E0277]: `Rectangle` doesn't implement `Debug`

Mais une nouvelle fois, le compilateur nous fait une remarque utile :

   = help: the trait `Debug` is not implemented for `Rectangle`
   = note: add `#[derive(Debug)]` to `Rectangle` or manually `impl Debug for Rectangle`

Il nous conseille d'ajouter #[derive(Debug)] ou d'implémenter manuellement std::fmt::Debug.

Rust inclut bel et bien une fonctionnalité pour afficher des informations de débogage, mais nous devons l'activer explicitement pour la rendre disponible sur notre structure. Pour ce faire, on ajoute l'attribut externe #[derive(Debug)] juste avant la définition de la structure, comme le montre l'encart 5-12.

Fichier : src/main.rs

#[derive(Debug)]
struct Rectangle {
    largeur: u32,
    hauteur: u32,
}

fn main() {
    let rect1 = Rectangle {
        largeur: 30,
        hauteur: 50
    };

    println!("rect1 est {:?}", rect1);
}

Encart 5-12 : ajout de l'attribut pour dériver le trait Debug et afficher l'instance de Rectangle en utilisant le formatage de débogage

Maintenant, quand on exécute le programme, nous n'avons plus d'erreurs et ce texte s'affiche à l'écran :

$ cargo run
   Compiling rectangles v0.1.0 (file:///projects/rectangles)
    Finished dev [unoptimized + debuginfo] target(s) in 0.48s
     Running `target/debug/rectangles`
rect1 est Rectangle { largeur: 30, hauteur: 50 }

Super ! Ce n'est pas le plus beau des affichages, mais cela montre les valeurs de tous les champs de cette instance, ce qui serait assurément utile lors du débogage. Quand on a des structures plus grandes, il serait bien d'avoir un affichage un peu plus lisible ; dans ces cas-là, on pourra utiliser {:#?} au lieu de {:?} dans la chaîne de formatage. Dans cette exemple, l'utilisation du style {:#?} va afficher ceci :

$ cargo run
   Compiling rectangles v0.1.0 (file:///projects/rectangles)
    Finished dev [unoptimized + debuginfo] target(s) in 0.48s
     Running `target/debug/rectangles`
rect1 est Rectangle {
    largeur: 30,
    hauteur: 50,
}

Une autre façon d'afficher une valeur en utilisant le format Debug est d'utiliser la macro dbg!, qui prend possession de l'expression, affiche le nom du fichier et la ligne de votre code où se trouve cet appel à la macro dbg! ainsi que le résultat de cette expression, puis rend la possession de cette valeur.

Remarque : l'appel à la macro dbg! écrit dans le flux d'erreur standard de la console (stderr), contrairement à println! qui écrit dans le flux de sortie standard de la console (stdout). Nous reparlerons de stderr et de stdout dans une section du chapitre 12.

Voici un exemple dans lequel nous nous intéressons à la valeur assignée au champ largeur, ainsi que la valeur de toute la structure rect1 :

#[derive(Debug)]
struct Rectangle {
    largeur: u32,
    hauteur: u32,
}

fn main() {
    let echelle = 2;
    let rect1 = Rectangle {
        largeur: dbg!(30 * echelle),
        hauteur: 50,
    };

    dbg!(&rect1);
}

Nous pouvons placer le dbg! autour de l'expression 30 * echelle et, comme dbg! retourne la possession de la valeur issue de l'expression, le champ largeur va avoir la même valeur que si nous n'avions pas appelé dbg! ici. Nous ne voulons pas que dbg! prenne possession de rect1, donc nous donnons une référence à rect1 lors de son prochain appel. Voici à quoi ressemble la sortie de cet exemple :

$ cargo run
   Compiling rectangles v0.1.0 (file:///projects/rectangles)
    Finished dev [unoptimized + debuginfo] target(s) in 0.61s
     Running `target/debug/rectangles`
[src/main.rs:10] 30 * echelle = 60
[src/main.rs:14] &rect1 = Rectangle {
    largeur: 60,
    hauteur: 50,
}

Nous pouvons constater que la première sortie provient de la ligne 10 de src/main.rs, où nous déboguons l'expression 30 * echelle, et son résultat est 60 (le formattage de Debug pour les entiers est d'afficher uniquement sa valeur). L'appel à dbg! à la ligne 14 de src/main.rs affiche la valeur de &rect1, qui est une structure Rectangle. La macro dbg! peut être très utile lorsque vous essayez de comprendre ce que fait votre code !

En plus du trait Debug, Rust nous offre d'autres traits pour que nous puissions les utiliser avec l'attribut derive pour ajouter des comportements utiles à nos propres types. Ces traits et leurs comportements sont listés à l'annexe C. Nous expliquerons comment implémenter ces traits avec des comportements personnalisés et comment créer vos propres traits au chapitre 10. Il existe aussi de nombreux attributs autres que derive ; pour en savoir plus, consultez la section “Attributs” de la référence de Rust.

Notre fonction aire est très spécifique : elle ne fait que calculer l'aire d'un rectangle. Il serait utile de lier un peu plus ce comportement à notre structure Rectangle, puisque cela ne fonctionnera pas avec un autre type. Voyons comment on peut continuer de remanier ce code en transformant la fonction aire en méthode aire définie sur notre type Rectangle.