Proyek: Penghitung Kata dengan Rust
Di proyek ini kamu akan membuat sebuah tool kecil tapi sungguhan: penghitung kata. Kamu memberinya sebuah teks, dan ia memberi tahu kata mana yang paling sering muncul. Penulis memakai tool seperti ini untuk menemukan kata yang terlalu sering mereka ulang. Mesin pencari memakai ide yang sama untuk memahami isi sebuah halaman.
Kita akan membangunnya sedikit demi sedikit. Setiap langkah adalah program lengkap yang bisa dijalankan, jadi kamu bisa berhenti kapan saja, bermain-main dengannya, lalu lanjut lagi. Sepanjang jalan kamu akan memakai string, HashMap, sorting (pengurutan), output yang diformat, dan argumen command line.
Di akhir, programmu akan mencetak sesuatu seperti ini:
WORD COUNT----------------the 6cat 3happy 2Teks contoh di halaman ini sengaja tetap dalam bahasa Inggris, karena semua hasil hitungan dan penjelasannya bergantung pada kata-kata itu. Judul kolom WORD dan COUNT juga tetap dalam bahasa Inggris, karena lebar kolom tabelnya dihitung dari panjang teks tersebut. Setelah programnya jadi, silakan coba dengan teks bahasa Indonesia milikmu sendiri.
Langkah 1: Memecah teks menjadi kata
Section titled “Langkah 1: Memecah teks menjadi kata”Tugas pertama adalah memotong teks menjadi kata-kata. split_whitespace() memecah string di setiap spasi, tab, dan baris baru, serta melewati bagian kosong di antaranya:
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!("{} kata", words.len());}Output:
["The", "cat", "sat", "on", "the", "mat.", "The", "dog", "sat", "on", "the", "log!"]12 kataSudah jalan, tapi perhatikan baik-baik. "The" dan "the" dianggap kata yang berbeda, dan "mat." masih membawa titiknya. Kalau kita menghitung sekarang, hasilnya akan salah. Mari kita perbaiki dulu.
Langkah 2: Menormalkan setiap kata
Section titled “Langkah 2: Menormalkan setiap kata”Menormalkan kata artinya mengubahnya ke satu bentuk standar. Kita akan melakukan dua hal:
- Menghapus tanda baca di awal dan akhir kata, dengan
trim_matches. - Mengubahnya menjadi huruf kecil, dengan
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} -> (dilewati)", word); } else { println!("{:>6} -> {}", word, clean); } }}Output:
The -> the cat -> cat sat -> sat on -> on the -> the mat. -> mat Don't -> don't stop -> stop -- -> (dilewati) said -> said the -> the DOG! -> dogtrim_matches menerima sebuah closure (fungsi anonim) yang menentukan karakter mana yang dihapus. Di sini ia menghapus apa pun yang bukan huruf atau angka, tapi hanya di kedua ujung. Itu sebabnya apostrof di don't tetap ada. “Kata” yang isinya hanya tanda baca, seperti --, menjadi string kosong, jadi kita lewati.
Langkah 3: Menghitung dengan HashMap
Section titled “Langkah 3: Menghitung dengan HashMap”Sekarang ide utamanya. Sebuah HashMap memetakan setiap kata ke berapa kali kita sudah melihatnya. Entry API membuat kode penghitungnya jadi singkat:
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!("{} kata berbeda", counts.len());}Output:
the: 6cat: 3happy: 211 kata berbedaMari baca *counts.entry(clean).or_insert(0) += 1; pelan-pelan:
counts.entry(clean)mencari tempat untuk kata ini di dalam map..or_insert(0)menaruh0di sana kalau katanya baru. Apa pun kasusnya, ia mengembalikan referensi mutable ke hitungannya.*mengikuti referensi itu ke angkanya, lalu+= 1menambahkan satu.
Tanda \ di akhir baris teks pertama hanya menyambung string ke baris berikutnya. Lalu counts["the"] mencari sebuah key secara langsung. Ia akan panic kalau key-nya tidak ada, tapi di sini aman karena kita tahu kata-kata itu ada di dalam teks. Untuk key yang mungkin tidak ada, pakai .get().
Kenapa kita tidak mencetak seluruh map dengan loop? Karena HashMap tidak punya urutan tetap. Kalau kamu melakukan loop padanya, urutannya bisa berubah setiap kali program dijalankan. Untuk mendapatkan urutan yang stabil dan berguna, kita perlu mengurutkannya.
Langkah 4: Urutkan berdasarkan jumlah, lalu berdasarkan kata
Section titled “Langkah 4: Urutkan berdasarkan jumlah, lalu berdasarkan kata”Kita pindahkan pasangan-pasangan dari map ke sebuah Vec, lalu kita urutkan. Kita ingin jumlah terbanyak di atas. Kalau dua kata punya jumlah yang sama, kita ingin keduanya diurutkan sesuai abjad, supaya output-nya selalu sama:
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}"); }}Output:
the: 6cat: 3happy: 2is: 2on: 2sat: 2dog: 1log: 1mat: 1very: 1yes: 1Baris pengurutan ini adalah bagian tersulit dari proyek ini, jadi mari kita bongkar:
sort_bymemberi kita dua item,adanb, lalu bertanya “mana yang duluan?”.b.1.cmp(&a.1)membandingkan jumlahnya (.1adalah bagian kedua dari tuple). Kita sengaja menulisbsebeluma: itu membalik urutannya, sehingga jumlah besar muncul duluan..then(a.0.cmp(&b.0))hanya dipakai kalau jumlahnya sama. Ia membandingkan katanya dengan urutan normal dariakeb, sehingga kata dengan jumlah yang sama diurutkan sesuai abjad.
Halaman Mengurutkan dan mencari punya lebih banyak contoh sort_by dan teman-temannya.
Langkah 5: Mencetak tabel top-N
Section titled “Langkah 5: Mencetak tabel top-N”Daftar yang panjang susah dibaca. Mari tampilkan beberapa kata teratas saja, dalam kolom yang rapi. Format specifier akan mengatur perataannya untuk kita:
{:<10}artinya “rata kiri dalam ruang selebar 10 karakter”.{:>5}artinya “rata kanan dalam ruang selebar 5 karakter”. Angka biasanya paling enak dilihat kalau rata kanan.
Kita juga akan memindahkan kodenya ke beberapa fungsi, supaya main terbaca seperti cerita pendek:
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}", "WORD", "COUNT"); 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);}Output:
WORD COUNT----------------the 6cat 3happy 2is 2on 2.take(top_n) berhenti setelah top_n item. Kalau jumlah katanya lebih sedikit dari itu, ia berhenti lebih awal, jadi kamu tidak perlu khawatir melewati ujung daftar.
Perhatikan bahwa print_table menerima &[(String, usize)], sebuah slice. Artinya fungsi itu hanya meminjam daftarnya, dan bisa dipakai untuk Vec berisi pasangan apa pun.
Langkah 6: Versi final yang membaca file
Section titled “Langkah 6: Versi final yang membaca file”Penghitung kata yang sungguhan seharusnya membaca file. Kita akan mengambil path file dari command line, plus argumen kedua (opsional) untuk jumlah baris yang ditampilkan. Kalau ada yang salah, kita cetak pesan yang ramah dengan eprintln! (yang menulis ke output error) dan keluar dengan kode 1.
Buat proyeknya dan sebuah file teks untuk dites:
cargo new word_countercd word_counterecho "The cat sat on the mat. The dog sat on the log!" > story.txtTaruh kode ini di src/main.rs. Versi ini untuk komputermu sendiri, karena Playground tidak bisa membaca file-mu:
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}", "WORD", "COUNT"); 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] adalah nama program, args[1] adalah path file let Some(path) = args.get(1) else { eprintln!("Cara pakai: word_counter <file> [top_n]"); process::exit(1); };
// argumen kedua opsional; pakai 10 kalau tidak ada atau bukan angka 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!("Gagal membaca {path}: {e}"); process::exit(1); } };
let pairs = sorted_counts(count_words(&text)); let total: usize = pairs.iter().map(|(_, count)| count).sum();
println!("{} kata, {} berbeda\n", total, pairs.len()); print_table(&pairs, top_n);}Jalankan. Ingat, argumen untuk program milikmu ditulis setelah --:
cargo run -- story.txt 312 kata, 7 berbeda
WORD COUNT----------------the 4on 2sat 2Dan kalau kamu lupa menulis nama file-nya, yang muncul adalah pesan cara pakai, bukan crash:
cargo runCara pakai: word_counter <file> [top_n]let Some(path) = args.get(1) else { ... }; disebut let-else. Artinya “kalau pola ini cocok, lanjutkan; kalau tidak, jalankan blok else, yang wajib menghentikan alurnya (di sini, dengan keluar dari program)”. Halaman Membaca dan menulis file menjelaskan read_to_string dan error-nya dengan lebih detail.
Ini program yang sama dengan teks yang sudah ditanam di kodenya, supaya bisa dijalankan di Playground. Hanya main yang berbeda:
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}", "WORD", "COUNT"); println!("{}", "-".repeat(16)); for (word, count) in pairs.iter().take(top_n) { println!("{:<10} {:>5}", word, count); }}
fn main() { // anggap saja teks ini dibaca dari sebuah 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!("{} kata, {} berbeda\n", total, pairs.len()); print_table(&pairs, top_n);}Output:
20 kata, 11 berbeda
WORD COUNT----------------is 5rust 3fun 2it 2Kesalahan umum
Section titled “Kesalahan umum”Lupa menulis * saat menghitung. or_insert memberimu sebuah referensi ke angkanya, bukan angkanya sendiri. Tanpa *, Rust menolak menambahkan nilainya:
*counts.entry(word).or_insert(0) += 1; // benarcounts.entry(word).or_insert(0) += 1; // errorerror[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; | +Pesannya bilang += tidak bisa dipakai pada tipe &mut {integer} (sebuah referensi), tapi bisa dipakai pada {integer} kalau sisi kirinya di-dereference. Compiler bahkan menunjukkan solusinya: tambahkan *.
Mencetak HashMap secara langsung. println!("{:?}", counts) memang jalan, tapi urutannya acak dan bisa berubah setiap kali program dijalankan. Kalau urutan itu penting, selalu urutkan ke dalam Vec dulu.
Mengurutkan berdasarkan jumlah saja. Kalau kamu mengurutkan hanya dengan b.1.cmp(&a.1), kata-kata dengan jumlah yang sama bisa muncul dalam urutan apa saja. Tambahkan .then(...) sebagai penentu urutan, supaya output-nya selalu sama.
Ide untuk mengembangkannya
Section titled “Ide untuk mengembangkannya”Sekarang kamu punya tool yang berfungsi. Ini beberapa cara untuk menjadikannya milikmu, dari yang paling mudah sampai yang paling sulit:
- Abaikan stop word. Kata seperti “the”, “a”, “is”, dan “and” ada di mana-mana dan tidak terlalu menarik. (Dalam bahasa Indonesia, contohnya “yang”, “dan”, “di”, dan “itu”.) Masukkan kata-kata itu ke sebuah
HashSetdan lewati setiap kata yang ada di dalamnya. - Tampilkan persentase. Di sebelah setiap jumlah, cetak berapa persen bagiannya dari seluruh kata, seperti
the 6 28.6%. Pakai{:.1}untuk menampilkan satu angka di belakang koma. - Baca dari stdin kalau tidak ada file. Pakai
std::io::read_to_string(std::io::stdin())supaya kamu bisa mengirim teks lewat pipe:cat story.txt | cargo run. - Hitung beberapa file sekaligus. Lakukan loop pada setiap path di
args[1..]dan tambahkan semua katanya ke satu map. - Pakai
clapuntuk argumen. Jalankancargo add clap --features derivedan tambahkan flag sungguhan seperti--top 5dan--ignore-case, plus pesan--helpgratis. Lihat halaman Argumen command line untuk contohnya. - Tulis test. Pindahkan fungsi-fungsinya ke
src/lib.rsdan tambahkan beberapa test untuknormalizedancount_words.
Ringkasan
Section titled “Ringkasan”split_whitespace()memotong teks menjadi kata;trim_matchesdanto_lowercasemenormalkannya.*map.entry(key).or_insert(0) += 1adalah cara standar menghitung sesuatu dengan HashMap.- HashMap tidak punya urutan. Kumpulkan ke
Vecdan pakaisort_bydengan penentu urutan kedua supaya output-nya stabil. {:<10}dan{:>5}merapikan teks dalam kolom;.take(n)membatasi berapa baris yang dicetak.- Baca path file dari
env::args(), tangani error dengan pesan yang ramah, dan keluar denganprocess::exit(1).
Kuis singkat
Section titled “Kuis singkat”Kuis singkat
1. Apa hasil dari "Hello!".trim_matches(|c: char| !c.is_alphanumeric())?
2. Kenapa kita mengumpulkan isi HashMap ke dalam Vec sebelum mencetaknya?
3. Di pairs.sort_by(|a, b| b.1.cmp(&a.1)), kenapa b ditulis sebelum a?
4. Apa fungsi {:>5} di println!?
Skor: 0 / 4
Bacaan lebih lanjut
Section titled “Bacaan lebih lanjut”- Storing Keys with Associated Values in Hash Maps — The Rust Book (bahasa Inggris): ada contoh menghitung kata dengan
entrydanor_insert, sangat mirip dengan Langkah 3. - An I/O Project: Building a Command Line Program — The Rust Book (bahasa Inggris): membuat tiruan kecil
grepyang membaca argumen dan file, keterampilan yang sama dengan Langkah 6, tapi lebih mendalam. HashMap::entry— dokumentasi std (bahasa Inggris): mengembalikan sebuahEntry, yang method-nya antara lainor_insert,or_default, danand_modify.slice::sort_by— dokumentasi std (bahasa Inggris): cara kerja closure pembanding, dan kenapa pengurutannya stabil (item yang sama tetap pada urutannya).- Width and alignment — dokumentasi
std::fmt(bahasa Inggris): semua opsi padding dan perataan, seperti{:<10}dan{:>6}, yang dipakai di tabel-tabel halaman ini.