Types produits

En théorie des types, si on a T et U deux types, on appelle type produit le type T x U dont les valeurs contiennent une valeur de type T et une valeur de type U. Le nom de type produit vient du fait que cette construction est équivalente à celle du produit cartésien en théorie des ensembles.

Rust fournit deux implémentations du concept de type produit, les tuples et les structs. Si ces deux notions se retrouvent aussi en C++, nous allons voir que la conception de la version Rust est assez différente, globalement pour le mieux.

Tuples

Cas général

La façon la plus simple de grouper des données hétérogènes en Rust est de les mettre dans un tuple. Ici, pas besoin d’un constructeur spécial comme std::make_tuple en C++. On met juste les données entre parenthèses, séparées par des virgules, et c’est parti :

#![allow(unused)]
fn main() {
let tuple = (1, "bonjour");
println!("{tuple:?}");
}

Le nom d’un type tuple est tout aussi simple à retenir. C’est exactement la même syntaxe que pour créer un tuple, mais avec des types au lieu des valeurs :

#![allow(unused)]
fn main() {
let int_float_str: (u32, f64, &str) = (1, 2.0, "blabla");
}

On peut accéder aux différents éléments d’un tuple via une indexation à la compilation…

#![allow(unused)]
fn main() {
let t = (123, 4.56);
println!("{t:?} contient {} et {}", t.0, t.1); 
}

…mais la façon la plus courante de déstructurer un tuple en Rust est d’utiliser des motifs (patterns). Nous reviendrons sur cette possibilité après avoir présenté les autres types structurés de Rust.

Tuple vide

Un cas particulier intéressant est le tuple vide (), parfois aussi appelé type unité (unit) par les théoriciens. On peut créer un tuple de ce type soi-même, mais ça n’a pas un très grand intérêt puisqu’un tel tuple ne contient aucune information :

#![allow(unused)]
fn main() {
let unite: () = ();
}

Ce qui est plus intéressant, en revanche, c’est que toutes les opérations qui ne retournent pas de résultat, à la manière des fonctions void en C++, retournent une valeur de type unité en Rust :

#![allow(unused)]
fn main() {
let resultat = println!("Bonjour");
println!("{resultat:?}");
}

Ce choix de conception se révèle très bénéfique quand on essaie d’écrire du code générique qui prend des fonctions en paramètre. Il évite d’avoir à gérer séparément le cas des fonctions qui retournent void et celui des fonctions qui retournent des données d’un autre type.

Plus généralement, la notion de tuple vide prend tout son sens dans le code générique, où elle est un moyen d’avoir un paramètre de type optionnel : passer le type () au code générique génère du code équivalent à ce qui se passerait si le paramètre de type et le code associé n’existaient pas.

Si on reprend la vision théorique introduite au début de ce chapitre, le tuple vide peut aussi être vu comme l’élément identité du produit de types. D’où son autre nom de type unité.

Puisque le tuple vide ne contient pas d’information, il n’occupe aucun stockage en mémoire à l’exécution. Rust n’a heureusement pas d’équivalent à la règle de C++ qui impose que les valeurs de tout type doivent utiliser au moins un octet d’espace mémoire :

#![allow(unused)]
fn main() {
let taille_unite = std::mem::size_of::<()>();  // Equivalent du sizeof() de C++
println!("{taille_unite}");
}

Tuple unaire

Pour terminer cette discussion sur les tuples, mentionnons une autre syntaxe plus exotique qu’on croise parfois dans la nature, le tuple à un élément. Il se distingue d’une expression entre parenthèses par l’ajout d’une virgule finale, dans le type comme dans les valeurs :

#![allow(unused)]
fn main() {
let mono: (u32,) = (123,);
}

Cette virgule finale est tolérée pour les autres tuples, ce qui facilite la génération de code :

#![allow(unused)]
fn main() {
let duo: (u8, u16,) = (123, 456,);
}

Structs

Introduction

Si le tuple est un moyen très pratique de créer des groupes de données ad-hoc, mieux vaut ne pas l’utiliser à grande échelle dans son code, car ce type n’offre aucune auto-documentation. Si quelque part au milieu d’un bout de code vous voyez un tup.3, vous n’avez aucune idée de ce que ce quatrième élément du tuple représente, sans vous pencher sur le code qui a créé la valeur tup.

Bien sûr, ponctuellement, on peut donner à une variable tuple un nom qui donne une idée de la fonction des éléments, du style key_and_value. Mais si on se retrouve à utiliser des tuples identiques en plusieurs points de son code, il vaut mieux donner un nom clair aux différents éléments de façon centralisée. C’est une des fonctions des structs en Rust :

#![allow(unused)]
fn main() {
// Déclaration d'un type struct
struct KeyValue {
    key: u16,
    value: String,
}

// Création d'une variable de type struct
let kv = KeyValue {
    key: 123,
    value: String::from("blabla"),
};

// Accès aux membres
println!("{}", kv.value);
}

Quelques remarques s’imposent :

  • Quand on déclare un membre de struct, comme quand on déclare une variable explicitement typée avec let, on commence par donner un nom avant de donner un type.
  • Contrairement à une déclaration de variable, une déclaration de membre doit contenir un type. C’est un choix de conception omniprésent en Rust : si quelque chose est susceptible d’apparaître dans une API, ce quelque chose doit être explicitement typé.
  • Les déclarations de membres sont séparées par des virgules (ce qui rend la syntaxe de déclaration et d’utilisation plus similaire), et on peut écrire une virgule excédentaire à la fin. C’est même idiomatique, car cela permet de réordonner facilement les membres plus tard.
  • L’ordre dans lequel les membres sont déclarés n’affecte pas non plus l’ordre dans lequel ils doivent être spécifiés lors de la création d’une valeur, qui est libre. On peut tout à fait créer une valeur de type KeyValue en écrivant value: en premier si on le souhaite :
    #![allow(unused)]
    fn main() {
    struct KeyValue {
        key: u16,
        value: String,
    }
    KeyValue {
        value: String::from("blabla"),
        key: 123,
    };
    }
  • Sauf indication contraire du programmeur, l’ordre des membres dans la déclaration d’une struct n’affecte pas la représentation mémoire, qui est choisie automatiquement par le compilateur pour minimiser le padding. On peut donc écrire ses membres dans l’ordre le plus logique pour le lecteur, sans risque pour l’empreinte mémoire et la localité de cache. C’est aussi vrai des tuples, un tuple étant traité exactement comme une struct à bas niveau.
  • Comme un tuple vide, une struct vide n’occupe aucune place en mémoire. Et les membres vides d’une struct n’ajoutent rien au poids de la struct.
    #![allow(unused)]
    fn main() {
    struct Vide {
        membre_vide: ()
    }
    println!("{}", std::mem::size_of::<Vide>());
    }

La création d’une valeur est moins remarquable du point de vue d’un programmeur C++. La seule chose qui est inhabituelle est qu’on est obligé de préciser le nom des membres. Cela rend le code plus lisible, ce qui est une des grandes raisons d’utiliser une struct plutôt qu’un tuple.

Il y a un raccourci utile à connaître : si vous avez déjà dans le scope des variables portant le nom des membres d’une struct, vous pouvez définir des membres ayant la valeur de ces variables comme ceci :

#![allow(unused)]
fn main() {
struct KeyValue {
    key: u16,
    value: String,
}

let value = "blabla".to_string();
KeyValue {
    value,
    // Il n'est pas nécessaire de le faire pour tous les membres
    key: 123,
};
}

Support du typage fort

Une autre raison d’utiliser une struct plutôt qu’un tuple (ou un type primitif en général) est de créer un type distinct, incompatible du point de vue du compilateur, ce qui permet d’avoir plus de contrôle sur ses interfaces.

Quand on abuse des types primitifs dans ses interfaces, on peut se retrouver dans une situation où on a plusieurs fonctions qui acceptent en entrée des données de même type, mais les interprètent très différemment. Par exemple une fonction interprète un flottant f32 comme une distance en mètres et l’autre l’interprète comme une distance en miles.

Ce type de différence d’interprétation est une source de bugs coûteux, et la création d’un plus grand nombre de types est un moyen d’éviter ces problèmes :

#![allow(unused)]
fn main() {
struct Metres {
    inner: f32
}
struct Miles {
    inner: f32
}

let a = Metres { inner: 4.2 };
let b = Miles { inner: 5.6 };
a = b;  // Ne compile pas
}

Pour aider à ce type d’utilisation, par défault une struct Rust n’implémente presque aucune opération, même si le ou les types intérieurs supportent ces opérations :

#![allow(unused)]
fn main() {
struct Metres {
    inner: f32
}

// Erreur de compilation : Metres n'implémente pas Mul par défaut (on peut ainsi
// définir un Mul manuel qui fait des conversions et retourne une Surface)
let a = Metres { inner: 1.23 } * Metres { inner: 4.56 };

// Erreur de compilation : Metres n'implémente pas Debug
println!("{:?}", Metres { inner: 7.89 });
}

Dans le cas de Debug, on pourrait trouver ça excessif, vu qu’il y a une implémentation évidente. Mais comme le message d’erreur de compilation l’indique, il suffit d’ajouter une petite directive lors de la déclaration de la struct pour que le compilateur génère ladite implémentation évidente…

#![allow(unused)]
fn main() {
#[derive(Debug)]
struct Metres {
    inner: f32
}

println!("{:?}", Metres { inner: 4.2 });
}

…et c’est important de pouvoir remplacer cette implémentation évidente par une implémentation plus futée lorsqu’elle devient trop verbeuse pour servir son objectif de déboguage efficace.

Nous reviendrons sur ce mécanisme d’implémentation automatique de traits via la directive derive() lorsque nous aurons abordé les traits.

Les membres d’une struct peuvent aussi être rendus privés, contrairement à ceux d’un tuple qui sont toujours publics. L’utilisation de struct en Rust permet donc l’encapsulation des données, un peu comme class et private en C++. Nous reviendrons sur ce point quand nous aborderons la question des modules, qui sont le mécanisme par lequel on contrôle la visibilité en Rust.

Tuple structs

Un problème du typage fort est sa tendance à produire du code très verbeux. Par exemple, en lisant les exemples ci-dessus avec Metres et Miles, vous avez peut-être trouvé que la répétition de inner devenait très vite indigeste.

Pour les cas comme ça où nommer les membres d’une struct finit par faire plus de mal que de bien, Rust fournit un compromis entre le tuple et la struct, logiquement appelé tuple struct. C’est globalement une struct, mais dont les membres sont anonymes comme un tuple.

#![allow(unused)]
fn main() {
#[derive(Debug)]
struct TupleStruct(u32, f64);

let a = TupleStruct(123, 4.56);
println!("{a:?} contient {} et {}", a.0, a.1);
}

Dans la vraie vie, ce type de struct est quasiment toujours utilisé pour des tâches comme celles discutées ci-dessus, où on encapsule une donnée d’un type primitif pour créer des interfaces plus fortement typées autour de ce type.