Dự án: trò chơi đoán số bằng Rust
Đây là dự án thật đầu tiên của bạn. Máy tính chọn một số bí mật từ 1 đến 100. Bạn đoán. Nó báo “nhỏ quá” hoặc “lớn quá” cho đến khi bạn đoán đúng, rồi cho bạn biết mình đã cần bao nhiêu lần thử.
Chương trình nhỏ thôi, nhưng nó dùng rất nhiều thứ bạn đã học: biến, match, vòng lặp và Result. Chúng ta sẽ xây nó từng bước một, và bước nào cũng tự chạy được.
Bạn sẽ xây dựng gì
Phần tiêu đề “Bạn sẽ xây dựng gì”Khi xong, trò chơi trên máy của bạn sẽ trông như thế này:
Đoán số đi! (từ 1 đến 100)Hãy nhập số bạn đoán:50Lớn quá!Hãy nhập số bạn đoán:helloĐó không phải là số nguyên. Thử lại nhé!Hãy nhập số bạn đoán:25Nhỏ quá!Hãy nhập số bạn đoán:37Bạn đoán đúng sau 3 lần!Để ý hai chi tiết thân thiện. Khi người chơi gõ hello, trò chơi không crash. Nó hỏi lại. Và lần nhập sai đó không bị tính là một lần đoán.
Bước 1: Tạo project
Phần tiêu đề “Bước 1: Tạo project”Mở terminal và tạo một project Cargo mới:
cargo new guessing_gamecd guessing_gameToàn bộ code trên trang này nằm trong src/main.rs. Chạy nó bất cứ lúc nào bằng:
cargo runNếu bạn chưa cài Rust, hãy xem Bắt đầu trước.
Bước 2: Đọc số đoán từ bàn phím
Phần tiêu đề “Bước 2: Đọc số đoán từ bàn phím”Để đọc những gì người chơi gõ, hãy dùng std::io::stdin() và phương thức read_line của nó:
use std::io;
fn main() { println!("Hãy nhập số bạn đoán:");
let mut guess = String::new(); io::stdin() .read_line(&mut guess) .expect("Failed to read line");
println!("Bạn đoán: {guess}");}Chương trình này chờ bạn gõ gì đó và nhấn Enter, nên hãy chạy nó bằng cargo run trên máy của bạn. (Rust Playground không có bàn phím để nhập, nên ở đây không có nút “Try it”.)
Có vài chuyện đang diễn ra:
String::new()tạo một String rỗng, có thể lớn dần, để chứa chữ.read_line(&mut guess)thêm dòng được gõ vào cuốiguess. Nó cần&mutvì nó thay đổi chuỗi. Xem Mượn nếu&mutcòn mới với bạn.read_linetrả về mộtResult, vì việc đọc dữ liệu nhập có thể thất bại..expect(...)nói rằng “nếu thất bại, hãy dừng chương trình với thông báo này”. Tạm thời như vậy là ổn; lát nữa ta sẽ xử lý nhẹ nhàng hơn.
Một điều bất ngờ: chữ nhận được có cả phím Enter. Nếu bạn gõ 42, guess chứa "42\n". Hãy nhớ điều đó cho bước tiếp theo.
Bước 3: Biến chữ thành số
Phần tiêu đề “Bước 3: Biến chữ thành số”Người chơi gõ chữ, nhưng ta muốn một con số để so sánh. .trim() bỏ khoảng trắng và ký tự xuống dòng ở hai đầu. Sau đó .parse() thử biến chữ thành số.
Việc parse có thể thất bại. "seven" không phải là số. Vì vậy parse() trả về một Result, và ta dùng match để xử lý cả hai trường hợp:
fn main() { // Giả vờ người chơi đã gõ những dòng này (mỗi dòng kết thúc bằng Enter). let inputs = ["42\n", " 7 \n", "seven\n", "-3\n"];
for input in inputs { match input.trim().parse::<u32>() { Ok(num) => println!("{:?} -> số {num}", input), Err(e) => println!("{:?} -> không phải số đoán hợp lệ ({e})", input), } }}Kết quả:
"42\n" -> số 42" 7 \n" -> số 7"seven\n" -> không phải số đoán hợp lệ (invalid digit found in string)"-3\n" -> không phải số đoán hợp lệ (invalid digit found in string)Thông báo lỗi trong ngoặc là của chính Rust, nên vẫn là tiếng Anh: “invalid digit found in string” nghĩa là “tìm thấy ký tự không phải chữ số trong chuỗi”. parse::<u32>() cho Rust biết cần parse thành kiểu nào. u32 là số nguyên không dấu (không bao giờ âm), vì vậy -3 bị từ chối. Đó đúng là điều ta muốn: số bí mật luôn nằm trong khoảng 1 đến 100.
Trong trò chơi, ta viết cùng ý tưởng đó như sau:
let guess: u32 = match guess.trim().parse() { Ok(num) => num, Err(_) => { println!("Đó không phải là số nguyên. Thử lại nhé!"); continue; }};Ở đây kiểu đến từ let guess: u32, nên ta không cần ::<u32>. Nếu parse thành công, match trả về con số. Nếu thất bại, ta in một lời nhắn tử tế và continue để hỏi lại. (continue chỉ dùng được bên trong vòng lặp, thứ ta sẽ thêm ở Bước 5.)
Sao không dùng luôn .expect()? Vì khi đó một lỗi gõ phím sẽ làm sập cả trò chơi. Xử lý trường hợp Err là cách bạn làm ra một chương trình dễ chịu khi dùng.
Bước 4: So sánh số đoán với số bí mật
Phần tiêu đề “Bước 4: So sánh số đoán với số bí mật”Mọi con số đều có phương thức .cmp(). Nó so sánh hai giá trị và trả về một std::cmp::Ordering, một enum với ba biến thể: Less (nhỏ hơn), Greater (lớn hơn) và Equal (bằng). Một match trên nó đọc gần như văn xuôi:
use std::cmp::Ordering;
fn main() { let secret: u32 = 42;
for guess in [10, 42, 99] { match guess.cmp(&secret) { Ordering::Less => println!("{guess}: Nhỏ quá!"), Ordering::Greater => println!("{guess}: Lớn quá!"), Ordering::Equal => println!("{guess}: Bạn thắng!"), } }}Kết quả:
10: Nhỏ quá!42: Bạn thắng!99: Lớn quá!Bạn có thể viết điều tương tự bằng if guess < secret ... else if .... Phiên bản match hay ở chỗ trình biên dịch kiểm tra rằng bạn đã xử lý cả ba trường hợp. Quên một cái là không biên dịch được.
Bước 5: Hỏi mãi cho đến khi đoán đúng
Phần tiêu đề “Bước 5: Hỏi mãi cho đến khi đoán đúng”Giờ ta bọc mọi thứ trong một loop. Nó chạy mãi mãi cho đến khi ta break ra ngoài. Ta cũng giữ một bộ đếm, attempts, và chỉ cộng 1 khi số đoán là một con số thật.
Trong trò chơi thật, dữ liệu nhập đến từ bàn phím. Để có một phiên bản chạy được trên Playground, ta dùng một số bí mật cố định và một danh sách cố định các dòng “đã gõ”. inputs.next() cho ta dòng tiếp theo, hoặc None khi danh sách hết:
use std::cmp::Ordering;
fn main() { let secret: u32 = 37; // Giả vờ người chơi đã gõ những dòng này, mỗi dòng một số. let mut inputs = ["50", "hello", "25", "37"].into_iter(); let mut attempts = 0;
println!("Đoán số đi! (từ 1 đến 100)");
loop { let input = match inputs.next() { Some(line) => line, None => { println!("Hết lượt đoán. Số bí mật là {secret}."); break; } }; println!("> {input}");
let guess: u32 = match input.trim().parse() { Ok(num) => num, Err(_) => { println!("Đó không phải là số nguyên. Thử lại nhé!"); continue; } };
attempts += 1;
match guess.cmp(&secret) { Ordering::Less => println!("Nhỏ quá!"), Ordering::Greater => println!("Lớn quá!"), Ordering::Equal => { println!("Bạn đoán đúng sau {attempts} lần!"); break; } } }}Kết quả:
Đoán số đi! (từ 1 đến 100)> 50Lớn quá!> helloĐó không phải là số nguyên. Thử lại nhé!> 25Nhỏ quá!> 37Bạn đoán đúng sau 3 lần!Hãy thử đổi số bí mật hoặc danh sách số đoán rồi chạy lại. Đặt đáp án đúng lên đầu, hoặc bỏ nó đi, và xem chuyện gì xảy ra.
Trường hợp None rất quan trọng. Đó là phiên bản Playground của tình huống “người chơi không còn gì để nhập”, và trò chơi thật cũng cần ý tưởng này (xem Bước 7).
Bước 6: Chọn một số bí mật ngẫu nhiên
Phần tiêu đề “Bước 6: Chọn một số bí mật ngẫu nhiên”Một trò chơi lần nào cũng cùng một số bí mật thì chẳng vui mấy. Trong một project thật, bạn sẽ dùng crate rand (tiếng Anh), lựa chọn chuẩn cho số ngẫu nhiên trong Rust. Bài Số ngẫu nhiên chỉ cách dùng nó. Bạn thêm nó bằng:
cargo add randTrong dự án này, ta chỉ dùng thư viện chuẩn, để bạn không phải cài đặt gì. Mẹo là: hỏi đồng hồ thời gian hiện tại, lấy phần nano giây (một số từ 0 đến 999.999.999 thay đổi liên tục), rồi ép nó vào khoảng 1 đến 100 bằng % (phép chia lấy dư):
use std::time::{SystemTime, UNIX_EPOCH};
fn secret_number() -> u32 { let nanos = SystemTime::now() .duration_since(UNIX_EPOCH) .expect("đồng hồ đang đặt trước năm 1970") .subsec_nanos(); nanos % 100 + 1}
fn main() { let secret = secret_number(); let in_range = (1..=100).contains(&secret); println!("Đã chọn số bí mật. Nó có nằm trong khoảng 1 đến 100 không? {in_range}");}Kết quả:
Đã chọn số bí mật. Nó có nằm trong khoảng 1 đến 100 không? truenanos % 100 luôn từ 0 đến 99, nên cộng 1 cho ta từ 1 đến 100. Ta chỉ in ra việc nó có nằm trong khoảng hay không, nên kết quả lần nào cũng giống nhau. Hãy in chính secret nếu bạn muốn nhìn trộm.
Hãy thành thật với bản thân về bản chất của nó: đây là “đủ khó đoán cho một trò chơi”, không phải ngẫu nhiên thật. Đừng bao giờ dùng mẹo như thế này cho mật khẩu, token bảo mật, hay bất cứ thứ gì mà ai đó có thể cố đoán giá trị. Với những thứ đó, hãy dùng một crate đàng hoàng.
Bước 7: Trò chơi hoàn chỉnh
Phần tiêu đề “Bước 7: Trò chơi hoàn chỉnh”Đây là mọi thứ ghép lại. Đây là phiên bản để đặt vào src/main.rs và chơi bằng cargo run.
Một điều mới: ta dùng match trên kết quả của read_line thay vì gọi .expect(). read_line trả về Ok(0) khi không còn dữ liệu nhập, ví dụ khi người chơi nhấn Ctrl+D (Ctrl+Z rồi Enter trên Windows), hoặc khi dữ liệu được đưa vào từ một file qua pipe. Thiếu phép kiểm tra đó, vòng lặp sẽ quay mãi trên dữ liệu rỗng.
use std::cmp::Ordering;use std::io;use std::time::{SystemTime, UNIX_EPOCH};
fn secret_number() -> u32 { let nanos = SystemTime::now() .duration_since(UNIX_EPOCH) .expect("đồng hồ đang đặt trước năm 1970") .subsec_nanos(); nanos % 100 + 1}
fn main() { println!("Đoán số đi! (từ 1 đến 100)");
let secret = secret_number(); let mut attempts = 0;
loop { println!("Hãy nhập số bạn đoán:");
let mut guess = String::new(); match io::stdin().read_line(&mut guess) { Ok(0) => { // Hết dữ liệu nhập: không còn gì để đọc. println!("Hết dữ liệu nhập. Số bí mật là {secret}. Tạm biệt!"); break; } Ok(_) => {} Err(e) => { println!("Không đọc được dữ liệu bạn nhập: {e}"); break; } }
let guess: u32 = match guess.trim().parse() { Ok(num) => num, Err(_) => { println!("Đó không phải là số nguyên. Thử lại nhé!"); continue; } };
attempts += 1;
match guess.cmp(&secret) { Ordering::Less => println!("Nhỏ quá!"), Ordering::Greater => println!("Lớn quá!"), Ordering::Equal => { println!("Bạn đoán đúng sau {attempts} lần!"); break; } } }}Muốn thử trong trình duyệt thay vì trên máy, hãy dùng phiên bản ở Bước 5, phiên bản chơi theo một danh sách số đoán cố định. 🦀
Lỗi thường gặp
Phần tiêu đề “Lỗi thường gặp”Quên mut cho chuỗi. read_line cần thay đổi chuỗi, nên chuỗi phải khả biến (mutable):
use std::io;
fn main() { let guess = String::new(); io::stdin().read_line(&mut guess).expect("Failed to read line");}error[E0596]: cannot borrow `guess` as mutable, as it is not declared as mutable --> src/main.rs:5:27 |5 | io::stdin().read_line(&mut guess).expect("Failed to read line"); | ^^^^^^^^^^ cannot borrow as mutable |help: consider changing this to be mutable |4 | let mut guess = String::new(); | +++Trình biên dịch báo rằng không thể mượn guess dưới dạng khả biến vì nó không được khai báo là khả biến. Cách sửa đúng như trình biên dịch gợi ý: let mut guess = String::new();.
So sánh chữ với số. Nếu bạn quên parse, guess vẫn là một String, và bạn không thể so sánh một String với một u32:
use std::cmp::Ordering;
fn main() { let secret: u32 = 42; let guess = String::from("50");
match guess.cmp(&secret) { Ordering::Less => println!("Nhỏ quá!"), Ordering::Greater => println!("Lớn quá!"), Ordering::Equal => println!("Bạn thắng!"), }}error[E0308]: mismatched types --> src/main.rs:7:21 |7 | match guess.cmp(&secret) { | --- ^^^^^^^ expected `&String`, found `&u32` | | | arguments to this method are incorrectLỗi này nghĩa là kiểu không khớp: phương thức chờ một &String nhưng lại nhận một &u32. Rust không bao giờ tự chuyển đổi giữa chữ và số giúp bạn. Hãy parse trước, rồi mới so sánh.
Quên .trim(). Đoạn này biên dịch được, nhưng số đoán nào cũng bị từ chối, vì ký tự xuống dòng không phải là chữ số:
fn main() { let typed = "42\n"; println!("{:?}", typed.parse::<u32>()); println!("{:?}", typed.trim().parse::<u32>());}Kết quả:
Err(ParseIntError { kind: InvalidDigit })Ok(42)Ý tưởng để mở rộng
Phần tiêu đề “Ý tưởng để mở rộng”Khi trò chơi đã chạy, hãy tự thử những điều này. Mỗi cái đều nhỏ:
- Kiểm tra khoảng giá trị. Nếu số đoán là 0 hoặc lớn hơn 100, hãy nói “Hãy chọn một số từ 1 đến 100” và không tính lần đó.
- Giới hạn số lần đoán. Cho người chơi 7 lần. Nếu hết lượt, hãy hiện số bí mật và kết thúc trò chơi.
- Chơi lại. Khi người chơi thắng, hỏi “Chơi lại không? (c/k)” và bắt đầu một ván mới với số bí mật mới.
- Lưu điểm tốt nhất. Ghi nhớ số lần đoán ít nhất qua các ván và in ra ở cuối.
- Gợi ý “nóng lạnh”. Nói “gần lắm rồi!” khi số đoán cách số bí mật không quá 3.
guess.abs_diff(secret)cho bạn khoảng cách giữa hai số. - Dùng crate
randthật. Chạycargo add randvà làm theo tài liệu của nó trên docs.rs (tiếng Anh) để thay thếsecret_number(). - Tách logic ra thành hàm. Viết
fn check_guess(guess: u32, secret: u32) -> Orderingvà thêm bài test cho nó.
Tóm tắt
Phần tiêu đề “Tóm tắt”io::stdin().read_line(&mut s)đọc một dòng, bao gồm cả ký tự xuống dòng, và trả về mộtResult.Ok(0)nghĩa là không còn dữ liệu nhập..trim().parse()biến chữ thành số. Nó trả về mộtResult, nên hãymatchtrên nó để xử lý dữ liệu nhập sai một cách tử tế.a.cmp(&b)trả vềOrdering::Less,GreaterhoặcEqual, vàmatchbảo đảm bạn xử lý cả ba.loopcùngcontinuevàbreakgiữ cho trò chơi tiếp diễn đến khi người chơi thắng.- Mẹo dùng đồng hồ ổn cho một trò chơi, nhưng project thật dùng crate
rand.
Kiểm tra nhanh
Phần tiêu đề “Kiểm tra nhanh”Kiểm tra nhanh
1. Người chơi gõ 42 rồi nhấn Enter. Chuỗi nhận từ read_line chứa gì?
2. Vì sao trò chơi dùng match trên parse() thay vì .expect()?
3. read_line trả về Ok(0) nghĩa là gì?
4. guess.cmp(&secret) có thể trả về những giá trị nào?
Điểm: 0 / 4
Đọc thêm
Phần tiêu đề “Đọc thêm”- Programming a Guessing Game — The Rust Book (tiếng Anh): phiên bản dự án này của chính cuốn Rust Book. Nó dùng crate
randvà giải thích chi tiết từng dòng. Stdin::read_line— std docs (tiếng Anh): vì sao chữ được nối thêm vàoStringcủa bạn, vì sao nó giữ ký tự xuống dòng, và vì sao nó trả vềOk(0)khi hết dữ liệu nhập.str::parse— std docs (tiếng Anh):parsechọn kiểu đích từ chú thích kiểu của bạn như thế nào, và lỗi trông ra sao khi chữ không phải là số.std::cmp::Ordering— std docs (tiếng Anh): enumLess/Equal/Greatermàcmptrả về, cùng các hàm tiện ích nhưis_ltvàreverse.- Crate
rand— docs.rs (tiếng Anh): cách thật sự để chọn số ngẫu nhiên, khi bạn sẵn sàng thêm crate đầu tiên.