I/O 프로젝트 개선하기

이제 이터레이터에 대해 배운 새로운 지식을 활용해 12장의 I/O 프로젝트를 개선할 수 있다. 이터레이터를 사용하면 코드를 더 명확하고 간결하게 만들 수 있다. 이제 Config::build 함수와 search 함수의 구현을 어떻게 개선할 수 있는지 살펴보자.

clone 제거하기: 이터레이터 활용

리스트 12-6에서는 String 값들의 슬라이스를 가져와 Config 구조체의 인스턴스를 생성했다. 이때 슬라이스의 특정 인덱스에 접근해 값을 복제(clone)하는 방식을 사용했는데, 이는 Config 구조체가 해당 값들의 소유권을 가지기 위함이었다. 리스트 13-17에서는 리스트 12-23의 Config::build 함수 구현을 그대로 재현했다.

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

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

impl Config {
    pub 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,
        })
    }
}

pub 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(())
}

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
}

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-17: 리스트 12-23의 Config::build 함수 재현

당시에는 비효율적인 clone 호출에 대해 걱정하지 말라고 했는데, 그 이유는 나중에 이를 제거할 계획이었기 때문이다. 이제 그때가 왔다!

clone이 필요했던 이유는 args 파라미터에 String 요소를 가진 슬라이스가 있었지만, build 함수가 args의 소유권을 가지지 않기 때문이다. Config 인스턴스의 소유권을 반환하려면 Configqueryfile_path 필드의 값을 복제해야 했다. 이렇게 해야 Config 인스턴스가 자신의 값을 소유할 수 있었다.

이제 이터레이터에 대한 새로운 지식을 활용해 build 함수를 수정할 수 있다. 슬라이스를 빌려오는 대신 이터레이터를 인자로 받아 소유권을 가지도록 변경한다. 슬라이스의 길이를 확인하고 특정 위치에 접근하는 코드 대신 이터레이터 기능을 사용할 것이다. 이렇게 하면 Config::build 함수가 무엇을 하는지 더 명확해진다. 이터레이터가 값을 직접 접근하기 때문이다.

Config::build가 이터레이터의 소유권을 가지고, 빌려오는 인덱스 연산을 더 이상 사용하지 않으면, clone을 호출해 새로운 메모리를 할당하는 대신 이터레이터에서 String 값을 Config로 바로 이동시킬 수 있다.

반환된 이터레이터 직접 사용하기

I/O 프로젝트의 src/main.rs 파일을 열면 다음과 같은 코드가 보일 것이다:

파일명: src/main.rs

use std::env;
use std::process;

use minigrep::Config;

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) = minigrep::run(config) {
        eprintln!("Application error: {e}");
        process::exit(1);
    }
}

먼저 리스팅 12-24에서 작성한 main 함수의 시작 부분을 리스팅 13-18의 코드로 변경한다. 이번에는 이터레이터를 사용한다. Config::build도 업데이트하기 전까지는 컴파일되지 않는다.

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

use minigrep::Config;

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) = minigrep::run(config) {
        eprintln!("Application error: {e}");
        process::exit(1);
    }
}
Listing 13-18: env::args의 반환 값을 Config::build에 전달

env::args 함수는 이터레이터를 반환한다! 이터레이터 값을 벡터로 모아서 슬라이스를 Config::build에 전달하는 대신, 이제는 env::args에서 반환된 이터레이터의 소유권을 직접 Config::build에 전달한다.

다음으로 Config::build의 정의를 업데이트해야 한다. I/O 프로젝트의 src/lib.rs 파일에서 Config::build의 시그니처를 리스팅 13-19와 같이 변경한다. 함수 본문도 업데이트해야 하기 때문에 아직 컴파일되지 않는다.

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

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

impl Config {
    pub 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,
        })
    }
}

pub 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(())
}

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
}

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-19: 이터레이터를 기대하도록 Config::build의 시그니처 업데이트

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

Config::build 함수의 시그니처를 업데이트하여 args 매개변수가 &[String] 대신 impl Iterator<Item = String> 트레잇 바운드를 가진 제네릭 타입을 갖도록 했다. “트레잇을 매개변수로 사용하기” 섹션에서 논의한 impl Trait 문법을 사용하면, argsIterator 트레잇을 구현하고 String 아이템을 반환하는 어떤 타입이든 될 수 있다.

args의 소유권을 가져오고, 이터레이터를 통해 args를 변경할 것이기 때문에, args 매개변수에 mut 키워드를 추가하여 가변적으로 만들 수 있다.

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

이제 Config::build 함수의 본문을 수정한다. argsIterator 트레이트를 구현하기 때문에 next 메서드를 호출할 수 있다. 리스팅 13-20은 리스팅 12-23의 코드를 업데이트하여 next 메서드를 사용한다.

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

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

impl Config {
    pub 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,
        })
    }
}

pub 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(())
}

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
}

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-20: Config::build의 본문을 iterator 메서드를 사용하도록 변경

env::args의 반환값에서 첫 번째 값은 프로그램 이름이다. 이 값을 무시하고 다음 값으로 넘어가기 위해 먼저 next를 호출하고 반환값은 사용하지 않는다. 그런 다음 next를 다시 호출해 Configquery 필드에 넣을 값을 가져온다. nextSome을 반환하면 match를 사용해 값을 추출한다. None을 반환하면 충분한 인자가 제공되지 않았다는 의미이므로 Err 값을 반환하고 함수를 종료한다. file_path 값에 대해서도 동일한 작업을 수행한다.

이터레이터 어댑터로 코드를 명확하게 만들기

우리는 I/O 프로젝트의 search 함수에서도 이터레이터를 활용할 수 있다. 이 함수는 12장의 리스트 12-19와 동일하게 리스트 13-21에 재현되어 있다:

Filename: src/lib.rs
use std::error::Error;
use std::fs;

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

impl Config {
    pub 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();

        Ok(Config { query, file_path })
    }
}

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

    Ok(())
}

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
use std::env;
use std::error::Error;
use std::fs;

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

impl Config {
    pub 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,
        })
    }
}

pub 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(())
}

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 함수에서도 동일한 변경을 적용해 이터레이터 메서드를 사용해 보자.

루프와 이터레이터 중 선택하기

다음으로 자연스럽게 떠오르는 질문은 여러분의 코드에서 어떤 스타일을 선택해야 하는지와 그 이유이다. 리스트 13-21의 원래 구현과 리스트 13-22의 이터레이터를 사용한 버전 중 어떤 것을 선택할 것인가? 대부분의 Rust 프로그래머는 이터레이터 스타일을 선호한다. 처음에는 익숙해지기 어려울 수 있지만, 다양한 이터레이터 어댑터와 그 기능을 이해하면 이터레이터가 더 쉽게 이해될 수 있다. 루프의 다양한 부분을 다루고 새로운 벡터를 만드는 대신, 코드는 루프의 상위 목표에 집중한다. 이는 일반적인 코드를 추상화하여 이터레이터의 각 요소가 통과해야 하는 필터링 조건과 같은 이 코드에만 해당하는 개념을 더 쉽게 파악할 수 있도록 한다.

하지만 두 구현이 정말 동등한가? 직관적으로는 더 낮은 수준의 루프가 더 빠를 것이라고 가정할 수 있다. 이제 성능에 대해 이야기해보자.