I/O 프로젝트 개선하기

I/O 프로젝트 개선하기 (Improving Our I/O Project)

반복자에 대한 새 지식을 바탕으로 12장의 I/O 프로젝트를 개선해 볼게요. 반복자를 사용해 코드에서 더 명확하고 간결해질 부분을 손보는 거예요. Config::build 함수와 search 함수의 구현을 반복자가 어떻게 개선해 주는지 함께 볼게요.

출처: The Rust Book

반복자로 clone 제거하기 (Removing a clone Using an Iterator)

Listing 12-6에서 String 값들의 슬라이스를 받아 그 슬라이스를 인덱싱하고 값을 clone해서 Config 구조체 인스턴스를 만드는 코드를 추가했었죠. 그 덕분에 Config 구조체가 그 값들을 소유할 수 있었어요. Listing 13-17에 Listing 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: Listing 12-23의 Config::build 함수 재현

그때는 비효율적인 clone 호출에 신경 쓰지 말라고 했죠. 언젠가 제거할 거라고요. 그 언젠가가 바로 지금이에요!

여기서 clone이 필요했던 이유는 매개변수 argsString 요소들의 슬라이스가 있는데, build 함수는 args를 소유하지 않기 때문이에요. Config 인스턴스의 소유권을 반환하려면 queryfile_path 필드의 값들을 clone해서 Config 인스턴스가 자기 값을 소유하게 해야 했죠.

반복자에 대한 새 지식으로 build 함수가 슬라이스를 빌리는 대신, 인자로 반복자의 소유권을 받도록 바꿀 수 있어요. 슬라이스의 길이를 확인하고 특정 위치를 인덱싱하는 코드 대신 반복자 기능을 쓸 거예요. 그러면 Config::build 함수가 무엇을 하는지 더 명확해져요. 반복자가 값을 접근할 테니까요.

Config::build가 반복자의 소유권을 받아 빌리는 인덱싱 연산을 쓰지 않게 되면, clone을 호출해 새 할당을 만드는 대신 반복자에서 String 값들을 Config이동시킬 수 있어요.

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

I/O 프로젝트의 src/main.rs 파일을 열어 보세요. 이렇게 생겼을 거예요:

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| {
        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 12-24에 있던 main 함수의 시작 부분을 이번에는 반복자를 쓰는 Listing 13-18의 코드로 바꿀게요. 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의 정의를 수정해야 해요. Config::build의 시그니처를 Listing 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의 소유권을 받고 반복하면서 args를 변형할 것이므로, args 매개변수 지정에 mut 키워드를 추가해 가변으로 만들 수 있어요.

Iterator 트레이트 메서드 사용하기

이제 Config::build의 본문을 고칠 차례예요. argsIterator 트레이트를 구현하므로 그 위에서 next 메서드를 호출할 수 있다는 걸 알 수 있어요! Listing 13-20은 Listing 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를 호출하고 반환 값을 사용하지 않아요. 그런 다음 next를 호출해 Configquery 필드에 넣을 값을 얻어요. nextSome을 반환하면 match로 값을 추출하고, None을 반환하면 인자가 충분히 주어지지 않았다는 뜻이므로 Err 값을 갖고 조기 반환해요. file_path 값에도 같은 작업을 해요.

반복자 어댑터로 코드 명확하게 하기 (Clarifying Code with Iterator Adapters)

I/O 프로젝트의 search 함수에서도 반복자 이점을 살릴 수 있어요. 이 함수는 Listing 12-19 당시의 모습 그대로 Listing 13-21에 재현돼 있어요.

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: Listing 12-19의 search 함수 구현

이 코드는 반복자 어댑터 메서드를 사용해 더 간결하게 쓸 수 있어요. 그렇게 하면 가변 중간 results 벡터도 피할 수 있고요. 함수형 프로그래밍 스타일은 가변 상태의 양을 최소화하는 쪽을 선호해 코드를 더 명확하게 만들어요. 가변 상태를 제거하면 results 벡터에 대한 동시 접근을 관리할 필요가 없어서, 미래에 검색을 병렬로 수행하는 개선도 가능해질 수 있어요. Listing 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 함수의 목적이 query를 포함하는 contents의 모든 줄을 반환하는 것이라는 점을 기억하세요. Listing 13-16의 filter 예시와 비슷하게, 이 코드는 filter 어댑터를 사용해 line.contains(query)true를 반환하는 줄만 남겨요. 그런 다음 일치하는 줄들을 collect로 다른 벡터에 모아요. 훨씬 간단하죠! search_case_insensitive 함수에서도 같은 방식으로 반복자 메서드를 쓰도록 바꿔 봐도 좋아요.

더 나아가 collect 호출을 제거하고 반환 타입을 impl Iterator<Item = &'a str>로 바꾸면 search 함수가 반복자를 반환하는 iterator adapter가 돼요. 그러면 테스트도 수정해야 한다는 점을 잊지 마세요! 이 변경 전후로 minigrep 도구로 큰 파일을 검색해 보면서 동작 차이를 관찰해 봐요. 변경 전에는 모든 결과를 수집한 뒤에야 프로그램이 결과를 출력하지만, 변경 후에는 run 함수의 for 루프가 반복자의 lazy함을 활용할 수 있어서 일치하는 줄이 발견될 때마다 결과가 출력돼요.

루프와 반복자 사이의 선택 (Choosing Between Loops and Iterators)

다음으로 자연스러운 질문은 어떤 스타일을 선택할 것인가예요. Listing 13-21의 원래 구현인가, 아니면 Listing 13-22의 반복자 버전인가(반복자를 반환하지 않고 모든 결과를 모아 반환한다고 가정할 때). 대부분의 러스트 프로그래머는 반복자 스타일을 선호해요. 처음에는 익숙해지기 조금 어렵지만, 다양한 반복자 어댑터와 그 역할에 감을 잡고 나면 오히려 이해하기 더 쉬워져요. 루프의 여러 조각을 만지작거리며 새 벡터를 만드는 대신, 코드가 루프의 높은 수준 목표에 집중하게 되죠. 이렇게 하면 평범한 코드 중 일부가 추상화되어, 반복자의 각 요소가 통과해야 하는 필터링 조건 같은 이 코드 고유의 개념들이 더 잘 보여요.

그런데 두 구현은 정말로 동등할까요? 직관적으로는 더 낮은 수준의 루프가 더 빠를 거라고 생각하기 쉬워요. 성능에 대해 이야기해 보죠.

더 알아보기 (Learn more)