Arithmétique
Concentrons-nous maintenant sur les types arithmétiques primitifs de Rust, à savoir les booléens, les entiers et les flottants. Comme vous allez le voir, du point de vue d’un programmeur C++, il y a beaucoup de points communs et quelques différences importantes.
Types
Les types arithmétiques primitifs du langage sont :
- Le type booléen
bool
. - Les types entiers signés
i8
,i16
,i32
,i64
,i128
etisize
- Les types entiers non signés
u8
,u16
,u32
,u64
,u128
etusize
. - Les types flottants
f32
etf64
.
Une première différence avec le C++ saute aux yeux : à l’exception de isize
et
usize
qui ont la taille d’une adresse mémoire (comme size_t
en C++), les
types entiers sont de taille fixe, comme ceux de <cstdint>
en C++.
Dans l’ensemble, l’expérience du C et du C++ nous enseigne qu’il est presque
impossible d’écrire du code portable et correct en présence de types de
taille variable comme le long
de C. De plus, il y a aujourd’hui en C/++ une
relative standardisation des tailles qui fait que la plupart des programmes
existants seraient incorrects sur une implémentation utilisant, disons, des
char
de 16 bits. Accepter cet état de fait en privilégiant l’utilisation
d’entiers taille fixe est donc préférable.
Une autre curiosité de Rust est l’inclusion d’entiers 128 bits. Il est rare aujourd’hui de rencontrer du matériel qui supporte ceux-ci nativement (quoique ce soit prévu par l’architecture RISC-V), mais comme ils sont assez faciles à émuler et ont plusieurs applications intéressantes (timestamps Unix précis à la nanoseconde, cryptographie, identifiants de systèmes distribués…), ils y en a en Rust.
Litérales
Dans l’ensemble, les litérales de C++ et Rust sont très similaires.
Une différence importante est que par défaut, les litérales Rust ne sont pas
typées. Contrairement à ce qui se passe en C++ une litérale entière comme 123
n’est pas présumée être d’un certain type int
, et une litérale flottante
comme 4.2
n’est pas présumée être double précision. Si vous avez eu affaire
aux suffixes ULL
et f
en C++, vous comprendrez le gain ergonomique que ça
représente.
Ceci est une conséquence de l’inférence de type bidirectionnelle de Rust : puisque le compilateur est généralement capable d’inférer le type d’une variable en fonction du contexte dans lequel elle est utilisée, il est a fortiori souvent capable d’inférer le type d’une litérale, et donc il n’y a plus besoin de s’embêter avec des litérales typées.
Si vous tenez à préciser le type d’une litérale (par exemple pour guider
l’inférence d’un type générique), vous pouvez cependant le faire en l’écrivant
en fin de litérale : 4.2f32
est de type f32
.
En l’absence de toute contrainte, un type par défaut adapté à la litérale peut être sélectionné par le compilateur, mais ce n’est fait que dans des cas simples.
Les autres différences sont mineures et résumées par le code éditable suivant :
fn main() { // On n'écrit pas un nombre octal comme ceci... let decimal = 0321; // ...mais comme cela. let octal = 0o777; // On utilise _ et pas ' pour la lisibilité des grands nombres let long_number = 1_234_567.89; println!("{decimal} {octal} {long_number}"); }
Opérateurs
Les opérateurs arithmétiques de C++ et Rust sont très similaires, du moins
quand on les applique à des nombres de même type. Il en va de même pour les
opérateurs de comparaisons usuels comme !=
. Nous aborderons la question
des types hétérogènes un peu plus loin.
Une différence notable est le traitement des opérations bit à bit. Puisque
Rust n’autorise pas à traiter les entiers comme des booléens, il n’y a pas
besoin d’un opérateur ~
dédié pour le NON bit à bit, on réutilise tout
simplement la syntaxe !
utilisée pour le NON des booléens.
Une autre différence est qu’il n’y a pas d’opérateur d’incrémentation /
décrémentation dédié, ce qui résoud l’éternelle question du ++valeur
vs
valeur++
en C++. Les opérateurs de modification en place comme valeur += 1
ne retournent pas non plus de valeur numérique. L’ergonomie des boucles est
assurée par d’autres moyens (itérateurs) que nous étudierons ultérieurement.
Je n’ai pas vérifié si les priorités opératoires sont différentes, mais franchement, si vous êtes le genre de personne qui réduisez au maximum le nombre de parenthèses dans vos expressions au motif qu’une norme obscure quelque part dit que c’est légal, vous méritez ce qui vous arrive quand vous basculez entre deux langages de programmation…
fn main() { let add = 1 + 2; let sub = 3 - 4; let mut muldiv = 5 * 6; muldiv /= 7; // Un des rares cas où le type inféré par défaut est observable ! let not = !0; // Comparaison n'est pas raison dans le monde des flottants IEEE-754... let absurdity = f32::NAN == f32::NAN; println!("{add} {sub} {muldiv} {not:#x} {absurdity}"); }
Conversions
Sur la question des conversions, Rust a une conception très différente de C++.
En Rust, il n’y a pas de conversion implicite entre types arithmétiques, et très peu d’opérateurs hétérogènes entre deux types primitifs différents (il est possible d’en définir pour ses propres types). Ainsi, ce genre de code ne compile pas…
#![allow(unused)] fn main() { let x = 1u8 + 2u32; }
…et il faut à la place utiliser des conversions explicites pour spécifier dans quel type on veut que l’opération soit effectuée :
#![allow(unused)] fn main() { let a = 4u8; let b = 2u32; let c = a + (b as u8); }
Pourquoi ce choix a-t’il été fait par les concepteurs de Rust ?
- Les conversions implicites sont une source de bugs invisibles, notamment quand elles surviennent à l’affectation (qui peut jeter des décimales de flottants ou des bits de poids fort d’entiers sans autre forme de procès en C/++).
- Les conversions implicites causent aussi des problèmes de performance dans les calculs, où il est très fréquent de se retrouver à calculer en précision plus élevée qu’on ne le voulait.
- L’existence de conversions implicites rend très souvent l’inférence de type indécidable, or nous avons vu que celle-ci joue un rôle central dans le code Rust idiomatique.
L’exemple ci-dessus introduit as
, qui est le seul opérateur de conversion
explicite disposant d’une syntaxe dédiée dans le langage. Celui-ci
suit grosso-modo les règles des casts
C,
le comportement indéfini en moins, il a donc une sémantique assez complexe qui
facilite les erreurs. De l’aveu général, c’est un des ratés de la conception de
Rust.
Par conséquent, il y a un mouvement en cours dans la communauté pour construire
petit des alternatives plus spécialisées à toutes les utilisations de as
, qui
clarifient l’intention de l’auteur du code. Ces alternatives ne sont plus
directement intégrées au langage, mais implémentées sous forme de traits dans
des bibliothèques. Quelques exemples issus de la bibliothèque standard :
From
etInto
représentent les conversions qui réussissent toujours sans pertes d’information. Ces opérations permettent donc de convertiri8
eni16
, mais pasf32
enusize
.TryFrom
etTryInto
représentent les conversions qui peuvent être sans perte, et échouent si ce n’est pas le cas. Par exemple,u16::try_from(256usize)
réussirait maisu8::try_from(666usize)
échouerait.
Nous n’allons pas discuter de ces alternatives en détail ici car elles dépendent
d’autres aspects du langage que nous n’avons pas encore abordés (les traits et
la gestion des erreurs notamment). Mais retenez que as
est considéré
aujourd’hui comme une solution de facilité dont l’utilisation devrait être
réduite et idéalement éliminée à terme.
Autres opérations
Les types arithmétiques ont un très grand nombre de constantes et méthodes associées (voyez par exemple f32 et usize). C’est la façon idiomatique d’utiliser les fonctions mathématiques de base de la bibliothèque standard Rust :
#![allow(unused)] fn main() { println!("{} {}", f32::MAX, (-1.0f32).acos()); }
Les méthodes des types numériques sont ambigües du point de vue de l’inférence
de type, puisqu’elles sont implémentées par plusieurs types flottants/entiers.
On est donc souvent forcé de typer ses litérales quand on les utilise, comme je
l’ai fait avec 1.0f32
ci-dessus. Si je ne l’avais pas fait, le compilateur
aurait rejeté le code ambigü : le code suivant ne compile pas.
#![allow(unused)] fn main() { println!("{}", 0.0.asin()); }
Le choix d’implémenter les opérations mathématiques sous forme de méthodes est discutable, et a été abondamment débattu pendant et après la stabilisation de Rust. L’avantage est que les méthodes sont toujours disponibles, sans devoir les ramener dans le scope, mais en contrepartie…
- C’est peu conventionnel comme sens de lecture (sauf si vous venez d’un langage objet comme Java), et donc c’est difficile à lire au début.
- Cela rend les pages de documentation des types primitifs assez chargées, comme vous avez pu le constater si vous avez ouvert les liens ci-dessus.
Après, si vous ne pouvez pas vous passer de la notation préfixe, c’est assez facile de l’implémenter sous forme de bibliothèque. Je l’ai fait moi-même pour un projet de calcul numérique il y a quelques temps, et j’ai publié la bibliothèque pour que d’autres personnes intéressées puissent utiliser facilement cette notation.
Les méthodes mathématiques standard de Rust contiennent aussi plusieurs choses inhabituelles pour un utilisateur C++ :
- Il y a des opérations arithmétiques entières comme
checked_add()
,saturating_add()
,wrapping_sub()
etoverflowing_div()
où le comportement en cas de dépassement des bornes inférieures et supérieures est choisi explicitement. Quand on utilise les opérateurs de base comme “+”, ce dépassement est traité comme une erreur, donc…- En mode debug, cet événement est détecté et provoque l’arrêt du programme (panic).
- En mode release, le test pour détecter l’erreur coûte trop cher au vu de la fréquence de ces opérations. On accepte donc le compromis de suivre la sémantique de tous les CPUs modernes et revenir à la borne opposée du type entier utilisé (wraparound).
- On retrouve toutes les opérations courantes sur les bits des entiers (comptage des 1 et 0 dans la représentation binaire, nombre de 0 et de 1 au début où à la fin de cette représentation, puissance de 2 suivante…) que les compilateurs implémentent très efficacement depuis la nuit des temps mais qu’en C++ < 20 on ne peut utiliser que via des intrinsèques spécifiques à chaque compilateur, ce qui nuit à la portabilité du code.
- Les puissances entières sont supportées explicitement et sans pièges de
performance, pour les entiers comme pour les flottants. Plus besoin de remplir
son code de
f * f * f
pour éviter les problèmes de performances dustd::pow
de C++, en Rustf.powi(3)
fait ce qu’on veut.
Intervalles
Rust possède un certain nombre de syntaxes d’intervalles, avec des types associés, qui sont le plus souvent utilisées pour représenter des ensembles d’entiers. Elles sont cependant applicables à tout type ordonné (possédant une relation d’ordre mathématique).
..
est un objet de typeRangeFull
représentant l’ensemble des valeurs d’un type ordonné.i..
est unRangeFrom
représentant l’ensemble des x tels quei <= x
.i..j
est unRange
représentant l’ensemble des x tels quei <= x < j
.i..=j
est unRangeInclusive
représentant l’ensemble des x tels quei <= x <= j
...j
est unRangeTo
représentant l’ensemble des x tels quex < j
...=j
est unRangeToInclusive
représentant l’ensemble des x tels quex <= j
.- Les autres cas sont couverts par des tuples
(Bound, Bound)
oùBound
est un type pouvant représenter une borne inclusive, exclusive, ou l’absence de borne. Une description précise nécessite des notions de Rust que nous n’avons pas encore abordées, je vous invite à y revenir si besoin après avoir étudié les types produits et les types sommes.
On le voit, la syntaxe de Rust possède un net biais en faveur des intervalles fermés à gauche et ouverts à droite. Ces intervalles ont en effet un certain nombre de bonnes propriétés déjà signalées par Dijkstra dans les années 80 :
- Par rapport aux intervalles ouverts à gauche, ils permettent de gérer facilement le 0 dans l’ensemble des entiers non signés.
- Par rapport aux intervalles fermés à gauche et à droite, ils simplifient le calcul du nombre d’éléments (borne droite - borne gauche), la représentation de l’intervalle vide (même borne à gauche et à droite), et la manipulation d’intervalles consécutifs (la borne droite de l’intervalle N devient la borne gauche de l’intervalle N+1).
En Rust, les intervalles sont principalement utilisés pour…
- Tester facilement si un nombre appartient à un intervalle, via la méthode
contains()
exposée par la plupart des types d’intervalles.#![allow(unused)] fn main() { let oui = (123..456).contains(&256); let non = (24..=42).contains(&666); println!("{oui} {non}"); }
- Itérer sur l’ensemble des entiers de l’intervalle (tous les intervalles
d’entiers ayant une borne inférieure se comportent comme des itérateurs
d’entiers).
#![allow(unused)] fn main() { for i in 1..=5 { println!("{i}"); } }
- Sélectionner des éléments d’un tableau (ou d’une entité proche d’un tableau
comme le type chaîne de caractère
str
) dont l’indice appartient à l’intervalle choisi.#![allow(unused)] fn main() { let tableau = [9, 8, 7, 6]; let fragment = &tableau[..2]; println!("{fragment:?}"); }
Nous reviendrons sur ces deux dernières possibilités lorsque nous aurons abordé
l’itération et les tableaux de tailles variables (slice
).