Aller au contenu

Les nombres aléatoires en Rust avec rand

Les jeux lancent des dés. Les quiz mélangent leurs questions. Les tests ont besoin de données d’exemple. Tout cela demande des nombres aléatoires, et la bibliothèque standard de Rust ne propose pas de générateur de nombres aléatoires prêt à l’emploi. À la place, presque tout le monde utilise une seule crate : rand (en anglais).

Cette page présente l’API moderne de rand 0.10. C’est important, car rand a changé certains noms dans les versions 0.9 et 0.10. Si un ancien tutoriel montre thread_rng() ou gen_range(), il a été écrit pour une version plus ancienne. Nous verrons les nouveaux noms, ainsi que les erreurs que provoquent les anciens.

Dans le dossier de votre projet, lancez :

Terminal window
cargo add rand

Cela ajoute une ligne de ce genre à votre Cargo.toml :

[dependencies]
rand = "0.10"

Si les crates et Cargo.toml sont nouveaux pour vous, lisez d’abord cette page. Les boutons « Essayer » de cette page ouvrent le Playground Rust, qui inclut déjà rand.

Pour obtenir une seule valeur aléatoire, utilisez les fonctions libres rand::random_range et rand::random :

fn main() {
let die: u32 = rand::random_range(1..=6);
let coin: bool = rand::random();
let chance: f64 = rand::random();
println!("le dé est entre 1 et 6 : {}", (1..=6).contains(&die));
println!("chance est dans 0.0..1.0 : {}", (0.0..1.0).contains(&chance));
println!("la pièce tombe sur face : {coin}");
}
▶ Essayez par vous-même

Il n’y a volontairement pas de bloc de sortie ici : la dernière ligne affiche true ou false au hasard. Cliquez plusieurs fois sur « Essayer » et regardez-la changer.

  • random_range(1..=6) donne un nombre de 1 à 6, 6 compris. Avec 1..6, vous n’obtiendriez que 1 à 5. Les intervalles fonctionnent comme dans une boucle for.
  • random() choisit une valeur du type que vous demandez. Pour un bool, c’est un pile ou face équitable. Pour un f64, c’est un nombre entre 0.0 (compris) et 1.0 (non compris).
  • L’annotation de type (: u32, : bool) indique à rand ce qu’il doit fabriquer. Sans elle, Rust risque de ne pas savoir quel type vous voulez.

Quand vous avez besoin de beaucoup de valeurs aléatoires, récupérez une fois pour toutes un générateur avec rand::rng(), puis appelez ses méthodes. C’est un peu plus rapide, et c’est le style que vous verrez dans la plupart des programmes :

use rand::RngExt;
fn main() {
let mut rng = rand::rng();
let rolls: Vec<u32> = (0..1000).map(|_| rng.random_range(1..=6)).collect();
println!("{} dés lancés", rolls.len());
println!("tous entre 1 et 6 : {}", rolls.iter().all(|r| (1..=6).contains(r)));
let x: f64 = rng.random();
println!("x est dans 0.0..1.0 : {}", (0.0..1.0).contains(&x));
}

Sortie :

1000 dés lancés
tous entre 1 et 6 : true
x est dans 0.0..1.0 : true
▶ Essayez par vous-même

Nous affichons des vérifications plutôt que les nombres eux-mêmes : ainsi, la sortie est la même à chaque fois, même si les valeurs changent.

Deux autres méthodes que vous utiliserez souvent :

let lucky = rng.random_bool(0.1); // true environ 10 % du temps
let rare = rng.random_ratio(1, 100); // true environ 1 fois sur 100

Pourquoi la ligne use rand::RngExt; ? Les méthodes comme random_range et random_bool viennent d’un trait, et on ne peut appeler les méthodes d’un trait que si ce trait est dans la portée. Dans rand 0.10, il y a deux traits :

  • Rng est le trait de base, « je sais produire des bits aléatoires ». Chaque générateur l’implémente.
  • RngExt ajoute les méthodes pratiques : random, random_range, random_bool et d’autres. Il fonctionne automatiquement sur tout Rng.

En résumé : importez RngExt pour appeler les méthodes, et utilisez Rng quand vous écrivez une fonction qui accepte « n’importe quel générateur ». Vous verrez les deux dans le lanceur de dés plus bas.

rand::rng() donne des nombres différents à chaque exécution. C’est ce qu’on veut dans un vrai jeu. Mais pour les tests, le débogage ou le niveau « défi du jour » d’un jeu, on veut les mêmes nombres « aléatoires » à chaque fois.

C’est le rôle d’une graine (seed). Un générateur initialisé avec une graine produit toujours la même suite pour la même graine :

use rand::rngs::StdRng;
use rand::{RngExt, SeedableRng};
fn main() {
let mut a = StdRng::seed_from_u64(2024);
let mut b = StdRng::seed_from_u64(2024);
let first: Vec<u8> = (0..4).map(|_| a.random_range(0..100)).collect();
let second: Vec<u8> = (0..4).map(|_| b.random_range(0..100)).collect();
println!("{:?}", first);
println!("{:?}", second);
println!("identiques ? {}", first == second);
}

Sortie :

[53, 82, 74, 51]
[53, 82, 74, 51]
identiques ? true
▶ Essayez par vous-même

seed_from_u64 vient du trait SeedableRng, qu’il faut donc importer aussi. Remplacez 2024 par un autre nombre et vous obtiendrez une autre suite, mais toujours la même d’une exécution à l’autre.

Un avertissement honnête : l’algorithme de StdRng a le droit de changer dans une future version de rand. Les nombres ci-dessus viennent de rand 0.10. Si vos tests doivent donner exactement les mêmes nombres pour toujours, le rand book recommande un générateur nommé comme ChaCha8Rng (voir « Pour aller plus loin » en bas de page).

Pour travailler avec des listes, importez deux traits de plus depuis rand::seq :

  • SliceRandom fournit shuffle, qui mélange une slice sur place.
  • IndexedRandom fournit choose (un élément au hasard) et sample (plusieurs éléments différents).
use rand::rngs::StdRng;
use rand::seq::{IndexedRandom, SliceRandom};
use rand::SeedableRng;
fn main() {
let mut rng = StdRng::seed_from_u64(7);
let mut cards = vec!["As", "Roi", "Dame", "Valet", "10"];
cards.shuffle(&mut rng);
println!("mélangées : {:?}", cards);
let colors = ["rouge", "vert", "bleu"];
println!("choisie : {:?}", colors.choose(&mut rng));
let team: Vec<&str> = cards.sample(&mut rng, 2).copied().collect();
println!("équipe : {:?}", team);
let empty: [i32; 0] = [];
println!("dans un tableau vide : {:?}", empty.choose(&mut rng));
}

Sortie :

mélangées : ["Roi", "Valet", "10", "Dame", "As"]
choisie : Some("rouge")
équipe : ["Roi", "Valet"]
dans un tableau vide : None
▶ Essayez par vous-même

choose renvoie une Option, car une slice vide n’a rien à proposer. Ces méthodes fonctionnent aussi bien sur les tableaux, les vecteurs et les slices. Remplacez le rng à graine par rand::rng() et vous obtiendrez un nouveau mélange à chaque exécution.

Écrivons une fonction qui lance des dés. L’astuce est dans le type du paramètre : &mut impl Rng signifie « n’importe quel générateur aléatoire ». Dans le vrai jeu, vous passez rand::rng(). Dans un test, vous passez un StdRng avec une graine, et vous savez exactement ce qui va sortir :

use rand::rngs::StdRng;
use rand::{Rng, RngExt, SeedableRng};
/// Lance `count` dés de `sides` faces chacun.
fn roll(rng: &mut impl Rng, count: u32, sides: u32) -> Vec<u32> {
(0..count).map(|_| rng.random_range(1..=sides)).collect()
}
fn main() {
let mut rng = StdRng::seed_from_u64(1);
for (count, sides) in [(2, 6), (1, 20), (3, 4)] {
let rolls = roll(&mut rng, count, sides);
let total: u32 = rolls.iter().sum();
println!("{count}d{sides} : {:?} = {total}", rolls);
}
}

Sortie :

2d6 : [5, 6] = 11
1d20 : [16] = 16
3d4 : [3, 1, 2] = 6
▶ Essayez par vous-même

2d6 est la notation des jeux de société et de rôle pour « deux dés à six faces ». Passer le générateur en paramètre, plutôt que d’appeler rand::rng() à l’intérieur de roll, est une bonne habitude : la fonction reste facile à tester.

Ici, nous choisissons 16 caractères dans un ensemble autorisé. Cet ensemble exclut les caractères qui se ressemblent, comme 0/O et 1/l, pour que le mot de passe soit plus facile à taper :

use rand::seq::IndexedRandom;
use rand::Rng;
const CHARSET: &[u8] = b"ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz23456789!@#$%&*";
fn password(rng: &mut impl Rng, length: usize) -> String {
(0..length)
.map(|_| *CHARSET.choose(rng).unwrap() as char)
.collect()
}
fn main() {
let mut rng = rand::rng();
let pw = password(&mut rng, 16);
println!("longueur : {}", pw.len());
println!("uniquement des caractères autorisés : {}", pw.bytes().all(|b| CHARSET.contains(&b)));
// println!("{pw}"); // décommentez pour le voir : différent à chaque exécution
}

Sortie :

longueur : 16
uniquement des caractères autorisés : true
▶ Essayez par vous-même

b"..." est une chaîne d’octets : un &[u8] de caractères ASCII. choose choisit un octet, et as char le transforme en caractère. Remarquez que nous avons utilisé rand::rng() ici, et jamais un générateur à graine. Un mot de passe issu d’une graine fixe est le même à chaque fois, ce qui revient à ne pas avoir de mot de passe du tout.

Le projet de jeu de devinettes choisit son nombre secret avec une astuce basée sur l’horloge, pour n’utiliser que la bibliothèque standard. Avec rand, toute la fonction secret_number tient en une ligne :

let secret: u32 = rand::random_range(1..=100);

Tout le reste du jeu ne change pas. Voici une version que vous pouvez lancer dans le Playground. Le secret vient d’un générateur à graine, et l’ordinateur devine en choisissant toujours le milieu de l’intervalle restant :

use rand::rngs::StdRng;
use rand::{RngExt, SeedableRng};
use std::cmp::Ordering;
fn main() {
// Dans le vrai jeu : let secret: u32 = rand::random_range(1..=100);
let mut rng = StdRng::seed_from_u64(99);
let secret: u32 = rng.random_range(1..=100);
let (mut low, mut high) = (1, 100);
let mut attempts = 0;
loop {
let guess = (low + high) / 2;
attempts += 1;
match guess.cmp(&secret) {
Ordering::Less => { println!("{guess} est trop petit"); low = guess + 1; }
Ordering::Greater => { println!("{guess} est trop grand"); high = guess - 1; }
Ordering::Equal => { println!("{guess} est le bon ! Trouvé en {attempts} essais."); break; }
}
}
}

Sortie :

50 est trop petit
75 est trop petit
88 est trop petit
94 est trop petit
97 est trop petit
99 est le bon ! Trouvé en 6 essais.
▶ Essayez par vous-même

Le saviez-vous ? Cette stratégie qui « coupe l’intervalle en deux » n’a jamais besoin de plus de 7 essais pour un nombre entre 1 et 100.

rand est-il sûr pour les mots de passe et les clés ?

Section intitulée « rand est-il sûr pour les mots de passe et les clés ? »

Réponse courte : rand::rng() est conçu pour être difficile à prédire, mais pour les secrets sérieux, utilisez des outils faits pour cela.

  • rand::rng() utilise un algorithme cryptographiquement sûr (actuellement ChaCha12). Il reçoit sa graine de la source aléatoire de votre système d’exploitation, via la crate getrandom (en anglais), et se réinitialise régulièrement. La documentation de rand le décrit comme un « générateur rapide et raisonnablement sûr », et précise qu’elle ne peut pas promettre qu’il convienne à tous les besoins de sécurité.
  • Pour les clés de chiffrement, les jetons d’API et les autres secrets à fort enjeu, prenez les octets directement auprès du système d’exploitation avec getrandom (ou rand::rngs::SysRng), ou mieux, laissez une bibliothèque de cryptographie spécialisée générer la clé pour vous.
  • N’utilisez jamais un générateur à graine (seed_from_u64) ni le rapide SmallRng pour quoi que ce soit de secret. Quiconque connaît ou devine la graine peut reproduire les mêmes valeurs « aléatoires ».

Pour les jeux, les simulations, les mélanges et les données de test, rand::rng() est exactement ce qu’il faut.

Importer Rng au lieu de RngExt. Dans rand 0.9, les méthodes se trouvaient sur Rng, donc l’ancien code fait use rand::Rng;. Avec rand 0.10, cela donne :

use rand::Rng;
fn main() {
let mut rng = rand::rng();
let n = rng.random_range(1..=6);
println!("{n}");
}
error[E0599]: no method named `random_range` found for struct `ThreadRng` in the current scope
--> src/main.rs:5:17
|
5 | let n = rng.random_range(1..=6);
| ^^^^^^^^^^^^
|
= help: items from traits can only be used if the trait is in scope
help: trait `RngExt` which provides `random_range` is implemented but not in scope; perhaps you want to import it
|
1 + use rand::RngExt;
|

Le compilateur (en anglais) explique qu’aucune méthode random_range n’est disponible, car le trait qui la fournit n’est pas dans la portée. Il vous donne même la solution : use rand::RngExt;.

Copier du code écrit pour rand 0.8. Les très vieux exemples utilisent thread_rng() et gen_range() :

fn main() {
let n = rand::thread_rng().gen_range(1..=6);
println!("{n}");
}
error[E0425]: cannot find function `thread_rng` in crate `rand`
--> src/main.rs:2:19
|
2 | let n = rand::thread_rng().gen_range(1..=6);
| ^^^^^^^^^^ not found in `rand`

Autrement dit : « la fonction thread_rng n’existe pas dans la crate rand ». Les nouveaux noms sont rand::rng() et random_range(). De même, gen() est devenu random() et gen_bool() est devenu random_bool().

Les intervalles décalés d’un cran. random_range(1..6) ne renvoie jamais 6. Pour un dé, écrivez 1..=6.

  • Ajoutez la crate avec cargo add rand. La bibliothèque standard de Rust n’a pas de générateur de nombres aléatoires à elle.
  • Pour des valeurs ponctuelles, utilisez rand::random_range(1..=6) et rand::random::<T>().
  • Pour beaucoup de valeurs, utilisez let mut rng = rand::rng(); avec use rand::RngExt; pour accéder aux méthodes comme random_range et random_bool.
  • SliceRandom::shuffle mélange une slice ; IndexedRandom::choose choisit un élément et renvoie une Option.
  • StdRng::seed_from_u64(n) donne la même suite à chaque exécution : parfait pour les tests et les niveaux de jeu reproductibles, jamais pour des secrets.
  • Pour les clés et les jetons, utilisez la source aléatoire du système (getrandom) ou une crate de cryptographie spécialisée.

Petit quiz

1. Quel appel peut renvoyer 6 ?

2. Avec rand 0.10, rng.random_range(..) échoue avec « no method named random_range ». Que manque-t-il ?

3. Pourquoi utiliser StdRng::seed_from_u64(42) ?

4. Que renvoie choose quand on l'appelle sur une slice vide ?

Score : 0 / 4