hyunsu blog

홈강좌Rust 입문·중급 47편 중 47편째

Rust 실습 — wordfreq 2판: 라이브러리·반복자·스레드·테스트

목차 · 6
  1. 01무엇이 달라지나
  2. 02lib.rs — 세는 일만 한다
  3. 03main.rs — 파일마다 스레드
  4. 04돌려 본다
  5. 05테스트
  6. 06여기까지

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::… 로 쓴다. 세는 일과 입출력이 나뉘어, 세는 부분만 따로 테스트할 수 있다.

rust
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 — 파일마다 스레드

rust
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 로 두고 둘을 함께 센다.

text
   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 이 이번에는 알파벳 순으로 섰다. 없는 파일이 섞이면 그 이유로 멈춘다.

text
   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 끝에 붙인 테스트 넷이다. 대소문자·문장 부호 무시, 합치기, 동점 정렬, 빈 글을 확인한다.

rust
#[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());
    }
}
text
     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.00s

Running unittests src/lib.rs 가 라이브러리의 테스트다. 그 아래로 main.rs 의 테스트(0개)와 문서 테스트(0개)도 돌지만 비어 있다.

06 여기까지

47편으로 설치부터 소유권·구조체·에러 처리·트레이트·동시성·async 까지 왔다. 다음에 읽을 것은 공식 입문서 The Rust Programming Language 의 뒷부분(고급 트레이트·매크로·unsafe)과, 직접 만들고 싶은 도구다. wordfreq 에 --top 20 같은 옵션을 붙이는 것부터 시작해도 좋다. 모르는 오류가 나오면 2편의 세 군데(무엇·어디·고치는 법)부터 읽는다.