Aller au contenu

Le dépassement d'entier en Rust

En Rust, chaque type entier a une taille fixe. Un u8 a de la place pour les nombres de 0 à 255, et rien d’autre. Alors que se passe-t-il si vous avez 255 et que vous ajoutez 1 ? Le résultat ne rentre pas. C’est ce qu’on appelle un dépassement d’entier (integer overflow).

Pensez au compteur kilométrique d’une vieille voiture qui n’aurait que trois chiffres. Après 999 kilomètres, il repasse à 000. Pour un jouet, ce n’est pas grave ; pour le solde d’un compte en banque, c’est catastrophique. Rust vous aide de deux façons. Il signale le problème haut et fort pendant que vous développez. Et il vous donne des méthodes simples pour dire exactement ce qui doit se passer à la place. Pas d’inquiétude : il n’y en a que quatre familles, et elles suivent toutes le même principe. Cette page s’appuie sur Les types de données et Les opérateurs.

Chaque type a une plus petite valeur (MIN) et une plus grande valeur (MAX). Vous pouvez les afficher avec des constantes comme u8::MAX ou i32::MIN :

fn main() {
println!("u8: {} à {}", u8::MIN, u8::MAX);
println!("i8: {} à {}", i8::MIN, i8::MAX);
println!("u16: {} à {}", u16::MIN, u16::MAX);
println!("i32: {} à {}", i32::MIN, i32::MAX);
println!("u32: {} à {}", u32::MIN, u32::MAX);
println!("i64: {} à {}", i64::MIN, i64::MAX);
println!("u64: {} à {}", u64::MIN, u64::MAX);
println!("u128: {} à {}", u128::MIN, u128::MAX);
}

Sortie :

u8: 0 à 255
i8: -128 à 127
u16: 0 à 65535
i32: -2147483648 à 2147483647
u32: 0 à 4294967295
i64: -9223372036854775808 à 9223372036854775807
u64: 0 à 18446744073709551615
u128: 0 à 340282366920938463463374607431768211455
▶ Essayez par vous-même

Voici toute la famille dans un seul tableau :

Type Bits Plus petite valeur Plus grande valeur
i8 / u8 8 -128 / 0 127 / 255
i16 / u16 16 -32 768 / 0 32 767 / 65 535
i32 / u32 32 environ -2,1 milliards / 0 environ 2,1 milliards / 4,3 milliards
i64 / u64 64 environ -9,2 × 10^18 / 0 environ 9,2 × 10^18 / 1,8 × 10^19
i128 / u128 128 environ -1,7 × 10^38 / 0 environ 1,7 × 10^38 / 3,4 × 10^38
isize / usize 64 sur la plupart des ordinateurs comme i64 / 0 comme i64 / u64

Un type signé (i) consacre la moitié de sa plage aux nombres négatifs. Un type non signé (u) commence à 0, donc sa plus grande valeur est environ deux fois plus grande. Tout ce qui sort de ces plages est un dépassement, et descendre sous MIN (comme 0 - 1 sur un u8) compte aussi. On parle parfois de dépassement par le bas (underflow), mais Rust le traite exactement de la même façon.

Quand vous lancez votre programme avec cargo run (une compilation en mode debug), Rust vérifie chaque +, - et *. Si le résultat ne rentre pas, le programme s’arrête avec un message clair. Ici, un jeu ajoute des points de bonus à un score de type u8 :

fn add_points(score: u8, bonus: u8) -> u8 {
score + bonus
}
fn main() {
let total = add_points(250, 10);
println!("total : {total}");
}

À l’exécution, vous obtenez :

thread 'main' (12345) panicked at src/main.rs:2:5:
attempt to add with overflow
note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace
▶ Essayez par vous-même

Le message de Rust (en anglais) dit que le programme a « paniqué » à la ligne 2, colonne 5, en tentant une addition qui déborde. Les valeurs passent volontairement par des paramètres de fonction. Si vous écriviez les nombres juste à côté du +, le compilateur repérerait le problème avant même que le programme ne s’exécute (vous le verrez dans un instant). Un plantage peut sembler brutal, mais c’est un service qu’on vous rend : vous trouvez le bug sur votre propre machine, avec le numéro de ligne exact, au lieu d’obtenir un score faux en production. La page Panic en Rust en dit plus sur les panics (paniques).

Quand vous compilez avec cargo run --release, ces vérifications sont désactivées pour rendre le code un peu plus rapide. Le nombre fait le tour (wrapping), comme le compteur kilométrique : 250 + 10 sur un u8 donne 4 (car 260 - 256 = 4).

Terminal window
cargo run --release
total : 4

Pas de panic, pas d’avertissement, juste un nombre faux, en silence. Voilà pourquoi il ne faut pas compter sur un dépassement « par accident ». Si vous voulez que le nombre fasse le tour, dites-le avec wrapping_add (présenté plus bas). Les boutons « Essayez par vous-même » de ce site lancent des compilations en mode debug : wrapping_add est donc aussi le moyen d’y voir ce comportement :

fn main() {
let score: u8 = 250;
println!("{}", score.wrapping_add(10));
}

Sortie :

4
▶ Essayez par vous-même

Si vous voulez que les compilations release déclenchent aussi une panic, vous pouvez réactiver les vérifications dans Cargo.toml :

[profile.release]
overflow-checks = true

Avec des nombres écrits en dur, ça ne compile même pas

Section intitulée « Avec des nombres écrits en dur, ça ne compile même pas »

Quand le compilateur voit tout le calcul, il refuse de le compiler :

fn main() {
let level: u8 = 255 + 1;
println!("{level}");
}
error: this arithmetic operation will overflow
--> src/main.rs:2:21
|
2 | let level: u8 = 255 + 1;
| ^^^^^^^ attempt to compute `u8::MAX + 1_u8`, which would overflow
|
= note: `#[deny(arithmetic_overflow)]` on by default

Le compilateur prévient que cette opération arithmétique va déborder. C’est la même chose pour un littéral tout simplement trop grand, comme let level: u8 = 256;, qui donne literal out of range for u8 (littéral hors de la plage d’un u8).

Plutôt que d’espérer qu’un dépassement n’arrive jamais, vous pouvez choisir ce qui doit se passer. Chaque type entier propose quatre familles de méthodes pour ça. Elles fonctionnent de la même manière sur u8, i32, u64 et tous les autres.

checked_add, checked_sub et checked_mul renvoient une Option : Some(résultat) si ça rentre, None sinon. C’est le choix le plus sûr dès qu’un nombre faux poserait un vrai problème, comme pour de l’argent. Un solde bancaire ne peut pas descendre sous 0, donc un u64 avec checked_sub refuse le retrait à votre place :

fn withdraw(balance: u64, amount: u64) -> Option<u64> {
balance.checked_sub(amount)
}
fn order_total(price: u32, quantity: u32, shipping: u32) -> Option<u32> {
price.checked_mul(quantity)?.checked_add(shipping)
}
fn main() {
let balance: u64 = 100;
for amount in [30, 250] {
match withdraw(balance, amount) {
Some(left) => println!("Retrait de {amount}, le solde est maintenant de {left}"),
None => println!("Impossible de retirer {amount} : solde insuffisant"),
}
}
println!("{:?}", order_total(20, 3, 5));
println!("{:?}", order_total(2_000_000_000, 3, 5));
}

Sortie :

Retrait de 30, le solde est maintenant de 70
Impossible de retirer 250 : solde insuffisant
Some(65)
None
▶ Essayez par vous-même

Comme le résultat est une Option, l’opérateur ? fonctionne aussi. Dans order_total, si la multiplication déborde, la fonction renvoie None immédiatement.

saturating_add et saturating_sub ne dépassent jamais MIN ni MAX. Elles s’arrêtent simplement là, comme un verre déjà plein. C’est parfait pour tout ce qui a un plancher ou un plafond naturel : une barre de santé, un bouton de volume, des articles en stock.

fn main() {
let health: u8 = 20;
let after_hit = health.saturating_sub(35);
println!("santé après un gros coup : {after_hit}");
let volume: u8 = 90;
let louder = volume.saturating_add(25).min(100);
println!("volume après l'avoir monté : {louder}");
let stock: u32 = 3;
let left = stock.saturating_sub(5);
println!("articles restants en stock : {left}");
}

Sortie :

santé après un gros coup : 0
volume après l'avoir monté : 100
articles restants en stock : 0
▶ Essayez par vous-même

La ligne du volume demande une étape de plus. saturating_add ne s’arrête qu’à la limite du type lui-même, 255 pour un u8. Votre bouton s’arrête à 100, donc .min(100) ramène la valeur à votre propre limite.

Parfois, faire le tour est exactement ce que vous voulez. Un distributeur de tickets qui fait 254, 255, 0, 1. Ou une fonction de hachage simple, où les nombres sont justement censés être mélangés. wrapping_add et wrapping_mul font ça dans toutes les compilations, debug comme release :

fn simple_hash(text: &str) -> u32 {
let mut hash: u32 = 5381;
for byte in text.bytes() {
hash = hash.wrapping_mul(33).wrapping_add(byte as u32);
}
hash
}
fn main() {
let mut ticket: u8 = 254;
for _ in 0..4 {
println!("ticket {ticket}");
ticket = ticket.wrapping_add(1);
}
println!("hash de \"hello\" : {}", simple_hash("hello"));
println!("hash de \"a much longer sentence\" : {}", simple_hash("a much longer sentence"));
}

Sortie :

ticket 254
ticket 255
ticket 0
ticket 1
hash de "hello" : 261238937
hash de "a much longer sentence" : 3373490575
▶ Essayez par vous-même

Les textes hachés restent en anglais, car le hash est calculé à partir de leurs octets : une lettre accentuée prend 2 octets en UTF-8 et changerait le résultat. Avec de simples * et +, la longue phrase ferait déborder un u32 de nombreuses fois. Avec les méthodes wrapping_, ce n’est pas un problème : c’est justement ce que le hash est censé faire.

overflowing_add renvoie un tuple : le résultat après le tour et un bool qui indique s’il y a eu dépassement. C’est pratique quand vous voulez la valeur après le tour, mais aussi savoir que ça s’est produit :

fn main() {
let (value, overflowed) = 250u8.overflowing_add(10);
println!("{value}, dépassement : {overflowed}");
let (value, overflowed) = 50u8.overflowing_add(10);
println!("{value}, dépassement : {overflowed}");
}

Sortie :

4, dépassement : true
60, dépassement : false
▶ Essayez par vous-même

Vous utiliserez celle-ci moins souvent que les autres. Si vous ne savez pas laquelle choisir, checked_ est un excellent choix par défaut.

Convertir entre types avec as ne déclenche jamais de panic, même en mode debug. Si la valeur ne rentre pas, elle est discrètement tronquée pour rentrer. Ça surprend beaucoup de monde, alors soyez prudent :

fn main() {
let big: i32 = 300;
let negative: i32 = -1;
println!("300 as u8 = {}", big as u8);
println!("-1 as u32 = {}", negative as u32);
match u8::try_from(big) {
Ok(small) => println!("ça rentre : {small}"),
Err(e) => println!("300 ne rentre pas dans un u8 : {e}"),
}
match u8::try_from(200) {
Ok(small) => println!("ça rentre : {small}"),
Err(e) => println!("200 ne rentre pas dans un u8 : {e}"),
}
}

Sortie :

300 as u8 = 44
-1 as u32 = 4294967295
300 ne rentre pas dans un u8 : out of range integral type conversion attempted
ça rentre : 200
▶ Essayez par vous-même

Le message d’erreur vient de Rust et reste en anglais : il signale une tentative de conversion d’entier hors de la plage autorisée. 300 as u8 ne garde que les 8 bits de poids faible, ce qui donne 44. -1 as u32 devient le plus grand u32. Ni l’un ni l’autre ne vous prévient. u8::try_from vérifie d’abord et vous donne un Result, pour que vous décidiez quoi faire. Une bonne habitude : n’utilisez as que si vous savez que la valeur rentre, et try_from quand elle vient de l’extérieur de votre programme. (Les nombres à virgule se comportent autrement : 300.7 as u8 s’arrête à 255 au lieu de faire le tour.) Les pages Conversion de types et Les conversions vont plus loin.

Quelques autres pièges classiques, réunis au même endroit :

fn main() {
println!("7 / 2 = {}", 7 / 2);
println!("-7 / 2 = {}", -7 / 2);
println!("i32::MIN.checked_abs() = {:?}", i32::MIN.checked_abs());
println!("i32::MIN.unsigned_abs() = {}", i32::MIN.unsigned_abs());
println!("10u32.checked_pow(9) = {:?}", 10u32.checked_pow(9));
println!("10u32.checked_pow(10) = {:?}", 10u32.checked_pow(10));
}

Sortie :

7 / 2 = 3
-7 / 2 = -3
i32::MIN.checked_abs() = None
i32::MIN.unsigned_abs() = 2147483648
10u32.checked_pow(9) = Some(1000000000)
10u32.checked_pow(10) = None
▶ Essayez par vous-même
  • La division entière laisse tomber le reste. Elle n’arrondit pas : elle coupe vers zéro, donc -7 / 2 vaut -3. Utilisez % pour obtenir le reste, ou des nombres à virgule si vous voulez des décimales.
  • abs() peut déborder. Un i32 descend jusqu’à -2147483648 mais ne monte que jusqu’à 2147483647 : la version positive de i32::MIN ne rentre donc pas. i32::MIN.abs() déclenche une panic en mode debug avec attempt to negate with overflow. Utilisez checked_abs(), ou unsigned_abs(), qui renvoie un u32, où la valeur rentre.
  • pow grandit vite. 10 puissance 10 est déjà trop grand pour un u32. Il existe aussi checked_pow, saturating_pow et wrapping_pow.

Si une valeur doit toujours faire le tour ou toujours saturer, vous pouvez l’envelopper dans std::num::Wrapping ou std::num::Saturating. Les simples + et - font alors ce qu’il faut, et vous ne pouvez plus oublier :

use std::num::{Saturating, Wrapping};
fn main() {
let mut ticket = Wrapping(254u8);
ticket += 1;
ticket += 1;
ticket += 1;
println!("ticket : {ticket}");
let mut battery = Saturating(10u8);
battery -= 25;
println!("batterie : {battery}");
}

Sortie :

ticket : 1
batterie : 0
▶ Essayez par vous-même
Famille de méthodes Renvoie À utiliser quand
+ - * Le nombre (panic en debug, fait le tour en release) Vous êtes sûr qu’il n’y aura pas de dépassement
checked_add et compagnie Option<T> Un nombre faux serait un bug : argent, tailles, identifiants
saturating_add et compagnie Le nombre, bloqué à MIN ou MAX Il existe un plancher ou un plafond naturel : santé, volume, stock
wrapping_add et compagnie Le nombre, après avoir fait le tour Faire le tour est le but : hachages, compteurs qui tournent en boucle
overflowing_add et compagnie (T, bool) Vous voulez la valeur après le tour et savoir si le tour a eu lieu
T::try_from(x) Result<T, _> Vous convertissez une valeur qui risque de ne pas rentrer dans un type plus petit

len() renvoie un usize, qui ne peut pas être négatif. Sur une liste vide, len() - 1 vaudrait -1, donc il y a dépassement :

fn last_index(tasks: &[String]) -> usize {
tasks.len() - 1
}
fn main() {
let tasks: Vec<String> = Vec::new();
println!("la dernière tâche est à l'indice {}", last_index(&tasks));
}

À l’exécution, vous obtenez :

thread 'main' (12345) panicked at src/main.rs:2:5:
attempt to subtract with overflow
note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace
▶ Essayez par vous-même

Cette fois, la panic signale une soustraction qui déborde. Presque tout le monde tombe dans ce piège au moins une fois. La solution consiste à laisser le type vous dire que la liste peut être vide : renvoyez tasks.len().checked_sub(1), qui est une Option<usize>. Et si vous voulez juste le dernier élément, tasks.last() est encore plus simple.

Trois notes de 90, 85 et 100 rentrent chacune dans un u8. Leur total, 275, ne rentre pas. Du coup, let total: u8 = scores.iter().sum(); déclenche une panic avec attempt to add with overflow. La panic pointe vers l’intérieur de la bibliothèque standard, ce qui peut dérouter, mais la cause, c’est bien votre u8. Convertissez chaque valeur vers un type plus grand avant de les additionner :

fn main() {
let scores: Vec<u8> = vec![90, 85, 100];
let total: u32 = scores.iter().map(|&s| s as u32).sum();
println!("total : {total}, moyenne : {}", total / 3);
}

Sortie :

total : 275, moyenne : 91
▶ Essayez par vous-même

Passer de u8 à u32 avec as est toujours sans risque, car tout u8 rentre dans un u32.

Le niveau de batterie d’un téléphone est un u8 de 0 à 100. Écrivez charge(level: u8, amount: u8) -> u8 et drain(level: u8, amount: u8) -> u8, qui ne dépassent jamais 100 et ne descendent jamais sous 0. Partez de 80, chargez de 30, puis déchargez de 130, et affichez le niveau après chaque étape. Indice : saturating_add avec .min(100) pour la charge, saturating_sub pour la décharge.

Voir la solution
fn charge(level: u8, amount: u8) -> u8 {
level.saturating_add(amount).min(100)
}
fn drain(level: u8, amount: u8) -> u8 {
level.saturating_sub(amount)
}
fn main() {
let mut level: u8 = 80;
println!("départ : {level} %");
level = charge(level, 30);
println!("après la charge : {level} %");
level = drain(level, 130);
println!("après une longue journée : {level} %");
}

Sortie :

départ : 80 %
après la charge : 100 %
après une longue journée : 0 %
▶ Essayez par vous-même

saturating_sub s’arrête tout seul à 0. Pour la charge, la limite du type est 255, donc .min(100) ajoute votre propre limite par-dessus.

Écrivez fn factorial(n: u64) -> Option<u64>, qui calcule 1 × 2 × … × n et renvoie None si le résultat devient trop grand pour un u64. Affichez le résultat pour 5, 20 et 21. Indice : commencez par let mut result: u64 = 1; et utilisez result.checked_mul(i)? dans une boucle for.

Voir la solution
fn factorial(n: u64) -> Option<u64> {
let mut result: u64 = 1;
for i in 1..=n {
result = result.checked_mul(i)?;
}
Some(result)
}
fn main() {
for n in [5, 20, 21] {
match factorial(n) {
Some(value) => println!("{n}! = {value}"),
None => println!("{n}! est trop grand pour un u64"),
}
}
}

Sortie :

5! = 120
20! = 2432902008176640000
21! est trop grand pour un u64
▶ Essayez par vous-même

Le ? fait renvoyer None à toute la fonction dès qu’une multiplication ne rentre pas. 20! est la plus grande factorielle qu’un u64 peut contenir.

  • Chaque type entier a une plage fixe, de T::MIN à T::MAX. En sortir, c’est un dépassement.
  • En mode debug, le programme déclenche une panic avec attempt to add with overflow. En mode release, le nombre fait le tour sans rien dire. Un dépassement que le compilateur peut voir est une erreur de compilation.
  • Choisissez vous-même le comportement : checked_ donne une Option, saturating_ s’arrête au bord, wrapping_ fait le tour, overflowing_ donne la valeur et un bool.
  • as ne vérifie jamais rien et peut changer un nombre en silence. Utilisez T::try_from quand une valeur risque de ne pas rentrer.
  • Méfiez-vous de len() - 1 sur une liste vide, des sommes dans de petits types, de abs() sur MIN, et de pow.

Petit quiz

1. Que devient 250u8 + 10 en mode debug, quand les valeurs viennent de variables ?

2. Que renvoie 100u64.checked_sub(250) ?

3. Une barre de santé ne doit jamais descendre sous 0. Quelle méthode convient le mieux ?

4. Que vaut 300_i32 as u8 ?

Score : 0 / 4