홈강좌Rust 입문·중급 47편 중 47편째
Rust 실습 — wordfreq 2판: 라이브러리·반복자·스레드·테스트
34편의 wordfreq 를 중급 구간에서 배운 것으로 다시 만든다. 세는 부분을 lib.rs 라이브러리로 빼고(40편), 반복자와 클로저로 줄이고(36·37편), 파일마다 스레드를 띄워 동시에 세고(43편), 테스트 넷으로 고정한다(42편). 같은 횟수는 알파벳 순으로 정렬한다.
01 무엇이 달라지나
| 1판 (34편) | 2판 | |
|---|---|---|
| 파일 | main.rs 하나 | lib.rs(세기) + main.rs(입출력) |
| 읽는 파일 | 하나 | 여러 개, 파일마다 스레드 |
| 같은 횟수 | 알파벳 거꾸로 | 알파벳 순 |
| 확인 | 눈으로 | 테스트 넷 |
02 lib.rs — 세는 일만 한다
src/lib.rs 가 있으면 cargo 는 같은 이름(wordfreq)의 라이브러리 크레이트를 함께 만든다. main.rs 는 그것을 41편의 외부 크레이트처럼 use wordfreq::… 로 쓴다. 세는 일과 입출력이 나뉘어, 세는 부분만 따로 테스트할 수 있다.
use std::collections::HashMap;
pub type Counts = HashMap<String, usize>;
pub fn count_words(text: &str) -> Counts {
let mut counts = Counts::new();
let words = text
.split_whitespace()
.map(|raw| raw.trim_matches(['.', ',', '!', '?', '"']).to_lowercase())
.filter(|word| !word.is_empty());
for word in words {
*counts.entry(word).or_insert(0) += 1;
}
counts
}
pub fn merge(total: &mut Counts, part: Counts) {
for (word, n) in part {
*total.entry(word).or_insert(0) += n;
}
}
pub fn top(counts: Counts, n: 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.truncate(n);
pairs
}pub type Counts = HashMap<String, usize>;는 긴 타입에 짧은 이름을 붙인다. 새 타입이 아니라 별명이다.count_words는 1판의for+if … continue를 반복자로 바꿨다.map으로 다듬고filter로 빈 것을 거른다(37편). 결과는 같다.merge는 파일 하나의 결과를 전체에 더한다. 스레드마다 따로 센 것을 합칠 때 쓴다.top의sort_by는 두 짝을 비교하는 규칙을 클로저로 받는다(36편).b.1.cmp(&a.1)은 횟수를 큰 것부터,.then(a.0.cmp(&b.0))은 횟수가 같으면 단어를 알파벳 순으로 비교한다.cmp는 크다·같다·작다를 돌려주는 비교 메서드다.
03 main.rs — 파일마다 스레드
use std::{env, fs, process, thread};
use wordfreq::{Counts, count_words, merge, top};
fn main() {
let paths: Vec<String> = env::args().skip(1).collect();
if paths.is_empty() {
eprintln!("usage: wordfreq <file>...");
process::exit(2);
}
let handles: Vec<_> = paths
.into_iter()
.map(|path| {
thread::spawn(move || {
fs::read_to_string(&path)
.map(|text| count_words(&text))
.map_err(|e| format!("cannot read {path}: {e}"))
})
})
.collect();
let mut total = Counts::new();
for handle in handles {
match handle.join().unwrap() {
Ok(counts) => merge(&mut total, counts),
Err(message) => {
eprintln!("{message}");
process::exit(1);
}
}
}
for (word, n) in top(total, 10) {
println!("{n:>3} {word}");
}
}env::args().skip(1).collect()은 프로그램 이름을 건너뛰고 나머지 인자를 모두Vec으로 모은다(37편).- 파일마다
thread::spawn(move || …)로 스레드를 띄운다(43편). 손잡이를Vec으로 모으는 것까지 반복자 한 줄이다. - 스레드 안에서
fs::read_to_string(&path)의Result에.map(…)을 붙이면Ok일 때만 안의 값을 바꾼다(세어서Counts로)..map_err(…)는Err일 때만 오류를 사람이 읽는 문장으로 바꾼다. 스레드의 결과는Result<Counts, String>이다. - 모두 띄운 뒤 차례로
join해서merge하고, 하나라도 실패하면 이유를 찍고 종료 코드 1 로 끝낸다(34편과 같다).
04 돌려 본다
34편의 sample.txt 를 a.txt 로, 빌림 검사기에 관한 세 줄을 b.txt 로 두고 둘을 함께 센다.
Compiling wordfreq v0.2.0 (~/projects/wordfreq)
Finished `dev` profile [unoptimized + debuginfo] target(s) in 0.86s
Running `target/debug/wordfreq a.txt b.txt`
15 the
7 is
6 code
5 and
4 it
3 a
3 build
3 compiler
3 fix
3 run두 파일의 합이다. the 가 15번(8 + 7)이고, 3번 나온 a·build·compiler·fix·run 이 이번에는 알파벳 순으로 섰다. 없는 파일이 섞이면 그 이유로 멈춘다.
Compiling wordfreq v0.2.0 (~/projects/wordfreq)
Finished `dev` profile [unoptimized + debuginfo] target(s) in 0.39s
Running `target/debug/wordfreq a.txt nope.txt`
cannot read nope.txt: No such file or directory (os error 2)05 테스트
lib.rs 끝에 붙인 테스트 넷이다. 대소문자·문장 부호 무시, 합치기, 동점 정렬, 빈 글을 확인한다.
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn ignores_case_and_punctuation() {
let counts = count_words("Rust, rust! RUST.");
assert_eq!(counts.get("rust"), Some(&3));
}
#[test]
fn merge_adds_counts() {
let mut total = count_words("a b");
merge(&mut total, count_words("b c"));
assert_eq!(total.get("b"), Some(&2));
assert_eq!(total.len(), 3);
}
#[test]
fn top_breaks_ties_alphabetically() {
let result = top(count_words("b a c a b"), 2);
assert_eq!(result, vec![("a".to_string(), 2), ("b".to_string(), 2)]);
}
#[test]
fn empty_text_has_no_words() {
assert!(count_words(" ... !! ").is_empty());
}
} Running unittests src/lib.rs (target/debug/deps/wordfreq-55880e97fe793190)
running 4 tests
test tests::empty_text_has_no_words ... ok
test tests::ignores_case_and_punctuation ... ok
test tests::merge_adds_counts ... ok
test tests::top_breaks_ties_alphabetically ... ok
test result: ok. 4 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00sRunning unittests src/lib.rs 가 라이브러리의 테스트다. 그 아래로 main.rs 의 테스트(0개)와 문서 테스트(0개)도 돌지만 비어 있다.
06 여기까지
47편으로 설치부터 소유권·구조체·에러 처리·트레이트·동시성·async 까지 왔다. 다음에 읽을 것은 공식 입문서 The Rust Programming Language 의 뒷부분(고급 트레이트·매크로·unsafe)과, 직접 만들고 싶은 도구다. wordfreq 에 --top 20 같은 옵션을 붙이는 것부터 시작해도 좋다. 모르는 오류가 나오면 2편의 세 군데(무엇·어디·고치는 법)부터 읽는다.