Rust 기초: Iterator 트레이트로 컬렉션 순회하기
Rust로 컬렉션을 다루다 보면 iter(), map(), filter(), collect() 같은 메서드를 자연스럽게 쓰게 됩니다.
이 메서드들 뒤에 있는 게 바로 Iterator 트레이트인데요.
Iterator는 Rust의 함수형 코드를 떠받치는 핵심 추상화입니다.
한 번 익숙해지면 for 루프와 임시 변수를 늘어놓던 코드가 짧고 우아하게 바뀌어요.
이 글에서는 Iterator 트레이트가 어떻게 동작하는지, 어댑터와 소비자의 차이가 무엇인지, collect()의 다양한 활용까지 알아보겠습니다.
Vec을 먼저 익혀두면 예제를 따라가기 한결 수월할 거예요.
Iterator 트레이트의 핵심
Iterator의 핵심은 단 하나의 메서드, next()입니다.
호출할 때마다 다음 값을 Some(value)로 돌려주고, 더 이상 값이 없으면 None을 반환하죠.
pub trait Iterator {
type Item;
fn next(&mut self) -> Option<Self::Item>;
// ... 수십 개의 기본 메서드들
}
Item은 연관 타입(associated type)으로 이 iterator가 어떤 값을 만들어내는지를 정의합니다.
나머지 map, filter, collect 같은 메서드는 모두 next() 위에 기본 구현되어 있어요.
즉, 어떤 타입에 next()만 구현해주면 자동으로 iterator의 모든 기능을 누릴 수 있는 거죠.
직접 한번 만들어볼까요?
struct Counter {
count: u32,
}
impl Iterator for Counter {
type Item = u32;
fn next(&mut self) -> Option<u32> {
if self.count < 5 {
self.count += 1;
Some(self.count)
} else {
None
}
}
}
fn main() {
let counter = Counter { count: 0 };
let sum: u32 = counter.sum();
println!("합계: {sum}");
}
합계: 15
next() 하나만 구현했는데 sum()이 바로 동작합니다.
이런 게 트레이트의 위력이에요.
지연 평가
iterator는 기본적으로 지연 평가(lazy)됩니다. 선언만 해서는 아무 일도 일어나지 않고, 소비자가 호출될 때 비로소 동작하죠.
fn main() {
let nums = vec![1, 2, 3, 4, 5];
let mapped = nums.iter().map(|x| {
println!("처리 중: {x}");
x * 2
});
println!("여기까지는 아무 출력도 없습니다");
let doubled: Vec<i32> = mapped.collect();
println!("결과: {doubled:?}");
}
여기까지는 아무 출력도 없습니다
처리 중: 1
처리 중: 2
처리 중: 3
처리 중: 4
처리 중: 5
결과: [2, 4, 6, 8, 10]
map을 호출한 시점이 아니라 collect를 호출한 시점에 실제 처리가 일어나죠.
이렇게 지연 평가되기 때문에 무한 시퀀스를 다루거나 큰 데이터를 효율적으로 처리할 수 있습니다.
어댑터와 소비자
Iterator 메서드는 크게 두 종류로 나뉩니다. 어댑터(adapter)는 새 iterator를 반환하고, 소비자(consumer)는 iterator를 실제로 돌려서 값을 만들어내요.
대표적인 어댑터부터 볼게요.
fn main() {
let nums = vec![1, 2, 3, 4, 5];
let result: Vec<i32> = nums
.iter()
.map(|x| x * 2) // 어댑터: 변환
.filter(|x| x > &4) // 어댑터: 거름
.take(2) // 어댑터: 앞에서 N개
.collect(); // 소비자
println!("{result:?}");
}
[6, 8]
map, filter, take 모두 새 iterator를 반환할 뿐 아직 실행되지 않습니다.
collect()가 호출되어야 비로소 체인 전체가 돌아가요.
자주 쓰는 소비자도 정리해볼게요.
collect()— 컬렉션으로 모음sum()/product()— 합 / 곱count()— 개수find()— 조건 맞는 첫 값을Option으로 반환any()/all()— 하나라도 만족 / 전부 만족fold()— 누적값 만들기for_each()— 부수 효과 실행
fn main() {
let nums = vec![1, 2, 3, 4, 5];
let sum: i32 = nums.iter().sum();
let has_even = nums.iter().any(|x| x % 2 == 0);
let first_big = nums.iter().find(|&&x| x > 3);
println!("합: {sum}, 짝수 있음: {has_even}, 처음 3 초과: {first_big:?}");
}
합: 15, 짝수 있음: true, 처음 3 초과: Some(4)
for 루프의 정체
for 루프는 사실 iterator를 감싼 문법 설탕인데요.
fn main() {
let nums = vec![1, 2, 3];
for n in &nums {
println!("{n}");
}
}
위 코드는 컴파일러 입장에서 대략 이렇게 풀립니다.
fn main() {
let nums = vec![1, 2, 3];
let mut iter = (&nums).into_iter();
while let Some(n) = iter.next() {
println!("{n}");
}
}
이 변환을 가능하게 하는 게 IntoIterator 트레이트입니다.
for 루프에 쓰는 모든 타입은 IntoIterator를 구현하고 있어야 해요.
역으로 말하면 자기 타입에 IntoIterator를 구현하면 사용자가 for 루프로 순회할 수 있게 됩니다.
Iterator와 IntoIterator가 헷갈리기 쉬운데요. 차이는 단순합니다.
Iterator는 “값을 하나씩 꺼낼 수 있는 능력”이고, IntoIterator는 “나를 iterator로 바꿔주는 능력”이에요.
Vec 같은 컬렉션은 IntoIterator를 구현해서 iterator를 만들어내고, 만들어진 iterator가 Iterator를 구현해서 next()로 값을 꺼낼 수 있는 거죠.
iter, iter_mut, into_iter
같은 Vec을 순회하는 데도 세 가지 방법이 있는데요. 세 가지의 차이는 소유권을 어떻게 다루느냐예요.
fn main() {
let nums = vec![1, 2, 3];
// iter(): 불변 참조로 순회
for n in nums.iter() {
println!("{n}"); // n: &i32
}
// iter_mut(): 가변 참조로 순회 (수정 가능)
let mut nums2 = vec![1, 2, 3];
for n in nums2.iter_mut() {
*n *= 10; // n: &mut i32
}
println!("{nums2:?}");
// into_iter(): 소유권을 가져감
for n in nums.into_iter() {
println!("{n}"); // n: i32
}
// nums는 이제 사용 불가
}
1
2
3
[10, 20, 30]
1
2
3
iter()는 &T를, iter_mut()는 &mut T를, into_iter()는 T를 만들어내는 iterator입니다.
for n in &nums는 nums.iter(), for n in &mut nums는 nums.iter_mut(), for n in nums는 nums.into_iter()와 같아요.
대부분 iter()를 쓰지만 컬렉션을 더 이상 쓰지 않을 거면 into_iter()로 소유권을 가져가서 불필요한 복사를 피할 수 있습니다.
collect와 타입 추론
collect()는 iterator를 컬렉션으로 변환하는 가장 흔한 소비자인데요.
어떤 컬렉션으로 모을지 명시해줘야 한다는 게 특이한 점입니다.
fn main() {
let nums = vec![1, 2, 3, 4, 5];
// 변수에 타입 명시
let evens: Vec<i32> = nums.iter().filter(|&&x| x % 2 == 0).copied().collect();
// turbofish ::<>로 명시
let evens2 = nums.iter().filter(|&&x| x % 2 == 0).copied().collect::<Vec<i32>>();
println!("{evens:?}, {evens2:?}");
}
[2, 4], [2, 4]
collect()는 Vec, HashMap, String 등 다양한 타입으로 변환할 수 있어서 컴파일러가 어디로 모을지 추론하기 어렵거든요.
그래서 타입을 명시해주는 게 필수입니다.
Result를 다룰 때 특히 유용한 사용법이 있는데요.
Iterator<Item = Result<T, E>>를 Result<Vec<T>, E>로 한 번에 모을 수 있습니다.
fn main() {
let strs = vec!["1", "2", "3"];
let ok: Result<Vec<i32>, _> = strs.iter().map(|s| s.parse::<i32>()).collect();
println!("{ok:?}");
let bad = vec!["1", "x", "3"];
let err: Result<Vec<i32>, _> = bad.iter().map(|s| s.parse::<i32>()).collect();
println!("{err:?}");
}
Ok([1, 2, 3])
Err(ParseIntError { kind: InvalidDigit })
중간에 하나라도 Err이 나오면 즉시 멈추고 그 에러를 돌려줘요.
하나하나 unwrap하는 대신 이 패턴으로 한 방에 처리할 수 있죠.
마치며
Iterator 트레이트는 Rust 함수형 코드의 척추 같은 존재입니다.
처음에는 메서드가 너무 많아 보이지만 결국 next()라는 단순한 인터페이스 위에 모든 게 쌓여 있어요.
핵심을 정리하면 이렇습니다.
Iterator는 next() 하나만 구현하면 수십 개의 메서드를 공짜로 얻을 수 있습니다.
어댑터(map, filter 등)는 새 iterator를 만들 뿐이고, 소비자(collect, sum 등)가 호출되어야 실제로 동작합니다.
for 루프는 IntoIterator를 호출하는 문법 설탕이고요.
iter() / iter_mut() / into_iter()는 소유권을 어떻게 다루는지에 따라 골라 씁니다.
Iterator에 익숙해지면 함수 체이닝으로 의도가 명확한 코드를 짤 수 있게 됩니다. Vec이나 HashMap 같은 컬렉션을 다룰 때 특히 진가가 발휘되니까 함께 익혀두면 좋아요.
더 자세한 내용은 std::iter::Iterator - Rust 공식 문서를 참고하세요.
This work is licensed under
CC BY 4.0