Bỏ qua để đến nội dung

Số ngẫu nhiên trong Rust với rand

Trò chơi thì tung xúc xắc. Bài trắc nghiệm thì xáo trộn câu hỏi. Kiểm thử thì cần dữ liệu mẫu. Tất cả đều cần số ngẫu nhiên, mà thư viện chuẩn của Rust lại không có sẵn bộ sinh số ngẫu nhiên. Thay vào đó, gần như ai cũng dùng một crate: rand (tiếng Anh).

Trang này trình bày API hiện đại của rand 0.10. Điều đó quan trọng, vì rand đã đổi một số tên ở phiên bản 0.9 và 0.10. Nếu một bài hướng dẫn cũ dùng thread_rng() hay gen_range(), nó được viết cho phiên bản cũ hơn. Chúng ta sẽ xem các tên mới và những lỗi bạn gặp khi dùng tên cũ.

Trong thư mục dự án, chạy:

Terminal window
cargo add rand

Lệnh đó thêm một dòng như thế này vào Cargo.toml:

[dependencies]
rand = "0.10"

Nếu crate và Cargo.toml còn mới với bạn, hãy đọc trang đó trước. Các nút “Try it” trên trang này mở Rust Playground, nơi đã có sẵn rand.

Để lấy một giá trị ngẫu nhiên dùng một lần, hãy dùng các hàm tự do rand::random_range và rand::random:

fn main() {
let die: u32 = rand::random_range(1..=6);
let coin: bool = rand::random();
let chance: f64 = rand::random();
println!("xúc xắc nằm trong khoảng 1 đến 6: {}", (1..=6).contains(&die));
println!("chance nằm trong 0.0..1.0: {}", (0.0..1.0).contains(&chance));
println!("đồng xu ra mặt ngửa: {coin}");
}
▶ Tự chạy thử

Ở đây cố ý không có khối kết quả: dòng cuối là true hoặc false một cách ngẫu nhiên. Hãy bấm “Try it” vài lần và xem nó thay đổi.

  • random_range(1..=6) cho một số từ 1 đến 6, bao gồm cả 6. Với 1..6 bạn chỉ nhận được từ 1 đến 5. Khoảng (range) hoạt động giống như trong vòng lặp for.
  • random() chọn một giá trị thuộc kiểu bạn yêu cầu. Với bool thì đó là một lần tung đồng xu công bằng. Với f64 thì đó là một số từ 0.0 đến (nhưng không bao gồm) 1.0.
  • Chú thích kiểu (type annotation) : u32, : bool cho rand biết cần tạo ra gì. Không có nó, Rust có thể không biết bạn muốn kiểu nào.

Khi bạn cần nhiều giá trị ngẫu nhiên, hãy lấy một generator (bộ sinh) một lần bằng rand::rng() rồi gọi phương thức trên nó. Cách này nhanh hơn một chút, và là phong cách bạn sẽ thấy trong phần lớn code:

use rand::RngExt;
fn main() {
let mut rng = rand::rng();
let rolls: Vec<u32> = (0..1000).map(|_| rng.random_range(1..=6)).collect();
println!("đã tung {} viên xúc xắc", rolls.len());
println!("tất cả nằm trong khoảng 1 đến 6: {}", rolls.iter().all(|r| (1..=6).contains(r)));
let x: f64 = rng.random();
println!("x nằm trong 0.0..1.0: {}", (0.0..1.0).contains(&x));
}

Kết quả:

đã tung 1000 viên xúc xắc
tất cả nằm trong khoảng 1 đến 6: true
x nằm trong 0.0..1.0: true
▶ Tự chạy thử

Chúng ta in ra kết quả kiểm tra thay vì chính các con số, để kết quả lần nào cũng giống nhau dù các giá trị thì không.

Hai phương thức nữa mà bạn sẽ hay dùng:

let lucky = rng.random_bool(0.1); // true khoảng 10% số lần
let rare = rng.random_ratio(1, 100); // true khoảng 1 lần trong 100

Vì sao lại có dòng use rand::RngExt;? Các phương thức như random_range và random_bool đến từ một trait, và bạn chỉ gọi được phương thức của một trait khi trait đó nằm trong phạm vi (scope). Trong rand 0.10 có hai trait:

  • Rng là trait cơ bản “tôi có thể tạo ra các bit ngẫu nhiên”. Mọi generator đều cài đặt nó.
  • RngExt thêm các phương thức tiện dụng: random, random_range, random_bool và nhiều nữa. Nó tự động dùng được trên mọi Rng.

Tóm lại: import RngExt để gọi các phương thức, và dùng Rng khi bạn viết một hàm nhận “bất kỳ generator nào”. Bạn sẽ thấy cả hai trong ví dụ tung xúc xắc bên dưới.

rand::rng() cho ra những con số khác nhau mỗi lần chạy. Đó là điều bạn muốn trong một trò chơi thật. Nhưng với kiểm thử, gỡ lỗi, hay màn “thử thách hằng ngày” của một trò chơi, bạn muốn những con số “ngẫu nhiên” giống nhau mỗi lần.

Seed (hạt giống) làm được điều này. Một generator có seed luôn tạo ra cùng một dãy số với cùng một 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!("giống nhau? {}", first == second);
}

Kết quả:

[53, 82, 74, 51]
[53, 82, 74, 51]
giống nhau? true
▶ Tự chạy thử

seed_from_u64 đến từ trait SeedableRng, nên cũng cần import nó. Đổi 2024 thành một số khác là bạn có một dãy khác, nhưng vẫn là cùng một dãy ở mọi lần chạy.

Một lời cảnh báo thẳng thắn: thuật toán của StdRng được phép thay đổi trong một bản phát hành rand sau này. Các con số ở trên đến từ rand 0.10. Nếu test của bạn phải cho ra đúng những con số đó mãi mãi, rand book khuyên dùng một generator có tên cụ thể như ChaCha8Rng (xem phần “Đọc thêm” bên dưới).

Để làm việc với danh sách, hãy import thêm hai trait từ rand::seq:

  • SliceRandom cung cấp shuffle, xáo trộn một slice tại chỗ.
  • IndexedRandom cung cấp choose (một phần tử ngẫu nhiên) và sample (nhiều phần tử khác nhau).
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!("đã xáo: {:?}", cards);
let colors = ["đỏ", "xanh lá", "xanh dương"];
println!("đã chọn: {:?}", colors.choose(&mut rng));
let team: Vec<&str> = cards.sample(&mut rng, 2).copied().collect();
println!("đội: {:?}", team);
let empty: [i32; 0] = [];
println!("từ mảng rỗng: {:?}", empty.choose(&mut rng));
}

Kết quả:

đã xáo: ["K", "J", "10", "Q", "A"]
đã chọn: Some("đỏ")
đội: ["K", "J"]
từ mảng rỗng: None
▶ Tự chạy thử

choose trả về một Option, vì một slice rỗng thì không có gì để chọn. Các phương thức này dùng được với mảng, vector và slice như nhau. Thay rng có seed bằng rand::rng() là bạn có một lần xáo mới ở mỗi lần chạy.

Hãy viết một hàm tung xúc xắc. Mẹo nằm ở kiểu tham số: &mut impl Rng nghĩa là “bất kỳ bộ sinh số ngẫu nhiên nào”. Trong trò chơi thật bạn truyền rand::rng(). Trong một test bạn truyền một StdRng có seed và biết chính xác kết quả sẽ ra sao:

use rand::rngs::StdRng;
use rand::{Rng, RngExt, SeedableRng};
/// Tung `count` viên xúc xắc, mỗi viên có `sides` mặt.
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);
}
}

Kết quả:

2d6: [5, 6] = 11
1d20: [16] = 16
3d4: [3, 1, 2] = 6
▶ Tự chạy thử

2d6 là cách viết tắt của dân chơi board game, nghĩa là “hai viên xúc xắc sáu mặt”. Truyền generator vào, thay vì gọi rand::rng() bên trong roll, là một thói quen tốt: nó giúp hàm dễ kiểm thử.

Ở đây ta chọn 16 ký tự từ một tập ký tự cho phép. Tập này bỏ đi các ký tự dễ nhầm như 0/O và 1/l, để mật khẩu dễ gõ hơn:

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!("độ dài: {}", pw.len());
println!("chỉ gồm ký tự cho phép: {}", pw.bytes().all(|b| CHARSET.contains(&b)));
// println!("{pw}"); // bỏ dấu chú thích để xem: mỗi lần chạy một khác
}

Kết quả:

độ dài: 16
chỉ gồm ký tự cho phép: true
▶ Tự chạy thử

b"..." là một chuỗi byte: một &[u8] gồm các ký tự ASCII. choose chọn một byte, và as char biến nó thành một ký tự. Để ý rằng ở đây ta dùng rand::rng(), không bao giờ dùng generator có seed. Một mật khẩu tạo từ seed cố định thì lần nào cũng là cùng một mật khẩu, tức là chẳng phải mật khẩu gì cả.

Dự án trò chơi đoán số chọn số bí mật bằng một mẹo dùng đồng hồ, để chỉ cần thư viện chuẩn. Với rand, cả hàm secret_number trở thành một dòng:

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

Mọi thứ khác trong trò chơi giữ nguyên. Đây là một phiên bản bạn chạy được trên Playground. Số bí mật đến từ một generator có seed, và máy tính đoán bằng cách luôn chọn điểm giữa của khoảng còn lại:

use rand::rngs::StdRng;
use rand::{RngExt, SeedableRng};
use std::cmp::Ordering;
fn main() {
// Trong trò chơi thật: 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} nhỏ quá"); low = guess + 1; }
Ordering::Greater => { println!("{guess} lớn quá"); high = guess - 1; }
Ordering::Equal => { println!("{guess} là đúng! Tìm ra sau {attempts} lần đoán."); break; }
}
}
}

Kết quả:

50 nhỏ quá
75 nhỏ quá
88 nhỏ quá
94 nhỏ quá
97 nhỏ quá
99 là đúng! Tìm ra sau 6 lần đoán.
▶ Tự chạy thử

Một điều thú vị: chiến thuật “chia đôi khoảng” này không bao giờ cần quá 7 lần đoán cho khoảng từ 1 đến 100.

rand có an toàn cho mật khẩu và khóa không?

Phần tiêu đề “rand có an toàn cho mật khẩu và khóa không?”

Trả lời ngắn: rand::rng() được thiết kế để khó đoán trước, nhưng với những bí mật quan trọng, hãy dùng công cụ chuyên cho việc đó.

  • rand::rng() dùng một thuật toán an toàn về mật mã (hiện là ChaCha12). Nó được gieo seed từ nguồn ngẫu nhiên của hệ điều hành, thông qua crate getrandom (tiếng Anh), và tự gieo lại seed định kỳ. Tài liệu của rand gọi nó là một “generator nhanh, an toàn ở mức hợp lý”, và nói rằng họ không thể hứa nó đáp ứng mọi nhu cầu bảo mật.
  • Với khóa mã hóa, API token và những bí mật quan trọng khác, hãy lấy byte trực tiếp từ hệ điều hành bằng getrandom (hoặc rand::rngs::SysRng), hoặc tốt hơn nữa, để một thư viện mật mã chuyên dụng tạo khóa giúp bạn.
  • Không bao giờ dùng generator có seed (seed_from_u64) hay SmallRng (loại nhanh) cho bất cứ thứ gì bí mật. Ai biết hoặc đoán được seed đều có thể tạo ra đúng những giá trị “ngẫu nhiên” đó.

Với trò chơi, mô phỏng, xáo trộn và dữ liệu test, rand::rng() là lựa chọn hoàn toàn đúng.

Import Rng thay vì RngExt. Ở rand 0.9 các phương thức nằm trên Rng, nên code cũ viết use rand::Rng;. Với rand 0.10, điều đó gây ra:

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

Trình biên dịch nói rằng không tìm thấy phương thức random_range, vì trait cung cấp nó chưa nằm trong phạm vi, và gợi ý luôn cách sửa: use rand::RngExt;.

Chép code viết cho rand 0.8. Những ví dụ rất cũ dùng thread_rng() và 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`

Lỗi này nói rằng không tìm thấy hàm thread_rng trong crate rand. Tên mới là rand::rng() và random_range(). Tương tự, gen() đã thành random() và gen_bool() đã thành random_bool().

Lệch một ở khoảng giá trị. random_range(1..6) không bao giờ trả về 6. Với xúc xắc, hãy viết 1..=6.

  • Thêm crate bằng cargo add rand. Thư viện chuẩn của Rust không có bộ sinh số ngẫu nhiên riêng.
  • Để lấy giá trị nhanh, dùng rand::random_range(1..=6) và rand::random::<T>().
  • Khi cần nhiều giá trị, dùng let mut rng = rand::rng(); cùng use rand::RngExt; cho các phương thức như random_range và random_bool.
  • SliceRandom::shuffle xáo trộn một slice; IndexedRandom::choose chọn một phần tử và trả về một Option.
  • StdRng::seed_from_u64(n) cho cùng một dãy số ở mỗi lần chạy: tuyệt vời cho test và màn chơi lặp lại được, nhưng không bao giờ dùng cho bí mật.
  • Với khóa và token, hãy dùng nguồn ngẫu nhiên của hệ điều hành (getrandom) hoặc một crate mật mã chuyên dụng.

Kiểm tra nhanh

1. Lời gọi nào có thể trả về 6?

2. Với rand 0.10, rng.random_range(..) báo lỗi "no method named random_range". Còn thiếu gì?

3. Vì sao bạn dùng StdRng::seed_from_u64(42)?

4. choose trả về gì khi bạn gọi nó trên một slice rỗng?

Điểm: 0 / 4