I/O 프로젝트 개선하기
이제 반복자에 대한 새로운 지식을 얻었으니, 12장의 I/O 프로젝트도 반복자를 사용해
더 명확하고 더 간결하게 개선할 수 있습니다. 여기서는 반복자가 Config::build
함수와 search 함수 구현을 어떻게 개선하는지 살펴보겠습니다.
반복자로 clone 제거하기
목록 12-6에서는 String 값 슬라이스를 받아, 특정 인덱스에 접근한 뒤 값을 clone
해 Config 구조체 인스턴스를 만드는 코드를 작성했습니다. 목록 13-17에는, 목록
12-23 시점의 Config::build 구현을 그대로 다시 적어 두었습니다.
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(())
}
Config::build 함수 다시 보기그때는 비효율적인 clone 호출은 지금 걱정하지 말고, 나중에 제거하겠다고 했습니다.
이제 바로 그 “나중” 입니다!
여기서 clone 이 필요했던 이유는, args 매개변수가 String 값들의 슬라이스였고,
build 함수는 그 슬라이스 자체의 소유권을 갖지 않았기 때문입니다. 그래서
Config 인스턴스의 소유권으로 값을 넘겨 주려면 query 와 file_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 도 아직 바꾸지 않았으므로, 이 상태에서는 컴파일되지 않습니다.
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(())
}
env::args 의 반환값을 그대로 Config::build 에 넘기기env::args 함수는 반복자를 반환합니다! 이제는 그 반복자를 벡터로 모은 뒤 슬라이스를
Config::build 로 넘기지 않고, env::args 가 반환한 반복자 소유권을 바로
Config::build 에 넘깁니다.
이제 Config::build 정의도 바꿔야 합니다. 시그니처를 목록 13-19처럼 수정합시다.
물론 함수 본문도 함께 바꿔야 하므로 아직은 컴파일되지 않습니다.
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(())
}
Config::build 시그니처를 반복자를 받도록 바꾸기표준 라이브러리의 env::args 문서를 보면, 이 함수가 반환하는 반복자 타입은
std::env::Args 이고, 이 타입은 Iterator 트레이트를 구현하며 String 값을
반환합니다.
우리는 Config::build 시그니처를 바꾸어, args 매개변수가 &[String] 대신
impl Iterator<Item = String> 라는 트레이트 바운드를 가진 제네릭 타입이 되도록
했습니다. 이는 10장의 “트레이트를 매개변수로 사용하기”
절에서 설명한 impl Trait 문법입니다. 즉, args 는 Iterator 트레이트를 구현하고
String 항목을 반환하는 어떤 타입이든 될 수 있다는 뜻입니다.
우리는 args 의 소유권을 가져오고, 실제로 순회하면서 그 내부 상태를 바꿀 것이므로,
매개변수 선언에 mut 키워드를 붙여 args 를 가변으로 만들었습니다.
인덱싱 대신 Iterator 트레이트 메서드 사용하기
이제 Config::build 본문을 고쳐 봅시다. args 가 Iterator 를 구현하므로,
우리는 next 메서드를 호출할 수 있다는 것을 알고 있습니다. 목록 13-20은 목록
12-23의 코드를 next 메서드를 사용하도록 바꾼 예입니다.
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(())
}
Config::build 본문을 반복자 메서드 사용 방식으로 바꾸기env::args 의 첫 번째 값은 프로그램 이름이라는 점을 기억하세요. 우리는 이것은
무시하고 그 다음 값부터 쓰고 싶습니다. 그래서 먼저 next 를 한 번 호출하되 반환값은
버리고, 그 다음 query 필드에 넣을 값을 얻기 위해 다시 next 를 호출합니다.
next 가 Some 을 반환하면 match 로 안의 값을 꺼내 쓰고, None 이면 인수가
부족하다는 뜻이므로 Err 값을 조기 반환합니다. file_path 값에 대해서도 같은
방식을 반복합니다.
반복자 어댑터로 코드 더 명확하게 만들기
우리 I/O 프로젝트 안의 search 함수도 반복자를 사용해 개선할 수 있습니다.
목록 13-21은 목록 12-19 시점 그대로의 search 구현을 다시 보여 줍니다.
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));
}
}
search 함수 구현이 코드는 반복자 어댑터 메서드를 사용하면 훨씬 더 간결하게 쓸 수 있습니다. 그렇게 하면
중간에 가변 results 벡터를 따로 둘 필요도 없어집니다. 함수형 스타일은 코드를 더
명확하게 만들기 위해 가변 상태를 최소화하는 것을 선호합니다. 가변 상태를 제거하면,
나중에 검색을 병렬로 실행하고 싶을 때 results 벡터에 대한 동시 접근을 관리할
필요가 없어진다는 점에서도 도움이 됩니다. 목록 13-22를 보세요.
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)
);
}
}
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 프로그래머는 반복자 스타일을 선호합니다. 처음에는 조금 익숙해지는 데 시간이 걸리지만, 다양한 반복자 어댑터가 무엇을 하고 어떻게 조합되는지 감이 잡히면, 반복자가 오히려 이해하기 더 쉬워집니다. 루프 인덱스와 중간 벡터를 이리저리 다루는 대신, 코드는 “무엇을 하고 싶은가” 라는 더 높은 수준의 목표에 집중하게 됩니다. 이렇게 하면 흔한 반복 코드는 추상화 뒤로 숨겨지고, 대신 이 코드만의 핵심 개념 예를 들어 “각 요소가 어떤 조건을 통과해야 하는가” 가 더 잘 드러납니다.
하지만 두 구현이 성능까지도 정말 비슷할까요? 직관적으로는 “더 저수준인 루프가 더 빠르지 않을까?” 라는 생각이 들 수 있습니다. 이제 성능 이야기를 해 봅시다.