[Rust Book] 2. Lập trình game đoán số

Lập trình game đoán số

Chúng ta hãy cùng làm quen với Rust kỹ hơn thông qua một project! Chương này sẽ giới thiệu một vài khái niệm phổ biến trong Rust bằng cách chỉ cho bạn cách sử dụng chúng trong những chương trình thực tế. Bạn sẽ học về let, match, các phương thức (method), các hàm liên kết (associated function), viêc sử dụng các crate bên ngoài và nhiều thứ khác nữa! Những chương tiếp theo sẽ đi sâu chi tiết hơn. Trong chương này, bạn sẽ chỉ thực hành những điều cơ bản.

Chúng ta sẽ làm một chương trình cơ bản cho người mới: một trò chơi đoán số. Cách nó hoạt động sẽ là: chương trình sẽ sinh ra một số nguyên ngẫu nhiên trong khoảng từ 1 đến 100. Người chơi đoán một con số và nhập vào. Sau khi được nhập, chương trình sẽ chỉ ra số đã đoán là quá thấp hay quá cao. Nếu đoán chính xác, trò chơi sẽ in ra một tin nhắn chúc mừng và thoát.

Thiết lập một project mới

Để thiết lập một project mới, đi đến thư mục projects mà bạn đã tạo trong Chương 1 và tạo một project bằng Cargo, như thế này:

$ cargo new guessing_game
$ cd guessing_game

Câu lệnh đầu tiên, cargo new, nhận tên của project (guessing_game) như đối số. Câu lệnh thứ hai đi tới thư mục của project mới.

Hãy cùng nhìn qua file Cargo.toml vừa được sinh ra:

[package]
name = "guessing_game"
version = "0.1.0"
edition = "2021"

Như trong Chương 1 bạn đã thấy, cargo new tạo một chương trình “Hello, world!” cho bạn. Kiểm tra file src/main.rs:

fn main() {
  println!("Hello, world!");
}

Giờ hãy biên dịch và chạy chương trình “Hello, world!” bằng lệnh cargo run:

$ cargo run
   Compiling guessing_game v0.1.0 (file:///projects/guessing_game)
    Finished dev [unoptimized + debuginfo] target(s) in 1.50s
     Running `target/debug/guessing_game`
Hello, world!

Lệnh run được dùng khi bạn cần chạy và kiểm tra mỗi công đoạn một cách nhanh chóng trước khi chuyển sang bước tiếp theo cho một project giống như trò chơi này.

Mở file src/main.rs. Bạn sẽ đặt toàn bộ code trong file này.

Nhập số đoán

Phần đầu tiên của một chương trình trò chơi đoán số là yêu cầu người dùng nhập input, đọc nó, và kiểm tra input có đúng định dạng không. Trước tiên, chúng ta sẽ cho phép người chơi nhập một số. Nhập code sau vào src/main.rs.

use std::io;

fn main() {
    println!("Guess the number!");

    println!("Please input your guess.");

    let mut guess = String::new();

    io::stdin()
        .read_line(&mut guess)
        .expect("Failed to read line");

    println!("You guessed: {guess}");
}

Code này chứa rất nhiều thông tin, chúng ta hãy đi từng dòng một. Để nhận input của người dùng rồi in kết quả ra như output, chúng ta cần đem thư viện io input/output vào trong scope. Thư viện io đến từ standard library, được biến đến như là std:

use std::io;

Mặc định, Rust có một vài công cụ được định nghĩa trong standard library và được mang vào scope của mọi chương trình. Tập những công cụ này được gọi là prelude, và bạn có thể xem chúng trong tài liệu của standard library.

Nếu một kiểu mà bạn muốn sử dụng không nằm trong prelude, bạn phải đem kiểu đó vào trong scope một cách rõ ràng với statement use. Việc sử dụng thư viện std::io cung cấp cho bạn một số tính năng hữu ích, bao gồm khả năng nhận user input.

Như bạn đã thấy trong Chương 1, hàm main là điểm bắt đầu (entry point) chương trình:

fn main() {

Cú pháp fn khai báo một hàm mới, dấu ngoặc đơn, (), biểu thị rằng ko có tham số nào, và dấu mở ngoặc nhọn, {, bắt đầu phần thân hàm.

Như bạn đã học trong Chương 1, println! là một macro in một chuỗi ra màn hình:

    println!("Guess the number!");
    
    println!("Please input your guess.");

Code này in ra một lời nhắc cho biết trò chơi là gì và yêu cầu input từ người dùng.

Lưu giá trị với biến

Tiếp theo chúng ta sẽ tạo một biến để lưu trữ input người dùng, như thế này:

    let mut guess = String::new();

Chương trình đang dần trở nên thú vị hơn! Có rất nhiều thứ xảy ra trên dòng này. Chúng ta sử dụng statement let để tạo biến. Đây là một ví dụ khác:

let apples = 5;

Dòng này tạo một biến mới tên là apple và gán cho nó giá trị 5. Trong Rust, mặc định, biến là immutable (bất biến). Chúng ta sẽ thảo luận về khái niệm này trong phần “Biến và Tính khả biến” ở Chương 3. Để tạo một biến mutable (khả biến), chúng ta thêm mut vào trước tên biến:

let apple = 5; // immutable
let mut banana = 5; // mutable

Lưu ý: Ký hiệu // bắt đầu một comment kéo dài tới kết thúc dòng. Rust bỏ qua mọi thứ trong comment. Chúng ta sẽ bàn chi tiết hơn về comment ở Chương 3.

Trở lại chương trình trò chơi đoán số. Bây giờ bạn đã biết let mut guess sẽ giới thiệu một biến mutable tên là guess. Dấu bằng (=) bảo Rust rằng chúng ta muốn gán thứ gì đó vào biến. Ở phía bên phải dấu bằng là giá trị mà guess được gán, kết quả của việc gọi String::new, một hàm trả về một instance mới của String. String là một kiểu chuỗi ký tự cung cấp bởi standard library có thể nối dài, mã hóa UTF-8.

Ký hiệu :: trong dòng ::new biểu thị rằng new là một hàm liên kết (associated function) của kiểu String. Associated function là một hàm được thực thi trên một kiểu, trong trường hợp này là String. Hàm new này tạo một chuỗi rỗng mới. Bạn sẽ tìm thấy một hàm new ở nhiều kiểu, bởi vì nó là một cái tên khá phổ biến cho một hàm để tạo một giá trị mới cho một kiểu nào đó.

Nói một cách đầy đủ, dòng let mut guess = String::new(); tạo một biến mutable là một instance rỗng mới của String.

Nhận input của người dùng

Nhớ lại rằng chúng ta đã include chức năng input/output từ standard library với use std::io; ở dòng đầu tiên của chương trình. Giờ chúng ta sẽ gọi hàm stdin từ module io, thứ sẽ cho phép chúng ta xử lý input của người dùng:

    io::stdin()
        .read_line(&mut guess)

Nếu chúng ta chưa import thư viện io với use std::io ở phần đầu của chương trình, chúng ta vẫn có thể dùng hàm bằng cách viết lời gọi hàm này như sau std::io::stdin. Hàm stdin trả về một instance của std::io::Stdin, một kiểu đại diện cho công cụ xử lý standard input cho terminal.

Tiếp theo, dòng .read_line(&mut guess) gọi phương thức read_line để nhận input từ người dùng. Chúng ta cũng truyền &mut guess như đối số tới read_line để bảo nó lưu input người dùng ở string nào. Công việc của read_line là nhận bất cứ thứ gì người dùng gõ trong standard input và nối chúng vào một string (không ghi đè nội dung của nó), do đó chúng ta có thể truyền string đó như một đối số. Đối số string cần là mutable để phương thức có thể thay đổi nội dung của string.

Ký tự & biểu thị rằng đối số này là một tham chiếu (reference), thứ cho phép nhiều phần của code có thể truy cập vào cùng một phần của dữ liệu mà không cần sao chép dữ liệu đó vào bộ nhớ nhiều lần. Tham chiếu là một tính năng phức tạp và một trong những ưu điểm chính của Rust là độ an toàn và dễ dàng trong việc sử dụng tham chiếu. Bạn không cần biết quá chi tiết để hoàn thành chương trình này. Bây giờ, tất cả những gì bạn cần biết là giống như biến, tham chiếu mặc định là immutable. Do đó, bạn cần viết &mut guess thay vì &guess để làm nó mutable. (Chương 4 sẽ giải thích tỉ mỉ hơn về tham chiếu.)

Xử lý lỗi thất bại tiềm tàng với kiểu Result

Chúng ta vẫn làm việc với dòng code này. Mặc dù giờ chúng ta sẽ bàn về dòng thứ ba của nó, nó vẫn là một phần của dòng code. Phần tiếp theo là phương thức này:

        .expect("Failed to read line");

Chúng ta có thể viết đoạn code này như sau:

io::stdin().read_line(&mut guess).expect("Failed to read line");

Tuy nhiên, một dòng code dài thì sẽ khó đọc hơn, nên tốt nhất là chia nhỏ nó ra. Thường thì chúng ta có thể sử dụng xuống dòng và các ký tự trống để cắt nhỏ các dòng dài khi gọi phương thức với cú pháp .method_name(). Bây giờ, chúng ta sẽ thảo luận về việc dòng code này làm gì.

Như đã đề cập từ trước, read_line đặt những gì người dùng gõ vào trong chuỗi mà chúng ta truyền vào, nhưng nó cũng trả về một giá trị Result. Result là những enumeration, thường được gọi tắt là enum, nó có thể là một trong các giá trị cố định được biết đến là các variant (biến thể).

Chương 6 sẽ nói chi tiết hơn về enum. Mục đích của những kiểu Result này là để encode thông tin xử lý lỗi (error-handling).

Những variant của ResultOk hoặc Err. Biến thể Ok biểu thị operation đã thành công, và bên trong Ok là giá trị được tạo thành công. Biến thể Err có nghĩa là operation đã thất bại và Err chứa thông tin làm cách nào hay tại sao operation lại thất bại.

Những giá trị của kiểu Result, cũng như những giá trị của bất cứ kiểu nào khác, có những phương thức định nghĩa riêng cho chúng. Instance của io::Result có một phương thức expect mà bạn có thể gọi. Nếu instance này của io::Result là một giá trị Err, expect sẽ khiến chương trình dừng lại và hiển thị một tin nhắn mà bạn đã truyền như một đối số vào expect. Nếu phương thức read_line trả về một Err, nó có thể là kết quả của một lỗi đến từ hệ điều hành bên dưới. Nếu instance của io::Result là một giá trị Ok, expect sẽ lấy giá trị trả về mà Ok đang giữ và trả ra giá trị đó cho bạn để sử dụng. Trong trường hợp này, giá trị là số byte mà người dùng đã nhập vào standard input.

Nếu bạn không gọi expect, chương trình sẽ vẫn biên dịch, nhưng bạn sẽ nhận được cảnh báo:

$ cargo build
   Compiling guessing_game v0.1.0 (file:///projects/guessing_game)
warning: unused `Result` that must be used
  --> src/main.rs:10:5
   |
10 |     io::stdin().read_line(&mut guess);
   |     ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
   |
   = note: `#[warn(unused_must_use)]` on by default
   = note: this `Result` may be an `Err` variant, which should be handled

warning: `guessing_game` (bin "guessing_game") generated 1 warning
    Finished dev [unoptimized + debuginfo] target(s) in 0.59s

Rust cảnh báo rằng bạn không sử dụng giá trị Result trả về từ read_line, chỉ ra rằng chương trình không xử lý những lỗi có thể xảy ra.

Cách đúng đắn để tránh cảnh báo này là viết code xử lý lỗi, nhưng trong trường hợp của chúng ta, chúng ta chỉ muốn dừng chương trình này khi có vấn đề xảy ra, nên chúng ta có thể sử dụng expect. Bạn sẽ học về điều này ở Chương 9.

In giá trị với println! Placeholders

Ngoài dấu đóng ngoặc nhọn ra, chỉ còn một dòng để thảo luận về code của nó:

    println!("You guessed: {guess}");

Dòng này in ra chuỗi mà chứa input người dùng. Cặp dấu ngoặc nhọn, {}, là một placeholder: hãy nghĩ {} như những cái càng cua nhỏ giữ trong nó một giá trị. Bạn có thể in ra nhiều hơn một giá trị bằng cách sử dụng những dấu ngoặc nhọn này: cặp dấu ngoặc nhọn đầu tiên nắm giữ giá trị đầu tiên được liệt kê sau chuỗi định dạng, cặp thứ hai giữ giá trị thứ hai và tiếp tục như thế. In nhiều giá trị trong một lần gọi println! sẽ trông như thế này:

let x = 5;
let y = 10;

println!("x = {} and y = {}", x, y);

Code trên sẽ in ra x = 5 and y = 10.

Kiểm tra phần đầu tiên

Chúng ta hãy cùng kiểm tra phần đầu tiên của trò chơi đoán số. Chạy chương trình với cargo run:

$ cargo run
   Compiling guessing_game v0.1.0 (file:///projects/guessing_game)
    Finished dev [unoptimized + debuginfo] target(s) in 6.44s
     Running `target/debug/guessing_game`
Guess the number!
Please input your guess.
6
You guessed: 6

Đến thời điểm này, phần đầu tiên của trò chơi đã xong; chúng ta đang nhận input từ bàn phím và in nó ra.

Tạo một số bí mật

Tiếp theo, chúng ta sẽ tạo một số bí mật cho người dùng đoán. Số bí mật sẽ thay đổi theo mỗi lần chơi để trò chơi vui hơn. Chúng ta hãy dùng một số ngẫu nhiên từ 1 đến 100 để trò chơi không khó quá. Rust vẫn chưa bao gồm chức năng sinh số ngẫu nhiên trong standard library. Tuy nhiên Rust team cung cấp một crate rand với chức năng này.

Sử dụng một crate để có nhiều chức năng hơn

Nhớ rằng một crate là một tập hợp của những file mã nguồn Rust. Project mà chúng ta đang xây dựng là một binary crate có thể thực thi được. Crate rand là một library crate, nó chứa code để dùng trong các chương trình khác, và không thể tự thực thi bởi chính nó.

Việc sử dụng các crate ngoại vi của Cargo thực sự rất hữu dụng. Trước khi chúng ta có thể viết code sử dụng rand, chúng ta cần chỉnh sửa file Cargo.toml để bao gồm crate rand như một dependency. Mở file đó lên và thêm dòng sau vào bên dưới phần tiêu đề [dependencies] mà Cargo đã tạo cho bạn. Hãy chắc chắn rằng chỉ định rand chính xác như những gì chúng ta có ở đây, nếu không ví dụ trong hướng dẫn này có thể không hoạt động đúng:

rand = "0.8.3"

Trong file Cargo.toml, mọi thứ theo sau một header là một phần của section đó kéo dài liên tục cho đến khi section khác bắt đầu. Section [dependencies] là nơi bạn nói với Cargo rằng crate ngoại vi nào mà project của bạn phụ thuộc vào và bạn cần phiên bản nào của những crate đó. Trong trường hợp này, cụ thể chúng ta sẽ dùng crate rand với phiên bản 0.8.3. Cargo có thể hiểu Semantic Versioning (đôi khi gọi là SemVer), thứ này là tiêu chuẩn cho việc đặt phiên bản. Số 0.8.3 thực chất là viết tắt cho ^0.8.3, có nghĩa là bất cứ phiên bản nào cao hơn hoặc bằng 0.8.3 nhưng dưới 0.9.0. Cargo sẽ xem xét những phiên bản này để có được API công khai tương thích với version 0.8.3, và thông số này đảm bảo bạn sẽ nhận được phiên bản mới nhất mà vẫn có thể biên dịch code của chương này. Bất kỳ phiên bản nào từ 0.9.0 trở lên không thể đảm bảo có cùng API như những gì được sử dụng trong những ví dụ sắp tới.

Không cần thay đổi gì ở code, hãy build project như sau.

$ cargo build
    Updating crates.io index
  Downloaded rand v0.8.3
  Downloaded libc v0.2.86
  Downloaded getrandom v0.2.2
  Downloaded cfg-if v1.0.0
  Downloaded ppv-lite86 v0.2.10
  Downloaded rand_chacha v0.3.0
  Downloaded rand_core v0.6.2
   Compiling rand_core v0.6.2
   Compiling libc v0.2.86
   Compiling getrandom v0.2.2
   Compiling cfg-if v1.0.0
   Compiling ppv-lite86 v0.2.10
   Compiling rand_chacha v0.3.0
   Compiling rand v0.8.3
   Compiling guessing_game v0.1.0 (file:///projects/guessing_game)
    Finished dev [unoptimized + debuginfo] target(s) in 2.53s

Bạn có lẽ sẽ thấy những số phiên bản khác (nhưng chúng sẽ tương thích với code, nhờ SemVer!), những dòng khác (tùy thuộc vào hệ điều hành) và các dòng có thể có thứ tự khác.

Khi chúng ta include một dependency ngoại vi, Cargo sẽ kéo phiên bản mới nhất của mọi thứ từ registry, một bản sao của dữ liệu từ Crates.io. Crates.io là nơi mọi người trong hệ sinh thái Rust đăng lên những project Rust của họ cho người khác sử dụng.

Sau khi cập nhật registry, Cargo kiểm tra section [dependencies] và tải bất kỳ crate nào mà chưa được tải. Ở đây, mặc dù chúng ta chỉ liệt kê rand là một dependency, Cargo cũng sẽ lấy cả những crate khác mà rand phụ thuộc vào. Sau khi tải xong crate, Rust biên dịch chúng rồi biên dịch project với những dependency khả dụng.

Nếu bạn ngay lập tức chạy lại cargo build mà không tạo ra bất cứ thay đổi nào, bạn sẽ không nhận được output nào ngoài dòng chữ Finished. Cargo biết rằng nó đã tải và biên dịch các dependency rồi và bạn không hề thay đổi gì về chúng trong file Cargo.toml. Cargo cũng biết rằng bạn không thay đổi gì trong code, nên nó cũng không biên dịch lại. Không có gì để làm, nó chỉ đơn giản là thoát chương trình.

Nếu bạn mở file src/main.rs lên, làm một thay đổi không đáng kể rồi lưu và build lại, bạn sẽ chỉ nhìn thấy hai dòng output sau:

$ cargo build
   Compiling guessing_game v0.1.0 (file:///projects/guessing_game)
    Finished dev [unoptimized + debuginfo] target(s) in 2.53 secs

Những dòng này biểu thị rằng Cargo chỉ cập nhật bản build với thay đổi nhỏ của bạn ở file src/main.rs. Các dependency của bạn không thay đổi, nên Cargo biết nó có thể tái sử dụng những gì mà nó đã tải và biên dịch cho chúng.

Đảm bảo các bản build tái tạo được (reproducible) với file Cargo.lock

Cargo có một cơ chế đảm bảo rằng mọi lần rebuild đều giống như nhau đối với bạn hay người khác build code của bạn: Cargo sẽ chỉ sử dụng phiên bản của các dependency mà bạn đã chỉ định cụ thể cho đến khi bạn đặt khác đi. Ví dụ, chuyện gì sẽ xảy ra nếu tuần tới crate rand ra phiên bản 0.8.4 và chứa một bản vá lỗi quan trọng nhưng cũng chứa một regression bug sẽ phá hỏng code của bạn? Để xử lý điều này, Rust tạo một file Cargo.lock khi bạn chạy cargo build lần đầu, nên bây giờ chúng ta có file này trong thư mục guessing_game.

Khi bạn build project lần đầu tiên, Cargo sẽ tìm ra tất cả các phiên bản của các dependency phù hợp với yêu cầu rồi ghi chúng vào file Cargo.lock. Khi bạn build lại project, Cargo sẽ thấy file Cargo.lock đã tồn tại và sử dụng những phiên bản được khai báo ở đó thay vì làm lại tất cả hay xác định lại các phiên bản. Điều này cho phép bạn tự động có một bản build reproducible. Nói cách khác, nhờ vào file Cargo.lock, project của bạn sẽ tiếp tục dùng 0.8.3 cho đến khi bạn chủ động nâng cấp.

Cập nhật phiên bản mới cho crate

Khi bạn muốn cập nhật một crate, Cargo cung cấp lệnh update để bỏ qua file Cargo.lock và tìm những phiên bản mới nhất phù hợp yêu cầu trong Cargo.toml. Cargo sẽ viết những phiên bản này vào file Cargo.lock. Mặt khác, mặc định Cargo sẽ chỉ tìm những phiên bản lớn hơn 0.8.3 và nhỏ hơn 0.9.0. Nếu crate rand phát hành hai phiên bản mới, 0.8.40.9.0, bạn sẽ nhìn thấy thông báo sau khi chạy cargo update:

$ cargo update
    Updating crates.io index
    Updating rand v0.8.3 -> v0.8.4

Bạn đã thấy rằng in file Cargo.lock ghi lại phiên bản của crate rand đang được sử dụng là 0.8.4. Nếu bạn muốn dùng rand phiên bản 0.9.0 hoặc bất cứ phiên bản nào trong loạt 0.9.x, bạn phải cập nhật file Cargo.toml như sau:

[dependencies]
rand = "0.9.0"

Lần tiếp theo bạn chạy cargo build, Cargo sẽ cập nhật registry của những crate sẵn có và đánh giá lại những yêu cầu về rand theo phiên bản mới mà bạn đã đặt.

Có rất nhiều điều nữa để nói về Cargohệ sinh thái của nó, chúng ta sẽ bàn về chúng trong Chương 14, nhưng cho đến giờ, những thứ trên là tất cả những gì bạn cần biết. Cargo khiến việc tái sử dụng thư viện dễ dàng hơn, nên các Rustacean có thể viết những project nhỏ được kết hợp lại từ những package đã có.

Sinh một số ngẫu nhiên

Vậy là bạn đã thêm crate rand vào Cargo.toml, giờ chúng ta có thể bắt đầu sử dụng rand. Bước tiếp theo là chỉnh sửa src/main.rs, như sau.

use std::io;
use rand::Rng;

fn main() {
  println!("Guess the number!");

  let secret_number = rand::thread_rng().gen_range(1..=100);

  println!("The secret number is: {secret_number}");

  println!("Please input your guess.");

  let mut guess = String::new();

  io::stdin()
          .read_line(&mut guess)
          .expect("Failed to read line");

  println!("You guessed: {guess}");
}

Đầu tiên, chúng ta thêm một dòng use: use rand::Rng. Trait Rng định nghĩa những phương thức mà bộ sinh số ngẫu nhiên thực hiện, và trail này phải nằm trong scope để chúng ta có thể sử dụng những phương thức này. Chương 10 sẽ giải thích chi tiết về trait.

Tiếp theo, chúng ta thêm hai dòng ở giữa. Hàm rand::thread_rng sẽ cho chúng ta bộ sinh số ngẫu nhiên cụ thể mà chúng ta sẽ dùng: nằm trong thread thực thi hiện tại và được sinh (seeded) bởi hệ điều hành. Sau đó chúng ta gọi phương thức gen_range trên bộ sinh số ngẫu nhiên. Phương thức này được định nghĩa bởi trail Rng mà chúng ta đã mang vào với lệnh use rand::Rng. Phương thức gen_range nhận vào một range expression như một đối số và sinh ra một số ngẫu nhiên giữa chúng. Loại range expression mà chúng ta sử dụng ở đây có dạng start..end. Nó bao gồm giá trị biên dưới nhưng lại không bao gồm giá trị biên trên, nên chúng ta cần đặt 1..101 để lấy một số nằm giữa 1 và 100. Cách khác tương đương, chúng ta có thể truyền range 1..=100.

Lưu ý: Bạn sẽ không biết cần sử dụng trail nào và gọi phương thức hay hàm nào từ một crate. Hướng dẫn sử dụng một crate nằm trong tài liệu của mỗi crate. Một tính năng rất hay nữa của Cargo là bạn có thể chạy cargo doc --open, nó sẽ build tài liệu được cung cấp bởi tất cả các dependency của bạn và mở nó trên trình duyệt. Ví dụ nếu bạn có hứng thú với những hàm khác của rand, bạn có thể chạy cargo doc --open và nhấp vào rand ở thanh bên trái.

Dòng thứ hai chúng ta thêm vào giữa đoạn code in ra số bí mật. Nó chỉ để kiểm tra chương trình khi chúng ta đang phát triển, chúng ta sẽ xóa nó đi ở bản cuối cùng. Sẽ không còn là trò chơi nếu câu trả lời lại được in ra ngay từ đầu!

Thử chạy chương trình vài lần và xem kết quả:

$ cargo run
   Compiling guessing_game v0.1.0 (file:///projects/guessing_game)
    Finished dev [unoptimized + debuginfo] target(s) in 2.53s
     Running `target/debug/guessing_game`
Guess the number!
The secret number is: 7
Please input your guess.
4
You guessed: 4

$ cargo run
    Finished dev [unoptimized + debuginfo] target(s) in 0.02s
     Running `target/debug/guessing_game`
Guess the number!
The secret number is: 83
Please input your guess.
5
You guessed: 5

Những số ngẫu nhiên nên được sinh ra và trong khoảng từ 1 đến 100.

So sánh số đã đoán với số bí mật

Giờ chúng ta đã có input của người dùng và một số ngẫu nhiên, chúng ta có thể tiến hành so sánh chúng. Bước này được hiển thị như ở code bên dưới. Lưu ý rằng code này vẫn chưa biên dịch được, điều này sẽ được giải thích sau.

use rand::Rng;
use std::cmp::Ordering;
use std::io;

fn main() {
  // --snip--

  println!("You guessed: {guess}");

  match guess.cmp(&secret_number) {
    Ordering::Less => println!("Too small!"),
    Ordering::Greater => println!("Too big!"),
    Ordering::Equal => println!("You win!"),
  }
}

Điều mới mẻ đầu tiên ở đây là một lệnh use khác, gọi một kiểu vào scope từ standard library là std::cmp::Ordering. Giống như Result, Ordering là một enum, nhưng những biến thể của OrderingLess, Greater, và Equal. Chúng là ba kết quả có thể trả về khi so sánh hai giá trị.

Sau đó chúng ta thêm năm dòng mới vào phía dưới đoạn sử dụng kiểu Ordering. Phương thức cmp so sánh hai giá trị và có thể được gọi với bất kỳ thứ gì so sánh được. Nó nhận một tham chiếu của thứ mà bạn muốn so sánh: ở đây nó đang so sánh guess so với secret_number. Sau đó nó trả về một biến thể của enum Ordering mà chúng ta đã mang vào với lệnh use. Chúng ta sử dụng một biểu thức match để quyết định sẽ làm gì tiếp dựa vào biến thể của Ordering được trả về từ lời gọi cmp với những giá trị trong guesssecret_number.

Một biểu thức match được tạo bởi các arm. Một arm bao gồm một pattern và code được chạy nếu giá trị được cho ở đầu của biểu thức match thỏa mãn pattern của arm. Rust lấy giá trị được đưa cho match và đi qua từng pattern của arm. Cấu trúc match và pattern là tính năng mạnh mẽ trong Rust cho phép bạn thể hiện một loạt các tình huống có thể gặp phải và đảm bảo rằng bạn xử lý tất cả chúng. Những tính năng này sẽ được đề cập chi tiết trong Chương 6 và Chương 18.

Hãy thử một ví dụ và xem chuyện gì sẽ xảy ra với biểu thức match. Giả sử người dùng đoán 50 và số bí mật được tạo ra ngẫu nhiên là 38. Khi code so sánh 50 so với 38, phương thức cmp sẽ trả về Ordering::Greater, bởi vì 50 lớn hơn 38. Biểu thức match nhận giá trị Ordering::Greater và bắt đầu kiểm tra từng pattern của arm. Nó nhìn vào pattern của arm đầu tiên, Ordering::Less, và thấy rằng giá trị Ordering::Greater không khớp với Ordering::Less, nên nó bỏ qua phần code ở arm đó và sang arm tiếp theo. Pattern của arm tiếp theo, Ordering::Greater, khớp với Ordering::Greater! Code tương ứng trong arm đó sẽ được thực thi và in Too big! ra màn hình. Biểu thức match kết thúc bởi vì nó không cần nhìn vào arm cuối cùng nữa.

Tuy nhiên, code không biên dịch được:

$ cargo build
   Compiling libc v0.2.86
   Compiling getrandom v0.2.2
   Compiling cfg-if v1.0.0
   Compiling ppv-lite86 v0.2.10
   Compiling rand_core v0.6.2
   Compiling rand_chacha v0.3.0
   Compiling rand v0.8.3
   Compiling guessing_game v0.1.0 (file:///projects/guessing_game)
error[E0308]: mismatched types
  --> src/main.rs:22:21
   |
22 |     match guess.cmp(&secret_number) {
   |                     ^^^^^^^^^^^^^^ expected struct `String`, found integer
   |
   = note: expected reference `&String`
              found reference `&{integer}`

error[E0283]: type annotations needed for `{integer}`
   --> src/main.rs:8:44
    |
8   |     let secret_number = rand::thread_rng().gen_range(1..=100);
    |         -------------                      ^^^^^^^^^ cannot infer type for type `{integer}`
    |         |
    |         consider giving `secret_number` a type
    |
    = note: multiple `impl`s satisfying `{integer}: SampleUniform` found in the `rand` crate:
            - impl SampleUniform for i128;
            - impl SampleUniform for i16;
            - impl SampleUniform for i32;
            - impl SampleUniform for i64;
            and 8 more
note: required by a bound in `gen_range`
   --> /Users/carolnichols/.cargo/registry/src/github.com-1ecc6299db9ec823/rand-0.8.3/src/rng.rs:129:12
    |
129 |         T: SampleUniform,
    |            ^^^^^^^^^^^^^ required by this bound in `gen_range`
help: consider specifying the type arguments in the function call
    |
8   |     let secret_number = rand::thread_rng().gen_range::<T, R>(1..=100);
    |                                                     ++++++++

Some errors have detailed explanations: E0283, E0308.
For more information about an error, try `rustc --explain E0283`.
error: could not compile `guessing_game` due to 2 previous errors

Lỗi chỉ ra rằng có những kiểu không tương thích (mismatched type). Rust có một hệ thống về kiểu tĩnh và rất mạnh. Tuy nhiên, nó cũng có suy luận kiểu. Khi chúng ta viết let mut guess = String::new(), Rust có thể suy rằng guess là một String và không bắt chúng ta khai báo kiểu. Biến secret_number, mặt khác, là một kiểu số. Một vài kiểu số trong Rust có thể có một giá trị giữa 1 và 100: i32, một số 32-bit; u32, một số 32-bit không dấu; i64, một số 64-bit; và còn những số khác. Rust mặc định gán i32 là kiểu của secret_number trừ khi bạn thêm thông tin về kiểu ở đâu đó khiến Rust suy ra một kiểu số khác. lý do cho lỗi trên là Rust không thể so sánh một kiểu chuỗi và một kiểu số.

Cuối cùng, chúng ta muốn chuyển đổi String mà chương trình đọc từ input sang một kiểu số để chúng ta có thể so sánh giá trị số của nó với số bí mật. Chúng ta có thể làm điều đó bằng cách thêm một dòng khác vào thân hàm main:

    // --snip--

    let mut guess = String::new();
    
    io::stdin()
        .read_line(&mut guess)
        .expect("Failed to read line");
    
    let guess: u32 = guess.trim().parse().expect("Please type a number!");
    
    println!("You guessed: {guess}");
    
    match guess.cmp(&secret_number) {
        Ordering::Less => println!("Too small!"),
        Ordering::Greater => println!("Too big!"),
        Ordering::Equal => println!("You win!"),
    }

Dòng đó là:

let guess: u32 = guess.trim().parse().expect("Please type a number!");

Chúng ta tạo một biến tên là guess. Nhưng không phải chương trình đã có biến guess rồi sao? Đúng thế, nhưng Rust cho phép chúng ta shadow giá trị cũ của guess với giá trị mới. Tính năng này thường được sử dụng trong những tình huống mà bạn muốn chuyển đổi một giá trị từ một kiểu này sang một kiểu khác. Shadow cho phép chúng ta tái sử dụng biến guess thay vì bắt chúng ta tạo hai biến riêng biệt, ví dụ guess_strguess. (Chương 3 sẽ đề cập chi tiết đến shadow.)

Chúng ta gán guess vào expression guess.trim().parse(). Biến guess trong expression là biến guess ban đầu, một String chứa input. Phương thức trim một đối tượng String sẽ loại trừ bất cứ ký tự trống nào ở đầu và cuối. Mặc dù u32 chỉ có thể chứa ký tự số, người dùng phải nhấn enter để thỏa mãn read_line. Khi người dùng nhấn enter, một dòng mới (newline) được thêm vào chuỗi. Ví dụ, nếu người dùng gõ 5 và nhấn enter, guess sẽ trông thế này: 5\n. Ký tự \n đại diện cho “newline,” kết quả của việc nhấn enter (Trên Windows, nhấn enter trả về một ký tự xuống dòng và một ký tự newline, \r\n). Phương thức trim loại bỏ \n hay \r\n, kết quả là sẽ chỉ còn 5.

Phương thức parse string ép kiểu một string sang loại khác. Chúng ta cần nói cho Rust biết kiểu số chính xác chúng ta muốn bằng cách sử dụng let guess: u32. Dấu hai chấm (:) đứng sau guess nói cho Rust chúng ta sẽ chú thích kiểu của biến. Rust có một vài kiểu số có sẵn; kiểu u32 ở đây là một kiểu số nguyên 32-bit không dấu. Nó rất phù hợp cho một số nguyên dương nhỏ. Bạn sẽ học về những kiểu số khác trong Chương 3. Thêm vào đó, ghi chú u32 trong ví dụ này và phép so sánh với secret_number có nghĩa rằng Rust sẽ suy rằng secret_number là một biến kiểu u32. Vì thế, giờ phép sẽ so sánh sẽ là giữa hai giá trị cùng kiểu!

Việc gọi parse có thể dễ dàng gây lỗi. Ví dụ nếu chuỗi chứa những ký tự A👍%, không cách nào có thể chuyển chuỗi đó thành một số. Bởi vì nó có thể sẽ thất bại, phương thức parse trả về một kiểu Result, giống như phương thức read_line làm (đã thảo luận ở phần “Xử lý lỗi thất bại tiềm tàng với kiểu Result). Chúng ta sẽ xử lý Result này một cách tương tự bằng việc lại sử dụng phương thức expect. Nếu parse trả về Err Result vì nó không thể tạo được số từ chuỗi, lời gọi expect sẽ dừng trò chơi và in ra tin nhắn chúng ta đưa. Nếu parse có thể chuyển đổi chuỗi sang số thành công, nó sẽ trả về biến thể Ok của Result, và expect sẽ trả về số chúng ta muốn từ giá trị của Ok.

Hãy thử chạy chương trình nào!

$ cargo run
   Compiling guessing_game v0.1.0 (file:///projects/guessing_game)
    Finished dev [unoptimized + debuginfo] target(s) in 0.43s
     Running `target/debug/guessing_game`
Guess the number!
The secret number is: 58
Please input your guess.
  76
You guessed: 76
Too big!

Tốt lắm! Mặc dù có những dấu cách trước số đoán, chương trình vẫn nhận biết được người dùng đoán 76. Chạy chương trình vài lần để xác nhận rằng chương trình sẽ thao tác khác nhau với những kiểu input khác nhau: đoán chính xác, đoán số quá cao hay đoán số quá thấp.

Phần lớn trò chơi giờ đã hoạt động, nhưng người dùng chỉ có thể đoán một lần. Giờ chúng ta cần thêm một vòng lặp!

Đoán nhiều lần với vòng lặp

Từ khóa loop tạo một vòng lặp vô hạn. Chúng ta sẽ thêm nó vào để cho người dùng có nhiều cơ hội để đoán hơn:

    // --snip--

    println!("The secret number is: {secret_number}");
    
    loop {
        println!("Please input your guess.");
        
        // --snip--
        
        match guess.cmp(&secret_number) {
            Ordering::Less => println!("Too small!"),
            Ordering::Greater => println!("Too big!"),
            Ordering::Equal => println!("You win!"),
        }
    }
}

Như bạn thấy, chúng ta chuyển mọi thứ vào trong một vòng lặp từ phần nhập input trở đi. Hãy chắc rằng thụt đầu dòng bên trong vòng lặp thêm 4 dấu cách nữa và chạy lại chương trình. Tuy nhiên, có một vấn đề nảy sinh, chương trình làm chính xác những gì chúng ta bảo: hỏi người dùng đoán số mới mãi mãi! Người dùng không thể thoát trò chơi!

Người dùng có thể dừng chương trình bằng sử dụng phím tắt ctrl-c. Nhưng có một cách khác để thoát con quái vật tham lam này, như đã đề cập đến trong thảo luận về parse ở phần “So sánh số đoán với số bí mật”: nếu người dùng nhập một câu trả lời không phải là số, chương trình sẽ bị lỗi và dừng. Người dùng có thể lợi dụng điều này để thoát:

$ cargo run
   Compiling guessing_game v0.1.0 (file:///projects/guessing_game)
    Finished dev [unoptimized + debuginfo] target(s) in 1.50s
     Running `target/debug/guessing_game`
Guess the number!
The secret number is: 59
Please input your guess.
45
You guessed: 45
Too small!
Please input your guess.
60
You guessed: 60
Too big!
Please input your guess.
59
You guessed: 59
You win!
Please input your guess.
quit
thread 'main' panicked at 'Please type a number!: ParseIntError { kind: InvalidDigit }', src/main.rs:28:47
note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace

quit thực sự sẽ thoát trò chơi, gõ những input không phải số khác cũng thế. Tuy nhiên, Tuy nhiên cách này không hay. Chúng ta muốn trò chơi tự động dừng khi đoán số chính xác.

Thoát sau khi đoán chính xác

Hãy thử lập trình trò chơi để thoát khi người dùng thắng bằng cách thêm một câu lệnh break:

        // --snip--

    match guess.cmp(&secret_number) {
        Ordering::Less => println!("Too small!"),
        Ordering::Greater => println!("Too big!"),
        Ordering::Equal => {
            println!("You win!");
            break;
            }
        }
    }
}

Thêm dòng break sau You win! khiến chương trình thoát vòng lặp khi người dùng đoán chính xác số bí mật. Thoát vòng lặp cũng có nghĩa là thoát chương trình, bởi vì vòng lặp là phần cuối cùng của main.

Xử lý input không hợp lệ

Tiếp tục cải thiện trò chơi, thay vì đóng chương trình lỗi khi người dùng nhập input không phải số, hãy làm cho trò chơi bỏ qua input đó và cho người dùng tiếp tục đoán. Chúng ta có thể làm điều này bằng cách chỉnh sửa dòng ép kiểu guess từ String sang u32, như sau.

        // --snip--

        io::stdin()
            .read_line(&mut guess)
            .expect("Failed to read line");

        let guess: u32 = match guess.trim().parse() {
            Ok(num) => num,
            Err(_) => continue,
        };

        println!("You guessed: {guess}");

        // --snip--

Việc chuyển từ một lời gọi expect sang một expression match là cách thông thường bạn có thể làm để chuyển từ việc chương trình bị đóng do lỗi sang việc xử lý lỗi. Nhớ rằng parse trả về một kiểu ResultResult là một enum có variant Ok hoặc Err. Chúng ta đang sử dụng một expression match, giống như chúng ta đã làm với kết quả Ordering của phương thức cmp.

Nếu parse có thẻ chuyển string thành số thành công, nó sẽ trả về một giá trị Ok chứa số đã được chuyển đổi. Giá trị Ok đó sẽ khớp với mẫu của arm đầu tiên, và expression match sẽ trả về giá trị numparse đã cung cấp và đặt nó vào trong giá trị Ok. Số đó sẽ được gán vào biến guess mới mà chúng ta đã tạo ra.

Nếu parse không thể chuyển string sang số, nó sẽ trả về một giá trị Err chứa thông tin lỗi. Giá trị Err không khớp mẫu Ok(num) ở arm đầu tiên, nhưng nó khớp mẫu Err(_) ở arm thứ hai. Dấu gạch dưới, _, là một catchall value; trong ví dụ này, chúng ta đang nói rằng chúng ta muốn khớp tất cả giá trị Err, không quan trọng chúng có thông tin gì bên trong. Nên chương trình sẽ thực thi code của arm thứ hai, continue, bảo chương trình tiếp tục chạy vòng lặp tiếp theo và yêu cầu đoán lại. Do vậy, chương trình bỏ qua tất cả các lỗi mà parse có thể gặp phải!

Cùng chạy thử nó nào:

$ cargo run
   Compiling guessing_game v0.1.0 (file:///projects/guessing_game)
    Finished dev [unoptimized + debuginfo] target(s) in 4.45s
     Running `target/debug/guessing_game`
Guess the number!
The secret number is: 61
Please input your guess.
10
You guessed: 10
Too small!
Please input your guess.
99
You guessed: 99
Too big!
Please input your guess.
foo
Please input your guess.
61
You guessed: 61
You win!

Tuyệt! Với một chút thay đổi nhỏ cuối cùng, chúng ta sẽ hoàn thành trò chơi đoán số. Chương trình hiện tại vẫn in ra số bí mật. Chúng ta cần xóa dòng println! in số bí mật. Đây là code hoàn chỉnh của chương trình.

use rand::Rng;
use std::cmp::Ordering;
use std::io;

fn main() {
    println!("Guess the number!");

    let secret_number = rand::thread_rng().gen_range(1..=100);

    loop {
        println!("Please input your guess.");
    
        let mut guess = String::new();
    
        io::stdin()
            .read_line(&mut guess)
            .expect("Failed to read line");
    
        let guess: u32 = match guess.trim().parse() {
            Ok(num) => num,
            Err(_) => continue,
        };
    
        println!("You guessed: {guess}");
    
        match guess.cmp(&secret_number) {
            Ordering::Less => println!("Too small!"),
            Ordering::Greater => println!("Too big!"),
            Ordering::Equal => {
                println!("You win!");
                break;
            }
        }
    }
}

Tổng kết

Chúc mừng bạn! Bạn đã xây dựng thành công trò chơi đoán số.

Project này là một ví dụ thực tế để giới thiệu cho bạn nhiều khái niệm mới trong Rust: let, match, phương thức, hàm liên kết, cách sử dụng crate ngoại vi, và nhiều cái khác nữa. Trong những chương tiếp theo, bạn sẽ được học về những khái nhiệm này cụ thể hơn. Chương 3 đề cập đến những khái niệm mà hầu hết các ngôn ngữ lập trình đều có, ví dụ như biến, kiểu dữ liệu, hàm và chỉ cho bạn cách sử dụng chúng trong Rust. Chương 4 tìm hiểu về quyền sở hữu (ownership), một tính năng làm cho Rust khác với những ngôn ngữ khác. Chương 5 bàn về struct và cú pháp của phương thức, và Chương 6 giải thích các hoạt động của enum.

Source

https://doc.rust-lang.org/stable/book/ch02-00-guessing-game-tutorial.html

Bài liên quan

comments powered by Disqus