I/O 프로젝트 개선
minigrep의 불필요한 clone과 인덱싱을 반복자 체인으로 바꾸고 filter·collect로 검색 로직을 간결하게 리팩터링합니다.
반복자를 새로 알게 되었으니, 11장의 I/O 프로젝트에 반복자를 써서 코드를 더 분명하고 간결하게 고칠 수 있습니다. 반복자로 Config::build 함수와 search 함수의 구현을 어떻게 개선할 수 있는지 봅니다. 아래 그림은 이 글에서 할 두 리팩터링을 나란히 보여 줍니다.
RUST · MINIGREP REFACTOR
인수는 소유해 옮기고 검색 결과는 본문을 빌린다
minigrep의 두 리팩터링은 다른 소유권 경계를 가집니다. 본문의 match와 filter·collect에 맞춰 변경 전후를 비교합니다.
인수 · 변경 전
build(args: &[String])
args[1].clone()
args[2].clone()빌린 슬라이스에서 Config가 소유할 query와 file_path를 복제합니다. len 검사와 인덱싱을 사용합니다.
인수 · 변경 후
build(
mut args: impl Iterator<
Item = String
>,
)
args.next(); // 호출 이름 건너뛰기
// query/path: match args.next()Some(String)을 Config 필드로 이동합니다. None이면 본문의 질의어·경로 오류를 반환합니다. 이 두 clone과 임시 args Vec를 없앱니다.
검색 · 변경 전
for line in contents.lines() {
if line.contains(query) {
results.push(line);
}
}일치한 &str을 results에 모읍니다. 원본 contents의 줄을 빌립니다.
검색 · 변경 후
contents.lines()
.filter(|line| line.contains(query))
.collect()같은 순서의 일치한 &str을 Vec<&str>로 모읍니다. 줄 String을 복사하지 않지만 결과 벡터 저장 공간은 필요합니다.
반환 참조의 수명은 contents에 연결됩니다. Config의 IGNORE_CASE 선택과 run의 I/O·오류 처리는 유지됩니다. 반복자 표현으로 바꾸어도 자동으로 병렬화되거나 항상 더 빨라지지는 않습니다. env::args의 비유니코드 입력 경계도 남습니다.
두 리팩터링은 소유권을 다루는 방식이 다릅니다. 위쪽 경로에서는 String을 소유한 인수 반복자에서 호출 이름을 건너뛰고 next로 값을 꺼내, 그 String을 Config의 query와 file_path 필드로 옮깁니다. 아래쪽 경로에서는 호출한 쪽이 소유한 contents를 lines와 filter로 원래 순서대로 검사하고, 일치한 원본 줄의 참조를 Vec<&str>에 모읍니다. 점선은 반환되는 참조가 contents를 빌린다는 것을 나타낼 뿐 실행 순서가 아니며, 두 경로는 서로 이어진 하나의 파이프라인이 아닙니다.
반복자를 사용하여 clone 제거하기
모듈성과 에러 처리 향상을 위한 리팩터링에서는 String 값의 슬라이스를 받는 코드를 더하고, 슬라이스를 인덱싱하고 값을 복제해 Config 구조체의 인스턴스를 만들어 Config 구조체가 그 값을 소유하게 했습니다. 지금의 Config::build 함수는 다음과 같습니다.
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,
})
}
}그때 비효율적인 clone 호출은 나중에 없앨 것이니 걱정하지 말라고 했습니다. 이제 그때가 되었습니다. 여기서 clone이 필요했던 까닭은 매개변수 args가 String 원소의 슬라이스이고, build 함수는 args를 소유하지 않기 때문입니다. Config 인스턴스가 소유권을 갖게 하려면 args의 값을 복제해 Config의 query와 file_path 필드에 넣어야 했습니다.
반복자를 알게 되었으니, 슬라이스를 빌리는 대신 반복자의 소유권을 인수로 받도록 build 함수를 바꿀 수 있습니다. 슬라이스의 길이를 확인하고 특정 위치를 인덱싱하는 코드 대신 반복자의 기능을 씁니다. 그러면 반복자가 값에 접근하므로 Config::build 함수가 무엇을 하는지 더 분명해집니다.
Config::build가 반복자의 소유권을 가져가고, 값을 빌리는 인덱싱을 더는 쓰지 않으면, clone을 부르거나 새로 할당하는 대신 반복자의 String 값을 Config 안으로 옮길 수 있습니다.
반환된 반복자를 직접 사용하기
src/main.rs를 열면 다음 코드가 있습니다. 11장에서 마지막으로 고친 main의 앞부분으로, env::args()가 돌려준 반복자를 collect로 벡터에 모은 뒤 그 벡터의 참조를 Config::build에 넘깁니다.
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);
});
// --생략--
}먼저 main 함수의 시작 부분을 다음처럼 바꿉니다. 이번에는 반복자를 씁니다. Config::build도 고칠 때까지는 이 코드가 컴파일되지 않습니다.
fn main() {
let config = Config::build(env::args()).unwrap_or_else(|err| {
eprintln!("Problem parsing arguments: {err}");
process::exit(1);
});
// --생략--
}env::args 함수는 반복자를 돌려줍니다. 반복자의 값을 벡터로 모은 뒤 Config::build에 슬라이스를 넘기는 대신, 이제는 env::args가 돌려준 반복자의 소유권을 Config::build에 곧바로 넘깁니다.
다음으로 Config::build의 정의를 고칩니다. Config::build의 시그니처를 다음처럼 바꿉니다. 함수 본문을 고쳐야 하므로 아직 컴파일되지 않습니다.
impl Config {
pub fn build(
mut args: impl Iterator<Item = String>,
) -> Result<Config, &'static str> {
// --생략--env::args 함수의 표준 라이브러리 문서를 보면, 이 함수가 돌려주는 반복자의 타입은 std::env::Args이며, 이 타입은 Iterator 트레이트를 구현하고 String 값을 돌려줍니다.
Config::build 함수의 시그니처를 바꿔, 매개변수 args가 &[String] 대신 impl Iterator<Item = String>이라는 트레이트 바운드를 가진 제네릭 타입이 되게 했습니다. 이 impl Trait 문법은 트레이트와 공통 동작의 매개변수로서의 트레이트에서 이야기했으며, args가 Iterator 트레이트를 구현하고 String 원소를 돌려주는 어떤 타입이든 될 수 있다는 뜻입니다.
args의 소유권을 가져가고 args를 돌며 바꿀 것이므로, args 매개변수의 정의에 mut 키워드를 붙여 가변으로 만듭니다.
인덱싱 대신 Iterator 트레이트 메서드 사용하기
다음으로 Config::build의 본문을 고칩니다. args는 Iterator 트레이트를 구현하므로 next 메서드를 부를 수 있습니다. 다음 코드는 앞의 코드를 next 메서드를 쓰도록 고친 것입니다.
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,
})
}
}env::args가 돌려주는 값의 첫 번째는 프로그램의 이름이라는 것을 떠올려 보세요. 이 값은 무시하고 다음 값으로 넘어가야 하므로, 먼저 next를 부르고 그 반환값으로는 아무것도 하지 않습니다. 그다음 next를 불러 Config의 query 필드에 넣을 값을 얻습니다. next가 Some을 돌려주면 match로 그 값을 꺼냅니다. None을 돌려준다면 인수가 충분히 주어지지 않았다는 뜻이므로 Err 값으로 일찍 돌아갑니다. file_path 값도 같은 방식으로 얻습니다.
이제 인수가 모자랄 때의 메시지가 무엇이 빠졌는지 알려 줍니다. 인수 없이 실행하면 Problem parsing arguments: Didn't get a query string이, 검색어만 주면 Problem parsing arguments: Didn't get a file path가 표준 에러에 출력됩니다. 꺼낸 String은 복제하지 않고 Config 필드로 옮기므로 두 clone과, 인수를 모아 두던 임시 벡터가 사라집니다. IGNORE_CASE를 읽는 규칙은 그대로이며, env::args가 유니코드로 올바르지 않은 인수에서 패닉을 낸다는 점도 그대로 남습니다.
반복자 어댑터로 더 간결한 코드 만들기
I/O 프로젝트의 search 함수에도 반복자를 쓸 수 있습니다. 테스트 주도 라이브러리 개발에서 만든 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
}반복자 어댑터 메서드를 쓰면 이 코드를 더 간결하게 쓸 수 있습니다. 그러면 가변인 중간 벡터 results도 없앨 수 있습니다. 함수형 프로그래밍 스타일은 코드를 더 분명하게 하려고 가변 상태를 줄이는 쪽을 선호합니다. 가변 상태를 없애면 나중에 검색을 병렬로 하도록 고치기도 더 쉬워질 수 있습니다. results 벡터에 대한 동시 접근을 관리하지 않아도 되기 때문입니다. 다음은 이렇게 바꾼 코드입니다.
pub fn search<'a>(query: &str, contents: &'a str) -> Vec<&'a str> {
contents
.lines()
.filter(|line| line.contains(query))
.collect()
}search 함수의 목적은 contents에서 query가 든 줄을 모두 돌려주는 것이라는 점을 떠올려 보세요. 반복자 기반 데이터 처리의 filter 예제와 비슷하게, 이 코드는 filter 어댑터로 line.contains(query)가 true를 돌려주는 줄만 남깁니다. 그다음 일치하는 줄을 collect로 다른 벡터에 모읍니다. 훨씬 간단합니다. 그림에서처럼 결과는 원래와 같은 순서의 &str이고, 줄의 String을 복사하지는 않지만 결과 벡터를 담을 공간은 필요합니다. 반환되는 참조의 라이프타임은 여전히 contents와 이어집니다. search_case_insensitive 함수도 반복자 메서드를 쓰도록 같은 방식으로 바꿔 보세요.
더 나아가 collect를 부르지 않고 반복자 자체를 돌려주도록 search 함수를 바꿔, 지연 평가를 끝까지 살릴 수도 있습니다. 반환 타입을 impl Iterator<Item = &'a str>로 바꾸면 search 함수는 반복자 어댑터를 돌려주는 함수가 됩니다. 그러려면 테스트도 고쳐야 합니다. 또 지금의 클로저는 매개변수 query를 빌려 캡처하는데, 돌려준 반복자는 함수가 끝난 뒤에도 쓰이므로 filter(move |line| line.contains(query))처럼 move를 붙여 query의 참조를 클로저 안에 복사해 두어야 합니다.
다만 지금의 run은 if와 else에서 고른 결과를 변수 results 하나에 담는데, if 식은 어느 갈래로 가든 타입이 같아야 합니다. search만 바꾸면 Vec을 돌려주는 search_case_insensitive와 타입이 다르고, 두 함수를 모두 반복자를 돌려주게 바꿔도 클로저마다 타입이 따로 있어 두 impl Iterator 뒤의 실제 타입이 서로 다르므로 컴파일되지 않습니다. 그래서 run은 두 갈래 안에서 각각 for 루프로 결과를 출력하도록 고칩니다. 이렇게 바꾼 전후에 minigrep 도구로 큰 파일을 검색해 동작의 차이를 확인해 보세요. 이렇게 바꾸면 결과를 다 모은 뒤에 출력하는 대신, 일치하는 줄을 찾을 때마다 출력하게 됩니다.
루프와 반복자 중 선택하기
그렇다면 자연스럽게, 자신의 코드에서 어느 스타일을 고를지와 그 까닭이 궁금해집니다. 원래의 구현과 반복자를 쓴 구현 가운데 무엇이 나을까요? 대부분의 러스트 프로그래머는 반복자 스타일을 선호합니다. 처음에는 익숙해지기가 좀 어렵지만, 여러 반복자 어댑터가 무엇을 하는지 감을 잡고 나면 반복자를 이해하기가 더 쉬워집니다. 루프를 돌리고 새 벡터를 만드는 세세한 일을 만지는 대신, 이 코드는 루프의 고수준 목표에 집중합니다. 흔한 코드를 추상화로 덜어 내므로, 반복자의 각 원소가 통과해야 하는 걸러 내기 조건처럼 이 코드만의 고유한 개념이 더 잘 보입니다.
그런데 두 구현이 정말 같을까요? 직관적으로는 저수준의 루프가 더 빠를 것 같다고 생각할 수 있습니다. 반복자 스타일로 바꾼다고 자동으로 병렬화되거나 언제나 더 빨라지는 것은 아닙니다. 다음 글에서 성능을 이야기합니다.
정리
Config::build가&[String]대신impl Iterator<Item = String>을 받으면,env::args()의 반복자를 곧바로 넘길 수 있고 인수를 모아 두던 벡터가 필요 없어집니다.- 반복자를 소유하면
next로 꺼낸String을clone없이Config필드로 옮길 수 있습니다. 반복자를 진행시키므로 매개변수에mut를 붙입니다. - 인덱싱과 길이 검사 대신
match args.next()로None을 확인하면, 무엇이 빠졌는지 알려 주는 오류 메시지를 줄 수 있습니다. search는contents.lines().filter(|line| line.contains(query)).collect()로 가변 중간 벡터 없이 쓸 수 있으며, 결과는 여전히contents를 빌립니다.- 반복자 스타일은 코드의 목표를 드러내 주지만, 그것만으로 병렬화되거나 더 빨라지지는 않습니다.
연습 문제
1. 대소문자 무시 검색도 바꾸기
search_case_insensitive를 반복자 어댑터로 다시 쓰세요. 검색어를 소문자로 바꾸는 일은 어디서 하나요?
해설 보기
다음처럼 쓸 수 있습니다.
pub fn search_case_insensitive<'a>(
query: &str,
contents: &'a str,
) -> Vec<&'a str> {
let query = query.to_lowercase();
contents
.lines()
.filter(|line| line.to_lowercase().contains(&query))
.collect()
}검색어를 소문자로 바꾸는 일은 체인 앞에서 한 번만 합니다. 클로저 안에서 바꾸면 줄마다 검색어의 소문자 String을 새로 만들게 됩니다. 클로저는 소문자로 바꾼 query를 빌려 캡처하고, 결과에는 원래 줄이 담깁니다.
2. 어떤 오류 메시지가 나올까
바뀐 Config::build에 (가) 인수 없이, (나) cargo run -- frog, (다) cargo run -- frog poem.txt extra로 실행하면 각각 무엇이 되나요?
해설 보기
(가)는 프로그램 이름을 건너뛴 뒤 첫 next가 None이므로 Didn't get a query string을 담은 Err를 돌려주고, main이 Problem parsing arguments: Didn't get a query string을 출력한 뒤 종료 상태 1로 끝납니다. (나)는 검색어는 얻지만 둘째 next가 None이라 Didn't get a file path가 나옵니다. (다)는 필요한 두 값을 얻은 뒤 반복자에 남은 extra를 꺼내지 않으므로 무시하고 정상적으로 검색합니다.
다음 글 성능 비교하기: 루프 vs. 반복자에서는 루프와 반복자로 쓴 검색의 성능을 비교하고, 반복자가 느려지지 않는 까닭을 살펴봅니다.