4.5. 반복자 (Iterators) - 100%
반복문에 대해 이야기 해 봅시다.
Rust 의 for
반복문을 기억하시나요? 여기 예제가 있습니다:
for x in 0..10 {
println!("{}", x);
}
지금부터 Rust 에 대해 좀더 알게 될테데요, 어떻게 동작하는 지에 대해서는 나중에 상세히 다룹니다.
0..10
로 표기된 범위(Range)를 '반복자(iterator)' 라고 합니다. 반복자란 계속해서 .next()
메소드를 호출할 수 있으며, 연속된 것들을 돌려주는 어떤 것을 말합니다.
이런거죠:
let mut range = 0..10;
loop {
match range.next() {
Some(x) => {
println!("{}", x);
},
None => { break }
}
}
범위로 만든 반복자에 대해 변경 가능한(mutable) 바인딩을 만들었습니다.
loop
안에서 range.next()
에 대해 match
를 사용하여 반복자의 다음 값에 대한 레퍼런스를 얻습니다.
next
는 Option<i32>
타입을 리턴하는데, 이 경우, 값이 있다면 Some(i32)
이 되며, 모든 값을 순회했다면 None
이 될 것 입니다. 리턴값이 Some(i32)
이면 출력하고, None
이면 break
를 사용해서 반복문을 빠져나갑니다.
이 예제는 기본적으로 for
반복문 버전과 동일합니다. for
반복문은 loop
/match
/break
를 사용하는 것 보다 쉬운 방법 입니다.
for
순환에서만 반복자를 사용하는 것은 아닙니다. 이 가이드의 범위를 벗어나긴 합니다만, Iterator
특성(trait)을 구현하여 자신만의 반복자를 만들 수 있습니다. Rust 는 다양한 작업에 쓸 수 있는 다수의 유용한 반복자들을 제공합니다. 반복자들에 대해 이야기 하기 전에, Rust 에서 피해야할 패턴(anti-pattern)들에 대해 이야기 하는 것이 좋을 것 같습니다. 아래와 같이 범위(range)를 사용하는 것이 그 중 하나 입니다.
범위는 멋지지만 동시에 매우 원시적입니다. 예를 들면, 벡터의 내용을 순회하려고 할 때 아래와 같이 쓸 수 있습니다:
let nums = vec![1, 2, 3];
for i in 0..nums.len() {
println!("{}", nums[i]);
}
이 방식은 실제로 반복자를 사용하는 것보다 좋지 못합니다. 벡터를 아래 처럼 직접 순회할 수 있습니다:
let nums = vec![1, 2, 3];
for num in &nums {
println!("{}", num);
}
이렇게 사용하는 두 가지 이유가 있는데요.
첫째, 의도하는 바를 더 직접적으로 표현 합니다. 벡터를 색인(index)으로 접근하면서 순회하기 보다, 전체 벡터를 순회합니다.
둘째, 이렇게 사용하는 것이 더 효과적입니다: nums[i]
처럼 색인을 사용하기 때문에 범위 체크가 필요합니다. 반복자를 사용해서 벡터의 각 요소에 대한 참조를 가져오기 때문에, 범위 체크가 필요 없습니다. 보퉁 반복자는 이렇게 사용합니다: 불필요한 범위 체크를 무시하면서도, 안전하게 됩니다.
여기 다른 상세한 내용이 있는데, println!
의 동작 때문에 100% 명확하진 않습니다. num
은 &i32
타입 입니다. 이 말은 i32
에 대한 참조라는 것이지 i32
자체라는 것이 아닙니다. println!
은 알아서 역참조를 처리하지만 보이진 않습니다. 이 코드 역시 잘 동작 합니다:
let nums = vec![1, 2, 3];
for num in &nums {
println!("{}", *num);
}
여기서는 명시적으로 num
을 역참조 합니다. &nums
는 왜 참조를 줄까요?
첫째로, 명시적으로 &
를 사용해서 요청 했습니다.
둘째로, 데이터를 받는다면 이것에 대한 소유자가 되어야 하며, 복사본을 받는 것 입니다.
참조를 사용하면, 데이터에 대한 참조를 빌려올 뿐 이동이 필요 없습니다.
이제 범위가 보통 필요로 하는 것이 아니라은 것을 알았습니다. 대신 어떤 것들이 필요한지 이야기 해 봅시다.
여기 적당한 세 개의 광대한 클래스가 있습니다: 반복자(iterator), 반복자 어댑터, 소비. 여기 정의가 있습니다.
- 반복자(iterator) 는 연속적인 값을 줍니다.
- 반복자 어댑터(iterator adapter) 는 반복자에 대해 동작하며, 다른 연속적인 출력을 위한 새로운 반복자를 만듭니다.
- 소비자(consumer) 는 반복자에 대해 동작하며, 최종 값의 집합을 만듭니다.
반복자와 범위를 이미 봤으니, 소비자에 대해 먼저 이야기 해 봅시다.
소비자(Consumers)
소비자 는 반복자에 대해 동작하며, 어떤 값 혹은 값들을 돌려줍니다.
가장 흔한 소비자는 collect()
입니다. 이 코드는 컴파일 되지 않지만, 어떤 의도가 있는지 알 수 있습니다:
let one_to_one_hundred = (1..101).collect();
보이는 것 처럼 반복자에 대해 collect()
를 호출합니다.
collect()
는 반복자가 줄 수 있는한 많은 값을 가져가서, 값 뭉치를 돌려줍니다.
그런데 왜 컴파일 되지 않을 까요? Rust 는 어떤 타입의 값을 모아야 하는지 알 수 없기 때문에, 알려줘야 합니다.
여기 컴파일 되는 버전이 있습니다:
let one_to_one_hundred = (1..101).collect::<Vec<i32>>();
::<>
문법을 사용해서 타입힌트를 줌으로써, 정수들을 갖는 vector 를 원한다는 것을 알려줄 수 있습니다.
항상 전체 타입을 기술할 필요는 없습니다. _
를 사용해서 부분적인 힌트를 줄 수 도 있습니다:
let one_to_one_hundred = (1..101).collect::<Vec<_>>();
이 것은 "Vec<T>
에 모아주세요, 그런데 T
가 어떤 타입인지는 추론 해주세요." 라고 말하는 것입니다.
이런 이유에서 _
는 type placeholder 라고도 합니다.
collect()
가 가장 흔한데, 여기 다른 것도 있습니다. find()
입니다:
let greater_than_forty_two = (0..100)
.find(|x| *x > 42);
match greater_than_forty_two {
Some(_) => println!("We got some numbers!"),
None => println!("No numbers found :("),
}
find
는 클로저(closure)를 인자로 받고, 반복자의 각 요소에 대한 참조를 사용합니다.
만약 찾고자 하는 요소가 있다면, 클로져는 true
를 리턴하고, 그 외에는 false
를 리턴 합니다. 일치하는 요소를 찾는 것이 아니기 때문에, find
는 요소 자체 보다는 Option
을 돌려줍니다.
또 다른 중요한 소비자는 fold
입니다. 이렇게 생겼습니다:
let sum = (1..4).fold(0, |sum, x| sum + x);
fold()
는 이런 형태 입니다:
fold(base, |accumulator, element| ...)
. 두 개의 인자를 받습니다.
첫 번째 인자는 base 라고 합니다.
두 번째는 두 개의 인자를 받는 클로져 입니다: 첫 번째는 누적기(accumulator) 라고 합니다. 두 번째는 요소(element) 입니다.
각 순회 마다 클로져가 호출되고, 클로져의 결과값은 다음 순회에 누적기 인자로 넘어갑니다. 최초 순회에서 base 는 누적기의 초기 값으로 사용됩니다.
약간 혼동 되겠지만, 이 반복자에서 모든 값들이 어떻게 변하는지 검사해 봅시다:
base | accumulator | element | closure result |
---|---|---|---|
0 | 0 | 1 | 1 |
0 | 1 | 2 | 3 |
0 | 3 | 3 | 6 |
아래와 같이 fold()
를 호출 했습니다:
# (1..4)
.fold(0, |sum, x| sum + x);
0
는 base 이고, sum
은 누적기, x
는 요소 입니다.
첫 번째 순회에서 sum
는 0
, x
는 nums
의 첫 번째 요소 1
.
그 다음 sum
과 x
를 더하면, 0 + 1 = 1
이 됩니다.
두 번째 순회에서 이 값은 누적기인 sum
의 값이 되고, x
는 배열이 두 번째 요소 2
가 됩니다.
1 + 2 = 3
은 마지막 순회에서 누적기의 값이 됩니다.
마지막 순회에서 x
는 마지막 요소인 3
이 되고, 3 + 3 = 6
이 최종 결과가 됩니다.
fold
는 처음에 좀 이상해 보일 수 있지만, 한번 적응이 되면 곳곳에 쓰게 됩니다.
일련의 처리 대상들이 있고 하나의 결과값이 필요할 때, fold
가 적절합니다.
소비자는 아직 다루지 않은 반복자의 추가적인 특성 때문에 중요합니다: 게으름(laziness)
반복자에 대해 더 알아보고, 왜 중요한지 알아 봅시다.
반복자 (Iterators)
이전에 이야기 했던 것 처럼, 반복자는 반복적으로 .next()
를 호출할 수 있고 일련의 요소들을 돌려주는 어떤 것을 말합니다.
다음 값을 얻으려면 메소드를 호출 해줘야 하기 때문에, 반복자는 미리 모든 값을 만들어 놓을 필요 없이 게을러(lazy) 질 수 있습니다. 예를 들면, 아래 예제는 1-99
사이의 수를 실제로 생성하는 대신 연속적인 어떤 것이라는 값을 만듭니다.
let nums = 1..100;
범위(range)로 아무것도 하지 않기 때문에, 값을 생성하지 않습니다. 소비자를 붙여 봅시다;
let nums = (1..100).collect::<Vec<i32>>();
이제, collect()
호출은 값들을 넘겨 받아야 하기 때문에, 범위(range)는 숫자들을 생성할 것입니다.
범위(range)는 앞으로 보게될 기본적인 두 개의 반복자 중에 하나 입니다. 다른 하나는 iter()
입니다.
iter()
는 벡터를 단순한 반복자로 변환하는데, 이 반복자는 한번에 하나씩 요소를 돌려 줍니다.
let nums = vec![1, 2, 3];
for num in nums.iter() {
println!("{}", num);
}
두 개의 기초적인 반복자는 필요한 작업을 잘 수행해 줍니다. 무한(infinite) 반복자 같은 고급 반복자도 있습니다.
이정도면 반복자에 대해서는 충분합니다. 반복자 어댑터는 반복자에 대해 이야히 할 때 필요한 마지막 개념 입니다.
살펴봅시다!
반복자 어댑터 (Iterator adapters)
반복자 어댑터는 반복자를 받아서 어떤 방식으로 수정한 다음, 새로운 반복자를 돌려줍니다.
가장 단순한 형태를 map
이라고 합니다;
(1..100).map(|x| x + 1);
map
은 반복자에 대해 호출하는데, 각 요소의 레퍼런스를 인자로 받는 클로저(closure)를 호출하게 됨으로써 새로운 반복자를 생성합니다. 결국 2-100
사이의 숫자들을 돌려 줄 것 입니다. 거의 된 것 같은데, 컴파일해 보면 경고를 출력 할 것입니다.
warning: unused result which must be used: iterator adaptors are lazy and
do nothing unless consumed, #[warn(unused_must_use)] on by default
(1..100).map(|x| x + 1);
^~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
여기서 또 게으름(laziness)을 보게 되는데, 클로저는 실행되지 않습니다.
이 예제는 어떤 숫자도 출력하지 않습니다:
(1..100).map(|x| println!("{}", x));
반복자에 대해 클로저를 실행하려고 하다면, for
를 사용하는 편이 났습니다.
흥미로운 반복자가 많은데, taken(n)
은 원본 반복자의 n
개의 요소를 갖는 반복자를 돌려 줄 것 입니다.
여기서 원본 반복자는 변경되지 않는다는 것에 유의하세요. 이제 무한 반복자를 사용해 봅시다:
for i (1..).take(5) {
println!("{}", i);
}
이렇게 출력될 것 입니다.
This will print
1
2
3
4
5
filter()
는 클로저를 인자로 받는 어댑터 입니다. 이 클로저는 true
혹은 false
를 돌려줍니다. filter()
가 돌려주는 새로운 반복자는 클로저가 true
로 리턴한 요소들을 갖고 있습니다:
for i in (1..100).filter(|&x| x % 2 == 0) {
println!("{}", i);
}
이것은 1 ~ 100 사이의 짝수들을 출력 합니다.
(filter
는 순회하는 요소들을 소비하지 않기 때문에, 각 요소에 대한 참조를 넘겨줍니다. 그러므로 filter 서술부에서는 정수 값을 가져오기 위해 &x
를 사용합니다.)
세 가지를 연결해서 사용할 수 도 있습니다: 반복자를 만들고 어댑터를 몇 번 적용한 후, 결과를 소비합니다.
살펴봅시다.
6
, 12
, 18
, 24
, 30
을 포함한 벡터를 돌려줍니다.
(1..)
.filter(|&x| x % 2 == 0)
.filter(|&x| x % 3 == 0)
.take(5)
.collect::<Vec<i32>>();
유용하게 사용할 수 있는 반복자와 반복자 어댑터, 소비자에 대해 약간 맛을 봤습니다. 다른 유용한 반복자가 많으며 새로 작성할 수 도 있습니다. 반복자는 모든 종류의 리스트를 안전하고 효율적으로 다루기 위한 방법을 제공합니다.
처음엔 어색할 수도 있는데, 계속 사용하다 보면 중독될 것 입니다. 다른 반복자와 소비자들에 대한 전체 목록 입니다. 확인해 보세요. iterator module documentation.