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 6cat 3happy 2Bước 1: Tách văn bản thành các từ
Phần tiêu đề “Bước 1: Tách văn bản thành các từ”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ừ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.
Bước 2: Chuẩn hóa từng từ
Phần tiêu đề “Bước 2: Chuẩn hóa từng từ”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:
- Bỏ dấu câu ở đầu và cuối, bằng
trim_matches. - Đổ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! -> dogtrim_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ó.
Bước 3: Đếm bằng HashMap
Phần tiêu đề “Bước 3: Đếm bằng HashMap”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: 6cat: 3happy: 211 từ khác nhauHã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ố0và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à+= 1cộ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: 6cat: 3happy: 2is: 2on: 2sat: 2dog: 1log: 1mat: 1very: 1yes: 1Dò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ử,avàb, và hỏi “cái nào đứng trước?”.b.1.cmp(&a.1)so sánh các số lần (.1là phần thứ hai của tuple). Ta cố ý viếtbtrướca: đ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đếnb, 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ọ.
Bước 5: In bảng top N
Phần tiêu đề “Bước 5: In bảng top N”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 6cat 3happy 2is 2on 2.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.
Bước 6: Phiên bản cuối, đọc từ file
Phần tiêu đề “Bước 6: Phiên bản cuối, đọc từ file”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ử:
cargo new word_countercd word_counterecho "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 --:
cargo run -- story.txt 312 từ, 7 từ khác nhau
TỪ ĐẾM----------------the 4on 2sat 2Và 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:
cargo runCá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 5rust 3fun 2it 2Lỗi thường gặp
Phần tiêu đề “Lỗi thường gặp”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; // đúngcounts.entry(word).or_insert(0) += 1; // lỗierror[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.
Ý tưởng để mở rộng
Phần tiêu đề “Ý tưởng để mở rộng”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
HashSetvà 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
clapcho đối số. Chạycargo add clap --features derivevà thêm các cờ thật như--top 5và--ignore-case, kèm thông báo--helpmiễ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.rsvà thêm vài bài test chonormalizevàcount_words.
Tóm tắt
Phần tiêu đề “Tóm tắt”split_whitespace()cắt văn bản thành các từ;trim_matchesvàto_lowercasechuẩn hóa chúng.*map.entry(key).or_insert(0) += 1là cách chuẩn để đếm mọi thứ bằng HashMap.- HashMap không có thứ tự. Hãy gom vào một
Vecvà dùngsort_byvớ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ằngprocess::exit(1).
Kiểm tra nhanh
Phần tiêu đề “Kiểm tra nhanh”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
Đọc thêm
Phần tiêu đề “Đọc thêm”- Storing Keys with Associated Values in Hash Maps — The Rust Book (tiếng Anh): có một ví dụ đếm từ với
entryvàor_insert, rất gần với Bước 3. - An I/O Project: Building a Command Line Program — The Rust Book (tiếng Anh): xây một bản sao nhỏ của
grepđọc đối số và file, cùng những kỹ năng như Bước 6 nhưng sâu hơn. HashMap::entry— std docs (tiếng Anh): trả về mộtEntry, với các phương thức nhưor_insert,or_defaultvàand_modify.slice::sort_by— std docs (tiếng Anh): closure so sánh hoạt động thế nào, và vì sao phép sắp xếp là ổn định (các phần tử bằng nhau giữ nguyên thứ tự).- Width and alignment —
std::fmtdocs (tiếng Anh): mọi tùy chọn đệm và căn lề, như{:<10}và{:>6}, được dùng trong các bảng trên trang này.