Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

I/O 프로젝트 개선하기

이제 반복자에 대한 새로운 지식을 얻었으니, 12장의 I/O 프로젝트도 반복자를 사용해 더 명확하고 더 간결하게 개선할 수 있습니다. 여기서는 반복자가 Config::build 함수와 search 함수 구현을 어떻게 개선하는지 살펴보겠습니다.

반복자로 clone 제거하기

목록 12-6에서는 String 값 슬라이스를 받아, 특정 인덱스에 접근한 뒤 값을 cloneConfig 구조체 인스턴스를 만드는 코드를 작성했습니다. 목록 13-17에는, 목록 12-23 시점의 Config::build 구현을 그대로 다시 적어 두었습니다.

Filename: src/main.rs
use std::env;
use std::error::Error;
use std::fs;
use std::process;

use minigrep::{search, search_case_insensitive};

fn main() {
    let args: Vec<String> = env::args().collect();

    let config = Config::build(&args).unwrap_or_else(|err| {
        println!("Problem parsing arguments: {err}");
        process::exit(1);
    });

    if let Err(e) = run(config) {
        println!("Application error: {e}");
        process::exit(1);
    }
}

pub struct Config {
    pub query: String,
    pub file_path: String,
    pub ignore_case: bool,
}

impl Config {
    fn build(args: &[String]) -> Result<Config, &'static str> {
        if args.len() < 3 {
            return Err("not enough arguments");
        }

        let query = args[1].clone();
        let file_path = args[2].clone();

        let ignore_case = env::var("IGNORE_CASE").is_ok();

        Ok(Config {
            query,
            file_path,
            ignore_case,
        })
    }
}

fn run(config: Config) -> Result<(), Box<dyn Error>> {
    let contents = fs::read_to_string(config.file_path)?;

    let results = if config.ignore_case {
        search_case_insensitive(&config.query, &contents)
    } else {
        search(&config.query, &contents)
    };

    for line in results {
        println!("{line}");
    }

    Ok(())
}
Listing 13-17: 목록 12-23의 Config::build 함수 다시 보기

그때는 비효율적인 clone 호출은 지금 걱정하지 말고, 나중에 제거하겠다고 했습니다. 이제 바로 그 “나중” 입니다!

여기서 clone 이 필요했던 이유는, args 매개변수가 String 값들의 슬라이스였고, build 함수는 그 슬라이스 자체의 소유권을 갖지 않았기 때문입니다. 그래서 Config 인스턴스의 소유권으로 값을 넘겨 주려면 queryfile_path 값을 복사해 새 할당을 해야 했습니다.

하지만 이제 반복자를 알게 되었으므로, build 함수가 슬라이스를 빌리는 대신 반복자의 소유권을 매개변수로 받게 만들 수 있습니다. 그러면 슬라이스 길이를 검사하고 특정 위치를 인덱싱하던 코드를 반복자 기능으로 바꿀 수 있고, Config::build 가 실제로 무엇을 하는지 더 잘 드러납니다.

한 번 Config::build 가 반복자의 소유권을 가져가고 더 이상 인덱싱으로 값을 빌리지 않게 되면, clone 으로 새 할당을 만들지 않고 반복자 안의 String 값 자체를 Config 안으로 이동시킬 수 있습니다.

반환된 반복자를 직접 사용하기

먼저 I/O 프로젝트의 src/main.rs 파일을 열면 현재 내용은 대략 이렇습니다.

파일명: src/main.rs

use std::env;
use std::error::Error;
use std::fs;
use std::process;

use minigrep::{search, search_case_insensitive};

fn main() {
    let args: Vec<String> = env::args().collect();

    let config = Config::build(&args).unwrap_or_else(|err| {
        eprintln!("Problem parsing arguments: {err}");
        process::exit(1);
    });

    // --snip--

    if let Err(e) = run(config) {
        eprintln!("Application error: {e}");
        process::exit(1);
    }
}

pub struct Config {
    pub query: String,
    pub file_path: String,
    pub ignore_case: bool,
}

impl Config {
    fn build(args: &[String]) -> Result<Config, &'static str> {
        if args.len() < 3 {
            return Err("not enough arguments");
        }

        let query = args[1].clone();
        let file_path = args[2].clone();

        let ignore_case = env::var("IGNORE_CASE").is_ok();

        Ok(Config {
            query,
            file_path,
            ignore_case,
        })
    }
}

fn run(config: Config) -> Result<(), Box<dyn Error>> {
    let contents = fs::read_to_string(config.file_path)?;

    let results = if config.ignore_case {
        search_case_insensitive(&config.query, &contents)
    } else {
        search(&config.query, &contents)
    };

    for line in results {
        println!("{line}");
    }

    Ok(())
}

우선 목록 12-24의 main 함수 앞부분을 목록 13-18의 코드처럼 바꾸겠습니다. 이번에는 env::args 가 반환한 반복자를 그대로 Config::build 에 넘깁니다. 물론 Config::build 도 아직 바꾸지 않았으므로, 이 상태에서는 컴파일되지 않습니다.

Filename: src/main.rs
use std::env;
use std::error::Error;
use std::fs;
use std::process;

use minigrep::{search, search_case_insensitive};

fn main() {
    let config = Config::build(env::args()).unwrap_or_else(|err| {
        eprintln!("Problem parsing arguments: {err}");
        process::exit(1);
    });

    // --snip--

    if let Err(e) = run(config) {
        eprintln!("Application error: {e}");
        process::exit(1);
    }
}

pub struct Config {
    pub query: String,
    pub file_path: String,
    pub ignore_case: bool,
}

impl Config {
    fn build(args: &[String]) -> Result<Config, &'static str> {
        if args.len() < 3 {
            return Err("not enough arguments");
        }

        let query = args[1].clone();
        let file_path = args[2].clone();

        let ignore_case = env::var("IGNORE_CASE").is_ok();

        Ok(Config {
            query,
            file_path,
            ignore_case,
        })
    }
}

fn run(config: Config) -> Result<(), Box<dyn Error>> {
    let contents = fs::read_to_string(config.file_path)?;

    let results = if config.ignore_case {
        search_case_insensitive(&config.query, &contents)
    } else {
        search(&config.query, &contents)
    };

    for line in results {
        println!("{line}");
    }

    Ok(())
}
Listing 13-18: env::args 의 반환값을 그대로 Config::build 에 넘기기

env::args 함수는 반복자를 반환합니다! 이제는 그 반복자를 벡터로 모은 뒤 슬라이스를 Config::build 로 넘기지 않고, env::args 가 반환한 반복자 소유권을 바로 Config::build 에 넘깁니다.

이제 Config::build 정의도 바꿔야 합니다. 시그니처를 목록 13-19처럼 수정합시다. 물론 함수 본문도 함께 바꿔야 하므로 아직은 컴파일되지 않습니다.

Filename: src/main.rs
use std::env;
use std::error::Error;
use std::fs;
use std::process;

use minigrep::{search, search_case_insensitive};

fn main() {
    let config = Config::build(env::args()).unwrap_or_else(|err| {
        eprintln!("Problem parsing arguments: {err}");
        process::exit(1);
    });

    if let Err(e) = run(config) {
        eprintln!("Application error: {e}");
        process::exit(1);
    }
}

pub struct Config {
    pub query: String,
    pub file_path: String,
    pub ignore_case: bool,
}

impl Config {
    fn build(
        mut args: impl Iterator<Item = String>,
    ) -> Result<Config, &'static str> {
        // --snip--
        if args.len() < 3 {
            return Err("not enough arguments");
        }

        let query = args[1].clone();
        let file_path = args[2].clone();

        let ignore_case = env::var("IGNORE_CASE").is_ok();

        Ok(Config {
            query,
            file_path,
            ignore_case,
        })
    }
}

fn run(config: Config) -> Result<(), Box<dyn Error>> {
    let contents = fs::read_to_string(config.file_path)?;

    let results = if config.ignore_case {
        search_case_insensitive(&config.query, &contents)
    } else {
        search(&config.query, &contents)
    };

    for line in results {
        println!("{line}");
    }

    Ok(())
}
Listing 13-19: Config::build 시그니처를 반복자를 받도록 바꾸기

표준 라이브러리의 env::args 문서를 보면, 이 함수가 반환하는 반복자 타입은 std::env::Args 이고, 이 타입은 Iterator 트레이트를 구현하며 String 값을 반환합니다.

우리는 Config::build 시그니처를 바꾸어, args 매개변수가 &[String] 대신 impl Iterator<Item = String> 라는 트레이트 바운드를 가진 제네릭 타입이 되도록 했습니다. 이는 10장의 “트레이트를 매개변수로 사용하기” 절에서 설명한 impl Trait 문법입니다. 즉, argsIterator 트레이트를 구현하고 String 항목을 반환하는 어떤 타입이든 될 수 있다는 뜻입니다.

우리는 args 의 소유권을 가져오고, 실제로 순회하면서 그 내부 상태를 바꿀 것이므로, 매개변수 선언에 mut 키워드를 붙여 args 를 가변으로 만들었습니다.

인덱싱 대신 Iterator 트레이트 메서드 사용하기

이제 Config::build 본문을 고쳐 봅시다. argsIterator 를 구현하므로, 우리는 next 메서드를 호출할 수 있다는 것을 알고 있습니다. 목록 13-20은 목록 12-23의 코드를 next 메서드를 사용하도록 바꾼 예입니다.

Filename: src/main.rs
use std::env;
use std::error::Error;
use std::fs;
use std::process;

use minigrep::{search, search_case_insensitive};

fn main() {
    let config = Config::build(env::args()).unwrap_or_else(|err| {
        eprintln!("Problem parsing arguments: {err}");
        process::exit(1);
    });

    if let Err(e) = run(config) {
        eprintln!("Application error: {e}");
        process::exit(1);
    }
}

pub struct Config {
    pub query: String,
    pub file_path: String,
    pub ignore_case: bool,
}

impl Config {
    fn build(
        mut args: impl Iterator<Item = String>,
    ) -> Result<Config, &'static str> {
        args.next();

        let query = match args.next() {
            Some(arg) => arg,
            None => return Err("Didn't get a query string"),
        };

        let file_path = match args.next() {
            Some(arg) => arg,
            None => return Err("Didn't get a file path"),
        };

        let ignore_case = env::var("IGNORE_CASE").is_ok();

        Ok(Config {
            query,
            file_path,
            ignore_case,
        })
    }
}

fn run(config: Config) -> Result<(), Box<dyn Error>> {
    let contents = fs::read_to_string(config.file_path)?;

    let results = if config.ignore_case {
        search_case_insensitive(&config.query, &contents)
    } else {
        search(&config.query, &contents)
    };

    for line in results {
        println!("{line}");
    }

    Ok(())
}
Listing 13-20: Config::build 본문을 반복자 메서드 사용 방식으로 바꾸기

env::args 의 첫 번째 값은 프로그램 이름이라는 점을 기억하세요. 우리는 이것은 무시하고 그 다음 값부터 쓰고 싶습니다. 그래서 먼저 next 를 한 번 호출하되 반환값은 버리고, 그 다음 query 필드에 넣을 값을 얻기 위해 다시 next 를 호출합니다. nextSome 을 반환하면 match 로 안의 값을 꺼내 쓰고, None 이면 인수가 부족하다는 뜻이므로 Err 값을 조기 반환합니다. file_path 값에 대해서도 같은 방식을 반복합니다.

반복자 어댑터로 코드 더 명확하게 만들기

우리 I/O 프로젝트 안의 search 함수도 반복자를 사용해 개선할 수 있습니다. 목록 13-21은 목록 12-19 시점 그대로의 search 구현을 다시 보여 줍니다.

Filename: src/lib.rs
pub fn search<'a>(query: &str, contents: &'a str) -> Vec<&'a str> {
    let mut results = Vec::new();

    for line in contents.lines() {
        if line.contains(query) {
            results.push(line);
        }
    }

    results
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn one_result() {
        let query = "duct";
        let contents = "\
Rust:
safe, fast, productive.
Pick three.";

        assert_eq!(vec!["safe, fast, productive."], search(query, contents));
    }
}
Listing 13-21: 목록 12-19의 search 함수 구현

이 코드는 반복자 어댑터 메서드를 사용하면 훨씬 더 간결하게 쓸 수 있습니다. 그렇게 하면 중간에 가변 results 벡터를 따로 둘 필요도 없어집니다. 함수형 스타일은 코드를 더 명확하게 만들기 위해 가변 상태를 최소화하는 것을 선호합니다. 가변 상태를 제거하면, 나중에 검색을 병렬로 실행하고 싶을 때 results 벡터에 대한 동시 접근을 관리할 필요가 없어진다는 점에서도 도움이 됩니다. 목록 13-22를 보세요.

Filename: src/lib.rs
pub fn search<'a>(query: &str, contents: &'a str) -> Vec<&'a str> {
    contents
        .lines()
        .filter(|line| line.contains(query))
        .collect()
}

pub fn search_case_insensitive<'a>(
    query: &str,
    contents: &'a str,
) -> Vec<&'a str> {
    let query = query.to_lowercase();
    let mut results = Vec::new();

    for line in contents.lines() {
        if line.to_lowercase().contains(&query) {
            results.push(line);
        }
    }

    results
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn case_sensitive() {
        let query = "duct";
        let contents = "\
Rust:
safe, fast, productive.
Pick three.
Duct tape.";

        assert_eq!(vec!["safe, fast, productive."], search(query, contents));
    }

    #[test]
    fn case_insensitive() {
        let query = "rUsT";
        let contents = "\
Rust:
safe, fast, productive.
Pick three.
Trust me.";

        assert_eq!(
            vec!["Rust:", "Trust me."],
            search_case_insensitive(query, contents)
        );
    }
}
Listing 13-22: search 구현에 반복자 어댑터 메서드 사용하기

search 함수의 목적은 contents 안에서 query 를 포함하는 모든 줄을 반환하는 것입니다. 목록 13-16의 filter 예제와 비슷하게, 이 코드는 filter 어댑터를 사용해 line.contains(query)true 인 줄만 남깁니다. 그런 뒤 collect 로 그 매칭 줄들을 새 벡터에 모읍니다. 훨씬 더 단순하지요! 원한다면 search_case_insensitive 함수에도 같은 변화를 적용해 보세요.

한 단계 더 나아가, search 함수에서 collect 호출을 제거하고 반환 타입을 impl Iterator<Item = &'a str> 로 바꾸면 반복자 자체를 반환할 수도 있습니다. 그러면 테스트도 함께 수정해야 합니다! 이 변경 전과 후에 큰 파일을 minigrep 로 검색해 보며 동작 차이를 관찰해 보세요. 변경 전에는 모든 결과를 다 모을 때까지 아무 것도 출력하지 않지만, 변경 후에는 run 함수의 for 루프가 반복자의 게으른 특성을 활용해, 매칭 줄을 찾자마자 바로 출력할 수 있습니다.

루프와 반복자 중 무엇을 선택할까

그렇다면 자연스럽게 드는 질문은, 여러분 자신의 코드에서는 어떤 스타일을 택해야 하고 왜 그런가 하는 점입니다. 목록 13-21의 원래 구현처럼 명시적인 for 루프를 쓸까요, 아니면 목록 13-22의 반복자 스타일을 쓸까요(여기서는 반복자를 반환하는 대신, 모든 결과를 모아 반환한다고 가정합니다)?

대부분의 Rust 프로그래머는 반복자 스타일을 선호합니다. 처음에는 조금 익숙해지는 데 시간이 걸리지만, 다양한 반복자 어댑터가 무엇을 하고 어떻게 조합되는지 감이 잡히면, 반복자가 오히려 이해하기 더 쉬워집니다. 루프 인덱스와 중간 벡터를 이리저리 다루는 대신, 코드는 “무엇을 하고 싶은가” 라는 더 높은 수준의 목표에 집중하게 됩니다. 이렇게 하면 흔한 반복 코드는 추상화 뒤로 숨겨지고, 대신 이 코드만의 핵심 개념 예를 들어 “각 요소가 어떤 조건을 통과해야 하는가” 가 더 잘 드러납니다.

하지만 두 구현이 성능까지도 정말 비슷할까요? 직관적으로는 “더 저수준인 루프가 더 빠르지 않을까?” 라는 생각이 들 수 있습니다. 이제 성능 이야기를 해 봅시다.