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 et isize
  • Les types entiers non signés u8, u16, u32, u64, u128 et usize.
  • Les types flottants f32 et f64.

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 et Into représentent les conversions qui réussissent toujours sans pertes d’information. Ces opérations permettent donc de convertir i8 en i16, mais pas f32 en usize.
  • TryFrom et TryInto 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 mais u8::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…

  1. 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.
  2. 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() et overflowing_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 du std::pow de C++, en Rust f.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 type RangeFull représentant l’ensemble des valeurs d’un type ordonné.
  • i.. est un RangeFrom représentant l’ensemble des x tels que i <= x.
  • i..j est un Range représentant l’ensemble des x tels que i <= x < j.
  • i..=j est un RangeInclusive représentant l’ensemble des x tels que i <= x <= j.
  • ..j est un RangeTo représentant l’ensemble des x tels que x < j.
  • ..=j est un RangeToInclusive représentant l’ensemble des x tels que x <= j.
  • Les autres cas sont couverts par des tuples (Bound, Bound)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).