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 écrivantvalue:
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.