Lewati ke konten

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 6
cat 3
happy 2

Teks 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.

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 kata
▶ Coba sendiri

Sudah 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.

Menormalkan kata artinya mengubahnya ke satu bentuk standar. Kita akan melakukan dua hal:

  1. Menghapus tanda baca di awal dan akhir kata, dengan trim_matches.
  2. 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! -> dog
▶ Coba sendiri

trim_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.

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: 6
cat: 3
happy: 2
11 kata berbeda
▶ Coba sendiri

Mari baca *counts.entry(clean).or_insert(0) += 1; pelan-pelan:

  • counts.entry(clean) mencari tempat untuk kata ini di dalam map.
  • .or_insert(0) menaruh 0 di sana kalau katanya baru. Apa pun kasusnya, ia mengembalikan referensi mutable ke hitungannya.
  • * mengikuti referensi itu ke angkanya, lalu += 1 menambahkan 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: 6
cat: 3
happy: 2
is: 2
on: 2
sat: 2
dog: 1
log: 1
mat: 1
very: 1
yes: 1
▶ Coba sendiri

Baris pengurutan ini adalah bagian tersulit dari proyek ini, jadi mari kita bongkar:

  • sort_by memberi kita dua item, a dan b, lalu bertanya “mana yang duluan?”.
  • b.1.cmp(&a.1) membandingkan jumlahnya (.1 adalah bagian kedua dari tuple). Kita sengaja menulis b sebelum a: 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 dari a ke b, sehingga kata dengan jumlah yang sama diurutkan sesuai abjad.

Halaman Mengurutkan dan mencari punya lebih banyak contoh sort_by dan teman-temannya.

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 6
cat 3
happy 2
is 2
on 2
▶ Coba sendiri

.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.

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:

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

Taruh 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 --:

Terminal window
cargo run -- story.txt 3
12 kata, 7 berbeda
WORD COUNT
----------------
the 4
on 2
sat 2

Dan kalau kamu lupa menulis nama file-nya, yang muncul adalah pesan cara pakai, bukan crash:

Terminal window
cargo run
Cara 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 5
rust 3
fun 2
it 2
▶ Coba sendiri

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; // benar
counts.entry(word).or_insert(0) += 1; // error
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;
| +

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.

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 HashSet dan 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 clap untuk argumen. Jalankan cargo add clap --features derive dan tambahkan flag sungguhan seperti --top 5 dan --ignore-case, plus pesan --help gratis. Lihat halaman Argumen command line untuk contohnya.
  • Tulis test. Pindahkan fungsi-fungsinya ke src/lib.rs dan tambahkan beberapa test untuk normalize dan count_words.
  • split_whitespace() memotong teks menjadi kata; trim_matches dan to_lowercase menormalkannya.
  • *map.entry(key).or_insert(0) += 1 adalah cara standar menghitung sesuatu dengan HashMap.
  • HashMap tidak punya urutan. Kumpulkan ke Vec dan pakai sort_by dengan 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 dengan process::exit(1).

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