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

Dự án: chương trình đếm từ bằng Rust

Trong dự án này, bạn sẽ xây một công cụ nhỏ nhưng thật: một chương trình đếm từ. Bạn đưa cho nó một đoạn văn bản, và nó cho bạn biết những từ nào xuất hiện nhiều nhất. Người viết dùng những công cụ như vậy để phát hiện các từ mình lặp lại quá nhiều. Công cụ tìm kiếm cũng dùng ý tưởng tương tự để hiểu một trang web nói về điều gì.

Chúng ta sẽ xây nó theo từng bước nhỏ. Bước nào cũng là một chương trình hoàn chỉnh chạy được, nên bạn có thể dừng ở bất cứ đâu, thử nghịch, rồi đi tiếp. Trên đường đi, bạn sẽ dùng chuỗi, HashMap, sắp xếp, định dạng kết quả in ra và đối số dòng lệnh.

Văn bản mẫu trên trang này được giữ tiếng Anh, vì chương trình đổi chữ hoa thành chữ thường và sắp xếp các từ theo bảng chữ cái. Cuối cùng, chương trình của bạn sẽ in ra thứ giống như thế này:

TỪ ĐẾM
----------------
the 6
cat 3
happy 2

Việc đầu tiên là cắt văn bản thành các từ. split_whitespace() tách một chuỗi tại các dấu cách, tab và xuống dòng, và bỏ qua những mẩu rỗng nằm giữa chúng:

fn main() {
let text = "The cat sat on the mat. The dog sat on the log!";
let words: Vec<&str> = text.split_whitespace().collect();
println!("{:?}", words);
println!("{} từ", words.len());
}

Kết quả:

["The", "cat", "sat", "on", "the", "mat.", "The", "dog", "sat", "on", "the", "log!"]
12 từ
▶ Tự chạy thử

Nó chạy, nhưng hãy nhìn kỹ. "The" và "the" bị coi là hai từ khác nhau, và "mat." vẫn còn dấu chấm. Nếu đếm ngay bây giờ, kết quả sẽ sai. Hãy sửa điều đó trước.

Chuẩn hóa (normalize) một từ nghĩa là đưa nó về một dạng chuẩn duy nhất. Ta sẽ làm hai việc:

  1. Bỏ dấu câu ở đầu và cuối, bằng trim_matches.
  2. Đổi thành chữ thường, bằng to_lowercase.
fn normalize(word: &str) -> String {
word.trim_matches(|c: char| !c.is_alphanumeric())
.to_lowercase()
}
fn main() {
let text = "The cat sat on the mat. Don't stop -- said the DOG!";
for word in text.split_whitespace() {
let clean = normalize(word);
if clean.is_empty() {
println!("{:>6} -> (bỏ qua)", word);
} else {
println!("{:>6} -> {}", word, clean);
}
}
}

Kết quả:

The -> the
cat -> cat
sat -> sat
on -> on
the -> the
mat. -> mat
Don't -> don't
stop -> stop
-- -> (bỏ qua)
said -> said
the -> the
DOG! -> dog
▶ Tự chạy thử

trim_matches nhận một closure quyết định những ký tự nào cần bỏ. Ở đây nó bỏ mọi thứ không phải chữ cái hay chữ số, nhưng chỉ ở hai đầu. Vì vậy dấu nháy trong don't vẫn còn. Một “từ” chỉ toàn dấu câu, như --, trở thành chuỗi rỗng, nên ta bỏ qua nó.

Giờ đến ý chính. Một HashMap ánh xạ mỗi từ tới số lần ta đã gặp nó. Entry API giúp việc đếm thật ngắn gọn:

use std::collections::HashMap;
fn normalize(word: &str) -> String {
word.trim_matches(|c: char| !c.is_alphanumeric())
.to_lowercase()
}
fn main() {
let text = "The cat sat on the mat. The dog sat on the log! \
Is the cat happy? Yes, the cat is very happy.";
let mut counts: HashMap<String, usize> = HashMap::new();
for word in text.split_whitespace() {
let clean = normalize(word);
if !clean.is_empty() {
*counts.entry(clean).or_insert(0) += 1;
}
}
println!("the: {}", counts["the"]);
println!("cat: {}", counts["cat"]);
println!("happy: {}", counts["happy"]);
println!("{} từ khác nhau", counts.len());
}

Kết quả:

the: 6
cat: 3
happy: 2
11 từ khác nhau
▶ Tự chạy thử

Hãy đọc chậm dòng *counts.entry(clean).or_insert(0) += 1;:

  • counts.entry(clean) tìm chỗ dành cho từ này trong map.
  • .or_insert(0) đặt một số 0 vào đó nếu từ còn mới. Dù thế nào, nó cũng trả về một tham chiếu khả biến tới số đếm.
  • * đi theo tham chiếu đó tới chính con số, và += 1 cộng thêm một.

Dấu \ ở cuối dòng văn bản đầu tiên chỉ để chuỗi tiếp tục ở dòng sau. Còn counts["the"] tra một khóa trực tiếp. Nó panic nếu khóa không có, điều đó ổn ở đây vì ta biết những từ này có trong văn bản. Với những khóa có thể không có, hãy dùng .get().

Sao ta không in cả map bằng một vòng lặp? HashMap không có thứ tự cố định. Nếu bạn lặp qua nó, thứ tự có thể đổi mỗi lần chạy chương trình. Để có một thứ tự ổn định, hữu ích, ta cần sắp xếp.

Bước 4: Sắp xếp theo số lần, rồi theo từ

Phần tiêu đề “Bước 4: Sắp xếp theo số lần, rồi theo từ”

Ta chuyển các cặp trong map vào một Vec, rồi sắp xếp nó. Ta muốn số lần lớn nhất đứng đầu. Khi hai từ có cùng số lần, ta muốn chúng theo thứ tự bảng chữ cái, để kết quả lúc nào cũng giống nhau:

use std::collections::HashMap;
fn normalize(word: &str) -> String {
word.trim_matches(|c: char| !c.is_alphanumeric())
.to_lowercase()
}
fn main() {
let text = "The cat sat on the mat. The dog sat on the log! \
Is the cat happy? Yes, the cat is very happy.";
let mut counts: HashMap<String, usize> = HashMap::new();
for word in text.split_whitespace() {
let clean = normalize(word);
if !clean.is_empty() {
*counts.entry(clean).or_insert(0) += 1;
}
}
let mut pairs: Vec<(String, usize)> = counts.into_iter().collect();
pairs.sort_by(|a, b| b.1.cmp(&a.1).then(a.0.cmp(&b.0)));
for (word, count) in &pairs {
println!("{word}: {count}");
}
}

Kết quả:

the: 6
cat: 3
happy: 2
is: 2
on: 2
sat: 2
dog: 1
log: 1
mat: 1
very: 1
yes: 1
▶ Tự chạy thử

Dòng sắp xếp là phần khó nhất của dự án này, nên hãy tách nó ra:

  • sort_by đưa cho ta hai phần tử, a và b, và hỏi “cái nào đứng trước?”.
  • b.1.cmp(&a.1) so sánh các số lần (.1 là phần thứ hai của tuple). Ta cố ý viết b trước a: điều đó đảo ngược thứ tự, nên số lần lớn đứng trước.
  • .then(a.0.cmp(&b.0)) chỉ được dùng khi số lần bằng nhau. Nó so sánh các từ theo thứ tự bình thường từ a đến b, nên các từ hòa nhau được xếp theo bảng chữ cái.

Trang Sắp xếp có thêm nhiều ví dụ về sort_by và những hàm cùng họ.

Một danh sách dài thì khó đọc. Hãy chỉ hiện vài từ đứng đầu, trong những cột ngay ngắn. Các ký hiệu định dạng sẽ căn lề giúp ta:

  • {:<10} nghĩa là “căn trái trong một khoảng rộng 10 ký tự”.
  • {:>5} nghĩa là “căn phải trong một khoảng rộng 5 ký tự”. Số thường trông đẹp nhất khi căn phải.

Ta cũng chuyển code vào các hàm, để main đọc như một câu chuyện ngắn:

use std::collections::HashMap;
fn normalize(word: &str) -> String {
word.trim_matches(|c: char| !c.is_alphanumeric())
.to_lowercase()
}
fn count_words(text: &str) -> HashMap<String, usize> {
let mut counts = HashMap::new();
for word in text.split_whitespace() {
let clean = normalize(word);
if !clean.is_empty() {
*counts.entry(clean).or_insert(0) += 1;
}
}
counts
}
fn sorted_counts(counts: HashMap<String, usize>) -> Vec<(String, usize)> {
let mut pairs: Vec<(String, usize)> = counts.into_iter().collect();
pairs.sort_by(|a, b| b.1.cmp(&a.1).then(a.0.cmp(&b.0)));
pairs
}
fn print_table(pairs: &[(String, usize)], top_n: usize) {
println!("{:<10} {:>5}", "TỪ", "ĐẾM");
println!("{}", "-".repeat(16));
for (word, count) in pairs.iter().take(top_n) {
println!("{:<10} {:>5}", word, count);
}
}
fn main() {
let text = "The cat sat on the mat. The dog sat on the log! \
Is the cat happy? Yes, the cat is very happy.";
let pairs = sorted_counts(count_words(text));
print_table(&pairs, 5);
}

Kết quả:

TỪ ĐẾM
----------------
the 6
cat 3
happy 2
is 2
on 2
▶ Tự chạy thử

.take(top_n) dừng sau top_n phần tử. Nếu số từ ít hơn thế, nó chỉ đơn giản dừng sớm, nên bạn không bao giờ phải lo đi quá cuối danh sách.

Để ý rằng print_table nhận &[(String, usize)], một slice. Nghĩa là nó chỉ mượn danh sách, và chạy được với bất kỳ Vec các cặp nào.

Một chương trình đếm từ thật nên đọc file. Ta sẽ lấy đường dẫn file từ dòng lệnh, cùng một đối số thứ hai tùy chọn cho số dòng cần hiện. Nếu có gì sai, ta in một thông báo thân thiện bằng eprintln! (ghi ra luồng lỗi) và thoát với mã 1.

Tạo project và một file văn bản để thử:

Terminal window
cargo new word_counter
cd word_counter
echo "The cat sat on the mat. The dog sat on the log!" > story.txt

Đặt đoạn này vào src/main.rs. Phiên bản này dành cho máy của bạn, vì Playground không đọc được file của bạn:

use std::collections::HashMap;
use std::env;
use std::fs;
use std::process;
fn normalize(word: &str) -> String {
word.trim_matches(|c: char| !c.is_alphanumeric())
.to_lowercase()
}
fn count_words(text: &str) -> HashMap<String, usize> {
let mut counts = HashMap::new();
for word in text.split_whitespace() {
let clean = normalize(word);
if !clean.is_empty() {
*counts.entry(clean).or_insert(0) += 1;
}
}
counts
}
fn sorted_counts(counts: HashMap<String, usize>) -> Vec<(String, usize)> {
let mut pairs: Vec<(String, usize)> = counts.into_iter().collect();
pairs.sort_by(|a, b| b.1.cmp(&a.1).then(a.0.cmp(&b.0)));
pairs
}
fn print_table(pairs: &[(String, usize)], top_n: usize) {
println!("{:<10} {:>5}", "TỪ", "ĐẾM");
println!("{}", "-".repeat(16));
for (word, count) in pairs.iter().take(top_n) {
println!("{:<10} {:>5}", word, count);
}
}
fn main() {
let args: Vec<String> = env::args().collect();
// args[0] là tên chương trình, args[1] là đường dẫn file
let Some(path) = args.get(1) else {
eprintln!("Cách dùng: word_counter <file> [top_n]");
process::exit(1);
};
// đối số thứ hai là tùy chọn; dùng 10 nếu thiếu hoặc không phải số
let top_n: usize = match args.get(2) {
Some(n) => n.parse().unwrap_or(10),
None => 10,
};
let text = match fs::read_to_string(path) {
Ok(text) => text,
Err(e) => {
eprintln!("Không đọc được {path}: {e}");
process::exit(1);
}
};
let pairs = sorted_counts(count_words(&text));
let total: usize = pairs.iter().map(|(_, count)| count).sum();
println!("{} từ, {} từ khác nhau\n", total, pairs.len());
print_table(&pairs, top_n);
}

Chạy nó. Nhớ rằng đối số cho chương trình của bạn đặt sau --:

Terminal window
cargo run -- story.txt 3
12 từ, 7 từ khác nhau
TỪ ĐẾM
----------------
the 4
on 2
sat 2

Và nếu bạn quên tên file, bạn nhận được thông báo cách dùng thay vì một cú crash:

Terminal window
cargo run
Cách dùng: word_counter <file> [top_n]

let Some(path) = args.get(1) else { ... }; được gọi là let-else. Nó nghĩa là “nếu mẫu này khớp, cứ đi tiếp; nếu không thì chạy khối else, khối này bắt buộc phải dừng lại (ở đây là bằng cách thoát chương trình)”. Trang Đọc và ghi file giải thích chi tiết hơn về read_to_string và các lỗi của nó.

Đây là cùng chương trình đó với văn bản viết sẵn bên trong, để bạn chạy được trên Playground. Chỉ có main là khác:

use std::collections::HashMap;
fn normalize(word: &str) -> String {
word.trim_matches(|c: char| !c.is_alphanumeric())
.to_lowercase()
}
fn count_words(text: &str) -> HashMap<String, usize> {
let mut counts = HashMap::new();
for word in text.split_whitespace() {
let clean = normalize(word);
if !clean.is_empty() {
*counts.entry(clean).or_insert(0) += 1;
}
}
counts
}
fn sorted_counts(counts: HashMap<String, usize>) -> Vec<(String, usize)> {
let mut pairs: Vec<(String, usize)> = counts.into_iter().collect();
pairs.sort_by(|a, b| b.1.cmp(&a.1).then(a.0.cmp(&b.0)));
pairs
}
fn print_table(pairs: &[(String, usize)], top_n: usize) {
println!("{:<10} {:>5}", "TỪ", "ĐẾM");
println!("{}", "-".repeat(16));
for (word, count) in pairs.iter().take(top_n) {
println!("{:<10} {:>5}", word, count);
}
}
fn main() {
// giả vờ văn bản này đến từ một file
let text = "Rust is fast. Rust is safe. Rust is fun!
Is it hard? It takes practice, and practice is fun too.";
let top_n = 4;
let pairs = sorted_counts(count_words(text));
let total: usize = pairs.iter().map(|(_, count)| count).sum();
println!("{} từ, {} từ khác nhau\n", total, pairs.len());
print_table(&pairs, top_n);
}

Kết quả:

20 từ, 11 từ khác nhau
TỪ ĐẾM
----------------
is 5
rust 3
fun 2
it 2
▶ Tự chạy thử

Quên * khi đếm. or_insert cho bạn một tham chiếu tới con số, không phải chính con số. Thiếu *, Rust từ chối cộng vào nó:

*counts.entry(word).or_insert(0) += 1; // đúng
counts.entry(word).or_insert(0) += 1; // lỗi
error[E0368]: binary assignment operation `+=` cannot be applied to type `&mut {integer}`
--> src/main.rs:6:9
|
6 | counts.entry(word).or_insert(0) += 1;
| -------------------------------^^^^^
| |
| cannot use `+=` on type `&mut {integer}`
|
help: `+=` can be used on `{integer}` if you dereference the left-hand side
|
6 | *counts.entry(word).or_insert(0) += 1;
| +

Trình biên dịch báo rằng không thể dùng += trên kiểu &mut {integer}, và còn chỉ luôn cách sửa cho bạn: thêm dấu *.

In thẳng HashMap. println!("{:?}", counts) chạy được, nhưng thứ tự là ngẫu nhiên và có thể đổi giữa các lần chạy. Khi thứ tự quan trọng, hãy luôn sắp xếp vào một Vec trước.

Chỉ sắp xếp theo số lần. Nếu bạn sắp xếp chỉ bằng b.1.cmp(&a.1), những từ có cùng số lần có thể hiện theo thứ tự bất kỳ. Hãy thêm phần phân định hòa .then(...) để kết quả lần nào cũng giống nhau.

Bạn đã có một công cụ chạy được. Đây là vài cách để biến nó thành của riêng bạn, từ dễ nhất đến khó nhất:

  • Bỏ qua stop word. Những từ như “the”, “a”, “is” và “and” có mặt ở khắp nơi và không thú vị lắm. Hãy đặt chúng vào một HashSet và bỏ qua mọi từ nằm trong đó.
  • Hiện phần trăm. Bên cạnh mỗi số lần, in ra nó chiếm bao nhiêu phần trăm tổng số từ, như the 6 28.6%. Dùng {:.1} để hiện một chữ số thập phân.
  • Đọc từ stdin khi không có file. Dùng std::io::read_to_string(std::io::stdin()) để bạn có thể đưa văn bản vào qua pipe: cat story.txt | cargo run.
  • Đếm nhiều file. Lặp qua mọi đường dẫn trong args[1..] và cộng tất cả các từ của chúng vào một map.
  • Dùng clap cho đối số. Chạy cargo add clap --features derive và thêm các cờ thật như --top 5 và --ignore-case, kèm thông báo --help miễn phí. Xem ví dụ ở trang Đối số dòng lệnh.
  • Viết test. Chuyển các hàm vào src/lib.rs và thêm vài bài test cho normalize và count_words.
  • split_whitespace() cắt văn bản thành các từ; trim_matches và to_lowercase chuẩn hóa chúng.
  • *map.entry(key).or_insert(0) += 1 là cách chuẩn để đếm mọi thứ bằng HashMap.
  • HashMap không có thứ tự. Hãy gom vào một Vec và dùng sort_by với phần phân định hòa để có kết quả ổn định.
  • {:<10} và {:>5} căn chữ thành cột; .take(n) giới hạn số dòng bạn in ra.
  • Đọc đường dẫn file từ env::args(), xử lý lỗi bằng một thông báo thân thiện, và thoát bằng process::exit(1).

Kiểm tra nhanh

1. "Hello!".trim_matches(|c: char| !c.is_alphanumeric()) trả về gì?

2. Vì sao ta gom HashMap vào một Vec trước khi in?

3. Trong pairs.sort_by(|a, b| b.1.cmp(&a.1)), vì sao b được viết trước a?

4. {:>5} làm gì trong println!?

Điểm: 0 / 4