跳转到内容

Rust 随机数

游戏要掷骰子,测验要打乱题目顺序,测试需要样例数据。这些都离不开随机数,而 Rust 的标准库并没有现成的随机数生成器。几乎所有人都会用同一个 crate:rand(英文)。

这一页介绍的是新版 rand 0.10 的 API。这一点很重要,因为 rand 在 0.9 和 0.10 版本里改了一些名字。如果某篇旧教程里出现了 thread_rng() 或 gen_range(),说明它是为旧版本写的。下面会介绍新名字,也会看看用旧名字时会报什么错。

在项目文件夹里运行:

Terminal window
cargo add rand

它会在 Cargo.toml 里加上这样一行:

[dependencies]
rand = "0.10"

如果你还不熟悉 crate 和 Cargo.toml,请先读那一页。这一页的“Try it”按钮会打开 Rust Playground,那里已经自带了 rand。

只想要一个随机值时,用自由函数 rand::random_range 和 rand::random:

fn main() {
let die: u32 = rand::random_range(1..=6);
let coin: bool = rand::random();
let chance: f64 = rand::random();
println!("骰子点数在 1 到 6 之间:{}", (1..=6).contains(&die));
println!("chance 在 0.0..1.0 之间:{}", (0.0..1.0).contains(&chance));
println!("硬币是正面:{coin}");
}
▶ 动手试一试

这里特意没有放输出:最后一行随机是 true 或 false。多点几次“Try it”,看看它怎么变。

  • random_range(1..=6) 给出 1 到 6 之间的数,包括 6。写成 1..6 的话,只会得到 1 到 5。区间的写法和 for 循环里一样。
  • random() 会生成你要求的那种类型的值。对 bool 来说,就是公平地抛一次硬币;对 f64 来说,是一个从 0.0 到 1.0(不包括 1.0)的数。
  • 类型注解(: u32、: bool)告诉 rand 要生成什么。没有它,Rust 可能不知道你想要哪种类型。

需要很多随机值时,先用 rand::rng() 拿到一个生成器,再调用它的方法。这样会快一点,而且大多数代码都是这种写法:

use rand::RngExt;
fn main() {
let mut rng = rand::rng();
let rolls: Vec<u32> = (0..1000).map(|_| rng.random_range(1..=6)).collect();
println!("掷了 {} 个骰子", rolls.len());
println!("全都在 1 到 6 之间:{}", rolls.iter().all(|r| (1..=6).contains(r)));
let x: f64 = rng.random();
println!("x 在 0.0..1.0 之间:{}", (0.0..1.0).contains(&x));
}

输出:

掷了 1000 个骰子
全都在 1 到 6 之间:true
x 在 0.0..1.0 之间:true
▶ 动手试一试

我们打印的是检查结果,而不是数字本身,所以虽然每次的值都不同,输出却每次都一样。

还有两个常用的方法:

let lucky = rng.random_bool(0.1); // 大约 10% 的概率为 true
let rare = rng.random_ratio(1, 100); // 大约每 100 次有 1 次为 true

为什么要写 use rand::RngExt; 这一行?random_range 和 random_bool 这样的方法来自一个 trait,只有当这个 trait 在作用域里时,才能调用它的方法。rand 0.10 里有两个 trait:

  • Rng 是最基础的“我能产生随机位”的 trait,每个生成器都实现了它。
  • RngExt 加上了好用的方法:random、random_range、random_bool 等等。它自动适用于所有 Rng。

所以:要调用这些方法就导入 RngExt;写一个接收“任意生成器”的函数时就用 Rng。后面的掷骰子程序里,这两个都会用到。

rand::rng() 每次运行都会给出不同的数字。在真正的游戏里,这正是你想要的。但在测试、调试或者游戏的“每日挑战”关卡里,你会希望每次都得到相同的“随机”数。

种子(seed)能做到这一点。同一个种子,带种子的生成器总是产生相同的序列:

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!("一样吗?{}", first == second);
}

输出:

[53, 82, 74, 51]
[53, 82, 74, 51]
一样吗?true
▶ 动手试一试

seed_from_u64 来自 SeedableRng trait,所以它也要导入。把 2024 换成别的数,会得到另一个序列,但每次运行仍然是同一个。

说句实话:StdRng 用的算法在将来的 rand 版本里可能会变。上面的数字来自 rand 0.10。如果你的测试必须永远得到完全相同的数字,rand 官方手册推荐使用有具体名字的生成器,比如 ChaCha8Rng(见下面的“延伸阅读”)。

要处理列表,需要再从 rand::seq 导入两个 trait:

  • SliceRandom 提供 shuffle,原地打乱一个 slice。
  • IndexedRandom 提供 choose(随机挑一个元素)和 sample(挑出几个不同的元素)。
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!["A", "K", "Q", "J", "10"];
cards.shuffle(&mut rng);
println!("打乱后:{:?}", cards);
let colors = ["red", "green", "blue"];
println!("选中:{:?}", colors.choose(&mut rng));
let team: Vec<&str> = cards.sample(&mut rng, 2).copied().collect();
println!("队伍:{:?}", team);
let empty: [i32; 0] = [];
println!("从空数组里选:{:?}", empty.choose(&mut rng));
}

输出:

打乱后:["K", "J", "10", "Q", "A"]
选中:Some("red")
队伍:["K", "J"]
从空数组里选:None
▶ 动手试一试

choose 返回一个 Option,因为空 slice 里没东西可选。这些方法对数组、vector 和 slice 都适用。把带种子的 rng 换成 rand::rng(),每次运行就会得到新的打乱结果。

来写一个掷骰子的函数。关键在参数类型:&mut impl Rng 的意思是“任意随机数生成器”。在真正的游戏里传入 rand::rng();在测试里传入带种子的 StdRng,就能确切知道会得到什么:

use rand::rngs::StdRng;
use rand::{Rng, RngExt, SeedableRng};
/// 掷 `count` 个骰子,每个骰子有 `sides` 个面。
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);
}
}

输出:

2d6: [5, 6] = 11
1d20: [16] = 16
3d4: [3, 1, 2] = 6
▶ 动手试一试

2d6 是桌游里的简写,意思是“两个六面骰”。把生成器作为参数传进去,而不是在 roll 里面调用 rand::rng(),是个好习惯:这样函数很容易测试。

这里从一组允许的字符里挑出 16 个。这组字符去掉了容易看错的字符,比如 0/O 和 1/l,这样密码更容易输入:

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!("长度:{}", pw.len());
println!("只含允许的字符:{}", pw.bytes().all(|b| CHARSET.contains(&b)));
// println!("{pw}"); // 取消注释就能看到密码:每次运行都不一样
}

输出:

长度:16
只含允许的字符:true
▶ 动手试一试

b"..." 是字节字符串:一个由 ASCII 字符组成的 &[u8]。choose 挑出一个字节,as char 把它变成字符。注意这里用的是 rand::rng(),绝对不要用带种子的生成器。用固定种子生成的密码每次都一样,那就根本不算密码了。

猜数字游戏项目用一个时钟小技巧来生成秘密数字,这样就只需要标准库。有了 rand,整个 secret_number 函数只要一行:

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

游戏的其他部分都不用改。下面是一个能在 Playground 里运行的版本。秘密数字来自带种子的生成器,电脑每次都猜剩余范围的正中间:

use rand::rngs::StdRng;
use rand::{RngExt, SeedableRng};
use std::cmp::Ordering;
fn main() {
// 在真正的游戏里: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} 太小了"); low = guess + 1; }
Ordering::Greater => { println!("{guess} 太大了"); high = guess - 1; }
Ordering::Equal => { println!("{guess} 猜对了!一共猜了 {attempts} 次。"); break; }
}
}
}

输出:

50 太小了
75 太小了
88 太小了
94 太小了
97 太小了
99 猜对了!一共猜了 6 次。
▶ 动手试一试

小知识:这种“每次把范围砍一半”的策略,在 1 到 100 之间最多只需要猜 7 次。

简短的回答:rand::rng() 的设计目标就是难以预测,但对于真正重要的机密,请用专门干这件事的工具。

  • rand::rng() 使用一种密码学安全的算法(目前是 ChaCha12)。它通过 getrandom(英文)crate 从操作系统的随机源获取种子,并定期重新播种。rand 文档称它是“快速、相当安全的生成器”,并表示无法保证它满足所有安全需求。
  • 对于加密密钥、API 令牌以及其他高风险的机密,直接用 getrandom(或 rand::rngs::SysRng)从操作系统获取字节;更好的做法是让专门的密码学库替你生成密钥。
  • 绝对不要把带种子的生成器(seed_from_u64)或快速的 SmallRng 用在任何机密上。知道或猜出种子的人,都能生成同样的“随机”值。

对于游戏、模拟、洗牌和测试数据,rand::rng() 恰到好处。

导入了 Rng 而不是 RngExt。在 rand 0.9 里,这些方法在 Rng 上,所以旧代码会写 use rand::Rng;。在 rand 0.10 里,这样会报错:

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;
|

意思是:在 ThreadRng 上找不到 random_range 方法,因为提供它的 trait RngExt 不在作用域里。编译器已经告诉你怎么修复了:use rand::RngExt;。

照抄为 rand 0.8 写的代码。很老的示例会用 thread_rng() 和 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`

编译器说在 rand 里找不到 thread_rng 函数。新名字是 rand::rng() 和 random_range()。同样,gen() 改成了 random(),gen_bool() 改成了 random_bool()。

区间差一。random_range(1..6) 永远不会返回 6。掷骰子要写 1..=6。

  • 用 cargo add rand 添加这个 crate。Rust 标准库自己没有随机数生成器。
  • 快速取值用 rand::random_range(1..=6) 和 rand::random::<T>()。
  • 需要很多值时,用 let mut rng = rand::rng();,再加上 use rand::RngExt;,就能调用 random_range、random_bool 等方法。
  • SliceRandom::shuffle 打乱一个 slice;IndexedRandom::choose 挑出一个元素,返回 Option。
  • StdRng::seed_from_u64(n) 每次运行都给出相同的序列:非常适合测试和可重玩的游戏关卡,但绝不能用于机密。
  • 密钥和令牌请用操作系统的随机源(getrandom)或专门的密码学 crate。

小测验

1. 哪个调用可能返回 6?

2. 在 rand 0.10 里,rng.random_range(..) 报错“no method named random_range”。缺了什么?

3. 为什么要用 StdRng::seed_from_u64(42)?

4. 在空 slice 上调用 choose 会返回什么?

得分: 0 / 4