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의 비유니코드 입력 경계도 남습니다.
반복자를 사용하여 clone 제거하기
예제 11-23에서는 String 값의 슬라이스를 받아서 슬라이스에 인덱스로 접근하고 복사하는 방식으로 Config 구조체의 인스턴스를 생성하는 코드를 넣었고, Config 구조체가 이 값들을 소유하도록 했습니다.
예제 12-17은 예제 11-23에 있던 Config::build 함수의 구현체를 재현한 것입니다.
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 호출에 대해서, 나중에 제거할 테니 걱정하지 말라고 이야기했었지요.
자, 그때가 되었습니다!
String 요소들의 슬라이스를 args 매개변수로 받았지만, build 함수는 args를 소유하지 않기 때문에 clone이 필요했습니다.
Config 인스턴스의 소유권을 반환하기 위해서는 Config의 query와 file_path 필드로 값을 복제하는 것으로 Config 인스턴스가 그 값들을 소유하게 할 필요가 있었습니다.
반복자에 대한 새로운 지식을 사용하면, 인수로서 슬라이스를 빌리는 대신 반복자의 소유권을 갖도록 build 함수를 변경할 수 있습니다.
슬라이스의 길이를 체크하고 특정 위치로 인덱싱하는 코드 대신 반복자의 기능을 사용할 것입니다.
이렇게 하면 반복자가 값에 접근하기 때문에 Config::build 함수가 수행하는 작업이 명확해집니다.
Config::build가 반복자의 소유권을 가져오고 빌린 값에 대한 인덱싱 연산을
사용하지 않게 되면, clone을 호출하여 새로 할당하는 대신 반복자의 String
값을 Config로 이동시킬 수 있습니다.
반환된 반복자를 직접 사용하기
여러분의 I/O 프로젝트에 있는 src/main.rs 파일을 열어보면, 아래와 같이 생겼을 것입니다.
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);
});
// --생략--
}
먼저 예제 11-24에 있던 main 함수의 시작점을 수정하여 예제 12-18의 코드로 바꾸려고 하는데, 이번에는 반복자를 사용합니다.
Config::build도 마찬가지로 업데이트하기 전에는 컴파일되지 않습니다.
env::args의 반환 값을 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의 정의를 업데이트할 필요가 있습니다.
여러분의 I/O 프로젝트에 있는 src/lib.rs 파일에서, 예제 12-19와 같이 Config::build의 시그니처를 변경합시다.
함수 본문도 업데이트해야 하므로 여전히 컴파일되지 않습니다.
예제 12-19: 반복자를 받도록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>를 갖는 제네릭 타입이 되도록 하였습니다.
9장의 ‘매개변수로서의 트레이트’ 절에서 논의했었던 이러한 impl Trait 문법을 사용하면 args가 Iterator 트레이트를 구현하면서 String 아이템을 반환하는 모든 종류의 타입을 사용할 수 있습니다.
args의 소유권을 가져와서 이를 순회하면서 args를 변경할 것이기
때문에, args 매개변수의 명세 부분에 mut 키워드를 추가하여 가변이
되도록 합니다.
인덱싱 대신 Iterator 트레이트 메서드 사용하기
다음으로 Config::build의 본문을 수정하겠습니다.
args가 Iterator 트레이트를 구현하고 있으므로, 여기에 next 메서드를 호출할 수 있다는 것을 알고 있지요!
예제 12-20은 예제 11-23의 코드를 next 메서드를 사용하여 업데이트한 것입니다.
Config::build의 본문 변경하기
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 값도 동일하게 처리합니다.
반복자 어댑터로 더 간결한 코드 만들기
I/O 프로젝트의 search 함수에도 반복자의 장점을 활용할 수 있는데,
이전 검색 루프 코드가 예제 12-21에 재현되어 있습니다.
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 벡터를 표현식 안으로 옮겼지만, 이 표준 반복자 체인은 여전히 순차 실행합니다. collect 내부의 결과 저장과 할당도 남습니다. 병렬 검색은 별도의 실행 방식과 데이터 공유·결과 순서 조건을 설계해야 하며, 이 리팩터링만으로 자동 적용되지 않습니다.
예제 12-22는 이러한 변경을 보여줍니다.
예제 12-22:search 함수 구현에서 반복자 어댑터
메서드 사용하기
pub fn search<'a>(query: &str, contents: &'a str) -> Vec<&'a str> {
contents
.lines()
.filter(|line| line.contains(query))
.collect()
}
search 함수의 목적은 query를 포함하는 contents의 모든 라인을 반환하는 것임을 상기합시다.
예제 12-16의 filter 예제와 유사하게, 이 코드는 line.contains(query)가 true를 반환하는 라인들만 유지하기 위해서 filter 어댑터를 사용합니다.
그런 다음 collect를 사용하여 매칭된 라인들을 모아 새로운 벡터로 만듭니다.
훨씬 단순하군요!
마찬가지로 search_case_insensitive도 반복자 메서드들을 사용하도록 동일한 변경을 해보셔도 좋습니다.
루프와 반복자 중 선택하기
clone 제거, 반복자 어댑터, 소유권과 빌림을 한 번에 연결해서 보면 어떤
스타일을 선택할지 판단하기 더 쉽습니다.
그렇다면 여러분의 코드에서 어떤 스타일을 선택하는 것이 좋은지와 그 이유에 대한 질문이 논리적으로 뒤따르겠지요: 예제 12-21에 있는 원래 구현과 예제 12-22에 있는 반복자를 사용하는 버전 중 어떤 것이 좋을까요?
대부분의 러스트 프로그래머는 반복자 스타일을 선호합니다.
처음 사용하기는 다소 어렵습니다만, 다양한 반복자 어댑터와 각 어댑터가 어떤 일을 하는지에 대해 일단 감을 잡으면 반복자들을 이해하기 쉬워질 것입니다.
루프를 만들고 새 벡터를 만드는 등 다양한 것들을 만지작거리는 대신, 이 코드는 루프의 고수준의 목표에 집중합니다.
이는 몇몇 아주 흔한 코드를 추상화해서 제거하므로, 반복자의 각 요소가 반드시 통과해야 하는 필터링 조건과 같이 이 코드만의 핵심을 더 알기 쉽게 설명합니다.
I/O 프로젝트 리팩터링은 입력 소유권을 옮기고, 인덱싱을 next로 바꾸고,
검색 루프를 filter와 collect로 접는 흐름으로 읽으면 실수 지점이 줄어듭니다.
그런데 이 두 가지 구현은 정말 동일할까요?
직관적으로는 더 저수준의 루프가 더 빨라 보입니다.
그러면 성능에 대해서 얘기해 봅시다.
인수 리팩터링은 누가 문자열을 소유하는가부터 확인하면 됩니다. 이전의 빌린 슬라이스는 원본 문자열을 빼낼 수 없어 clone으로 Config의 소유 값을 만들었습니다. 새 입력은 Iterator<Item = String>이므로 Some(arg) 안의 문자열을 그대로 필드로 옮깁니다. 그래서 두 필드의 복제와 중간 인수 벡터는 없어지지만, 환경을 읽고 파일을 읽는 비용까지 없어지는 것은 아닙니다. next가 내부 위치를 바꾸므로 매개변수 args는 mut입니다.
예시
환경에 의존하는 실제 프로그램 대신 인수의 모양만 바꿔 보려면, 기존 Config::build에 vec!["minigrep", "needle", "poem.txt", "extra"].into_iter().map(String::from)을 전달할 수 있습니다. 첫 값은 호출 이름으로 버리고, 다음 두 String을 질의어와 경로로 옮깁니다. 네 번째 값은 현재 코드에서 검사하지 않고 남겨 둡니다. 이 호출을 기존 라이브러리의 테스트에 넣을 때는 Config의 성공 여부와 필드를 확인하고, IGNORE_CASE 값은 별도로 통제해야 합니다.
검색 리팩터링은 소유권을 다르게 유지합니다. lines()는 &str을 내보내고 filter는 그것에 대한 &&str을 검사하지만, 통과한 항목은 처음의 &str입니다. collect가 원문 순서의 참조를 새 벡터에 담습니다. 대소문자 무시 버전으로 확장할 때도 비교용 소문자 문자열과 반환할 원문 참조를 구별해야 합니다.
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()
}이는 기존 src/lib.rs의 같은 함수 본문을 대신하는 예입니다. 호출용 main이나 기존 run을 새로 정의하는 코드는 아닙니다. 질의어는 한 번 바꾸고 각 줄은 검사할 때 임시로 바꾸지만, 반환되는 줄은 contents의 원래 표기입니다. contents가 "Rust.\nRUST!\nTrust me.\nDuct tape."이고 질의어가 "rUsT"라면 결과는 Rust.·RUST!·Trust me. 순서입니다.
스스로 검증하기
세 입력은 각각 새 String 반복자이며 환경 읽기는 성공한다고 가정합니다. A는 ["mini", "needle"], B는 ["needle", "poem.txt"], C는 ["mini", "needle", "poem.txt", "extra"]입니다. 기존 Config::build가 어느 값을 호출 이름으로 버리는지, 성공 여부와 필드 또는 정확한 오류 문구를 적으세요. 이어 위 원문에서 빈 질의어의 검색 결과와 결과가 소문자 String인지 원문 &str인지 설명하세요.
풀이 확인
A는 mini를 버리고 needle을 질의어로 얻지만 다음 값이 없어 Didn't get a file path입니다. B도 첫 값을 항상 버리므로 질의어는 poem.txt가 되고, 파일 경로가 없어 같은 오류입니다. 인수 반복자가 프로그램 이름을 포함한다는 준비 조건을 생략하면 해석이 한 칸씩 밀립니다. C는 질의어 needle과 경로 poem.txt로 성공하며, 현재 구현은 추가 값 extra를 거부하지 않습니다.
빈 질의어는 각 줄에 포함되므로 원문 네 줄 모두가 기존 순서와 표기로 반환됩니다. 벡터는 새로 만들지만 원소는 원문을 빌린 &str입니다. 검사 중 만든 임시 소문자 문자열을 반환한다면 해당 임시 값이 사라진 뒤를 가리키게 되어 같은 수명 계약을 만족하지 못합니다.
I/O 프로젝트 개선하기 학습 판단 기준표
I/O 프로젝트 개선 내용을 예제 코드에 적용할 때 먼저 볼 조건과 나중에 검증할 조건을 나눕니다.