아이템 시퀀스 처리와 이터레이터

이터레이터 패턴을 사용하면 아이템 시퀀스에 대해 순차적으로 작업을 수행할 수 있다. 이터레이터는 각 아이템을 순회하고 시퀀스가 끝났는지 판단하는 로직을 담당한다. 이터레이터를 사용하면 이러한 로직을 직접 구현할 필요가 없다.

Rust에서 이터레이터는 게으른(lazy) 특성을 가진다. 이는 이터레이터를 소모하는 메서드를 호출하기 전까지는 아무런 효과가 없다는 의미다. 예를 들어, 리스트 13-10은 Vec<T>에 정의된 iter 메서드를 호출해 벡터 v1의 아이템에 대한 이터레이터를 생성한다. 이 코드 자체로는 유용한 작업을 수행하지 않는다.

Filename: src/main.rs
fn main() {
    let v1 = vec![1, 2, 3];

    let v1_iter = v1.iter();
}
Listing 13-10: 이터레이터 생성

이터레이터는 v1_iter 변수에 저장된다. 이터레이터를 생성한 후에는 다양한 방식으로 사용할 수 있다. 3장의 리스트 3-5에서는 for 루프를 사용해 배열을 순회하며 각 아이템에 대해 코드를 실행했다. 이때 내부적으로 이터레이터가 암시적으로 생성되고 소모되었지만, 지금까지는 그 동작 원리를 자세히 다루지 않았다.

리스트 13-11의 예제에서는 이터레이터 생성과 for 루프에서의 사용을 분리했다. v1_iter에 있는 이터레이터를 사용해 for 루프가 호출되면, 이터레이터의 각 엘리먼트가 루프의 한 번의 반복에서 사용되며, 각 값을 출력한다.

Filename: src/main.rs
fn main() {
    let v1 = vec![1, 2, 3];

    let v1_iter = v1.iter();

    for val in v1_iter {
        println!("Got: {val}");
    }
}
Listing 13-11: for 루프에서 이터레이터 사용

표준 라이브러리에서 이터레이터를 제공하지 않는 언어에서는, 인덱스 0부터 시작하는 변수를 사용해 벡터에서 값을 가져오고, 루프에서 변수 값을 증가시키며 벡터의 전체 아이템 수에 도달할 때까지 반복하는 방식으로 동일한 기능을 구현할 가능성이 높다.

이터레이터는 이러한 모든 로직을 대신 처리해주며, 잠재적으로 실수할 수 있는 반복 코드를 줄여준다. 이터레이터는 벡터처럼 인덱스로 접근할 수 있는 데이터 구조뿐만 아니라 다양한 종류의 시퀀스에 동일한 로직을 사용할 수 있는 유연성을 제공한다. 이터레이터가 어떻게 이를 가능하게 하는지 살펴보자.

Iterator 트레이트와 next 메서드

모든 이터레이터는 표준 라이브러리에 정의된 Iterator라는 트레이트를 구현한다. 이 트레이트의 정의는 다음과 같다:

#![allow(unused)]
fn main() {
pub trait Iterator {
    type Item;

    fn next(&mut self) -> Option<Self::Item>;

    // 기본 구현이 있는 메서드들은 생략됨
}
}

이 정의에서 새로운 구문인 type ItemSelf::Item이 사용되었음을 알 수 있다. 이는 이 트레이트와 연관된 타입을 정의하는 것이다. 연관 타입에 대해서는 20장에서 자세히 다룰 예정이다. 지금은 Iterator 트레이트를 구현하려면 Item 타입도 정의해야 하며, 이 Item 타입이 next 메서드의 반환 타입으로 사용된다는 점만 알아두면 된다. 즉, Item 타입은 이터레이터가 반환할 타입이 된다.

Iterator 트레이트는 구현자가 오직 하나의 메서드만 정의하도록 요구한다: 바로 next 메서드다. 이 메서드는 이터레이터에서 한 번에 하나의 아이템을 반환하며, Some으로 감싸져 있다. 이터레이션이 끝나면 None을 반환한다.

이터레이터에서 직접 next 메서드를 호출할 수 있다. 리스트 13-12는 벡터에서 생성된 이터레이터에 next를 반복적으로 호출했을 때 어떤 값이 반환되는지 보여준다.

Filename: src/lib.rs
#[cfg(test)]
mod tests {
    #[test]
    fn iterator_demonstration() {
        let v1 = vec![1, 2, 3];

        let mut v1_iter = v1.iter();

        assert_eq!(v1_iter.next(), Some(&1));
        assert_eq!(v1_iter.next(), Some(&2));
        assert_eq!(v1_iter.next(), Some(&3));
        assert_eq!(v1_iter.next(), None);
    }
}
Listing 13-12: 이터레이터에서 next 메서드 호출

v1_iter를 가변으로 만들어야 한다는 점에 주목하자: 이터레이터에서 next 메서드를 호출하면 이터레이터가 시퀀스 내에서 현재 위치를 추적하기 위해 사용하는 내부 상태가 변경된다. 즉, 이 코드는 이터레이터를 소비하거나 사용한다. next를 호출할 때마다 이터레이터의 아이템을 하나씩 소비한다. for 루프를 사용할 때는 v1_iter를 가변으로 만들 필요가 없었는데, 이는 루프가 v1_iter의 소유권을 가져와서 내부적으로 가변으로 만들었기 때문이다.

또한 next를 호출하여 얻은 값들은 벡터 내 값에 대한 불변 참조라는 점도 주목하자. iter 메서드는 불변 참조에 대한 이터레이터를 생성한다. 만약 v1의 소유권을 가져오고 소유한 값을 반환하는 이터레이터를 만들고 싶다면, iter 대신 into_iter를 호출하면 된다. 마찬가지로, 가변 참조를 순회하고 싶다면 iter 대신 iter_mut를 호출하면 된다.

이터레이터를 소비하는 메서드

Iterator 트레이트는 표준 라이브러리에서 제공하는 기본 구현을 가진 여러 메서드를 포함한다. 이 메서드들에 대한 자세한 정보는 표준 라이브러리 API 문서에서 Iterator 트레이트를 참조하면 된다. 이 메서드들 중 일부는 정의 내부에서 next 메서드를 호출하기 때문에, Iterator 트레이트를 구현할 때 next 메서드를 반드시 구현해야 한다.

next를 호출하는 메서드는 소비 어댑터(consuming adapters) 라고 불린다. 이 메서드들을 호출하면 이터레이터가 소비되기 때문이다. 예를 들어, sum 메서드는 이터레이터의 소유권을 가져와 next를 반복적으로 호출하며 아이템을 순회한다. 이 과정에서 각 아이템을 누적 합계에 더하고, 순회가 완료되면 총합을 반환한다. 리스트 13-13은 sum 메서드를 사용하는 예제를 보여준다.

Filename: src/lib.rs
#[cfg(test)]
mod tests {
    #[test]
    fn iterator_sum() {
        let v1 = vec![1, 2, 3];

        let v1_iter = v1.iter();

        let total: i32 = v1_iter.sum();

        assert_eq!(total, 6);
    }
}
Listing 13-13: sum 메서드를 호출하여 이터레이터의 모든 아이템의 총합을 구하기

sum을 호출한 후에는 v1_iter를 사용할 수 없다. sum이 호출된 이터레이터의 소유권을 가져가기 때문이다.

다른 이터레이터를 생성하는 메서드

_이터레이터 어댑터_는 Iterator 트레이트에 정의된 메서드로, 이터레이터를 소비하지 않는다. 대신, 원래 이터레이터의 일부를 변경하여 새로운 이터레이터를 생성한다.

리스트 13-14는 이터레이터 어댑터 메서드인 map을 호출하는 예제를 보여준다. map은 각 항목을 순회할 때 호출할 클로저를 인자로 받는다. map 메서드는 수정된 항목을 생성하는 새로운 이터레이터를 반환한다. 여기서 클로저는 벡터의 각 항목에 1을 더한 새로운 이터레이터를 생성한다:

Filename: src/main.rs
fn main() {
    let v1: Vec<i32> = vec![1, 2, 3];

    v1.iter().map(|x| x + 1);
}
Listing 13-14: 새로운 이터레이터를 생성하기 위해 이터레이터 어댑터 map 호출

하지만 이 코드는 다음과 같은 경고를 발생시킨다:

$ cargo run
   Compiling iterators v0.1.0 (file:///projects/iterators)
warning: unused `Map` that must be used
 --> src/main.rs:4:5
  |
4 |     v1.iter().map(|x| x + 1);
  |     ^^^^^^^^^^^^^^^^^^^^^^^^
  |
  = note: iterators are lazy and do nothing unless consumed
  = note: `#[warn(unused_must_use)]` on by default
help: use `let _ = ...` to ignore the resulting value
  |
4 |     let _ = v1.iter().map(|x| x + 1);
  |     +++++++

warning: `iterators` (bin "iterators") generated 1 warning
    Finished `dev` profile [unoptimized + debuginfo] target(s) in 0.47s
     Running `target/debug/iterators`

리스트 13-14의 코드는 아무런 동작을 하지 않는다. 지정한 클로저가 호출되지 않기 때문이다. 이 경고는 이터레이터 어댑터가 지연 평가(lazy)되며, 이터레이터를 소비해야 한다는 사실을 상기시켜 준다.

이 경고를 해결하고 이터레이터를 소비하기 위해, 12장의 리스트 12-1에서 env::args와 함께 사용한 collect 메서드를 사용한다. 이 메서드는 이터레이터를 소비하고 결과 값을 컬렉션 데이터 타입으로 모은다.

리스트 13-15에서는 map 호출로 반환된 이터레이터를 순회한 결과를 벡터로 모은다. 이 벡터는 원래 벡터의 각 항목에 1을 더한 값을 포함하게 된다.

Filename: src/main.rs
fn main() {
    let v1: Vec<i32> = vec![1, 2, 3];

    let v2: Vec<_> = v1.iter().map(|x| x + 1).collect();

    assert_eq!(v2, vec![2, 3, 4]);
}
Listing 13-15: 새로운 이터레이터를 생성하기 위해 map 메서드를 호출하고, collect 메서드를 호출하여 새로운 이터레이터를 소비하고 벡터를 생성

map은 클로저를 인자로 받기 때문에, 각 항목에 대해 수행하고 싶은 어떤 연산이든 지정할 수 있다. 이는 클로저가 Iterator 트레이트가 제공하는 반복 동작을 재사용하면서도 특정 동작을 커스터마이즈할 수 있는 좋은 예시이다.

여러 이터레이터 어댑터 호출을 연결하여 복잡한 동작을 가독성 있게 수행할 수 있다. 하지만 모든 이터레이터는 지연 평가되기 때문에, 이터레이터 어댑터 호출의 결과를 얻으려면 소비형 어댑터 메서드 중 하나를 호출해야 한다.

환경을 캡처하는 클로저 사용하기

많은 이터레이터 어댑터는 클로저를 인자로 받는다. 특히 이터레이터 어댑터에 전달하는 클로저는 주변 환경을 캡처하는 경우가 많다.

이 예제에서는 클로저를 인자로 받는 filter 메서드를 사용한다. 이 클로저는 이터레이터에서 아이템을 받아 bool 값을 반환한다. 클로저가 true를 반환하면, filter가 생성한 이터레이션에 해당 값이 포함된다. false를 반환하면 포함되지 않는다.

리스트 13-16에서는 filter를 사용해 주변 환경에서 shoe_size 변수를 캡처하는 클로저를 적용한다. 이를 통해 Shoe 구조체 인스턴스 컬렉션을 순회하며 지정된 크기의 신발만 반환한다.

Filename: src/lib.rs
#[derive(PartialEq, Debug)]
struct Shoe {
    size: u32,
    style: String,
}

fn shoes_in_size(shoes: Vec<Shoe>, shoe_size: u32) -> Vec<Shoe> {
    shoes.into_iter().filter(|s| s.size == shoe_size).collect()
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn filters_by_size() {
        let shoes = vec![
            Shoe {
                size: 10,
                style: String::from("sneaker"),
            },
            Shoe {
                size: 13,
                style: String::from("sandal"),
            },
            Shoe {
                size: 10,
                style: String::from("boot"),
            },
        ];

        let in_my_size = shoes_in_size(shoes, 10);

        assert_eq!(
            in_my_size,
            vec![
                Shoe {
                    size: 10,
                    style: String::from("sneaker")
                },
                Shoe {
                    size: 10,
                    style: String::from("boot")
                },
            ]
        );
    }
}
Listing 13-16: shoe_size를 캡처하는 클로저와 함께 filter 메서드 사용하기

shoes_in_size 함수는 신발 벡터와 신발 크기를 매개변수로 받아 해당 크기의 신발만 포함된 벡터를 반환한다.

shoes_in_size 함수 본문에서는 into_iter를 호출해 벡터의 소유권을 가진 이터레이터를 생성한다. 그런 다음 filter를 호출해 클로저가 true를 반환하는 요소만 포함된 새로운 이터레이터로 변환한다.

클로저는 환경에서 shoe_size 매개변수를 캡처하고, 각 신발의 크기와 비교해 지정된 크기의 신발만 남긴다. 마지막으로 collect를 호출해 변환된 이터레이터가 반환한 값을 벡터로 모아 함수에서 반환한다.

테스트를 통해 shoes_in_size를 호출하면 지정한 크기와 동일한 신발만 반환되는 것을 확인할 수 있다.