관용구 ⑦ 반복자·클로저 — Rust다움의 본체
C++ 대응이 흐릿한, Rust에서 새로 배우는 축. 여길 건너뛰면 'C++을 Rust 문법으로 쓴' 코드가 된다. 환경 캡처 방식이 소유권과 맞물리는 클로저(Fn/FnMut/FnOnce), for문 대신 iter().map().filter().collect()로 짜는 lazy하고 zero-cost한 반복자 체인까지. 인덱스 루프 습관을 교정한다.
Rust 학습 로드맵의 ⑦ 관용구(고유) 단계입니다. 앞 글: ⑥ trait·제네릭
C++ 대응이 흐릿한, Rust에서 새로 배우는 축입니다. 문법만 알고 여길 건너뛰면 “C++을 Rust 문법으로 옮긴” 코드가 나옵니다. Rust 코드가 실제로 어떻게 생겼는지가 여기서 갈립니다. 클로저의 일반 개념은 클로저란 무엇인가에서 다룹니다.
클로저 — 캡처가 소유권과 맞물린다
클로저는 익명 함수입니다. C++ 람다에 대응하지만, 환경을 어떻게 캡처하느냐가 ②의 소유권과 정확히 맞물립니다.
1
2
3
let factor = 3;
let times = |x| x * factor; // factor를 캡처. 타입은 대개 추론
println!("{}", times(5)); // 15
클로저가 캡처한 값을 호출할 때 어떻게 사용하는지에 따라 구현하는 call trait이 갈립니다. move는 캡처 값을 클로저 안으로 가져오는 방식일 뿐, 한 번만 호출된다는 뜻은 아닙니다.
| trait | 호출에 필요한 receiver | 전형적인 이유 |
|---|---|---|
Fn | &self | 캡처를 소비하지 않음. Cell·atomic 등을 통한 내부 변경은 가능 |
FnMut | &mut self | 캡처 상태를 직접 변경 |
FnOnce | self | 모든 클로저가 구현. 캡처를 소비하는 클로저는 이것만 구현 |
1
2
3
4
5
6
7
8
let mut count = 0;
let mut inc = || count += 1; // count를 &mut로 캡처 → FnMut
let data = vec![1, 2, 3];
let show = move || println!("{:?}", data); // 소유 캡처지만 읽기만 → Fn도 구현
let data = vec![1, 2, 3];
let consume = move || drop(data); // 호출 때 data를 이동 → FnOnce만 구현
move 키워드를 붙이면 캡처한 값의 소유권을 클로저 안으로 가져갑니다 — 클로저를 스레드에 넘길 때 자주 필요합니다. 그러나 move 클로저도 값을 읽기만 하면 Fn을 구현할 수 있습니다. 모든 클로저는 FnOnce로 호출할 수 있고, 본문이 캡처를 소비하지 않으면 FnMut, shared receiver로도 충분하면 Fn까지 추가로 구현합니다. FnOnce만 구현하는 클로저가 실제로 한 번 호출하면 캡처를 소비합니다.
반복자 — for문을 대체하는 체인
Rust의 관용적 반복은 인덱스 for가 아니라 반복자 체인입니다.
1
2
3
4
5
6
7
8
let nums = vec![1, 2, 3, 4, 5, 6];
let sum_of_even_squares: i32 = nums.iter()
.filter(|&&x| x % 2 == 0) // 짝수만
.map(|&x| x * x) // 제곱
.sum(); // 합계 → 56
let doubled: Vec<i32> = nums.iter().map(|x| x * 2).collect(); // 새 Vec로 수집
C++의 <algorithm> + 범위 for를 하나의 흐르는 체인으로 쓰는 셈입니다. 두 가지 특성이 핵심입니다.
- lazy —
map·filter는 즉시 계산하지 않습니다..sum()·.collect()같은 소비자(consumer)가 붙어야 비로소 한 번의 순회로 전부 처리됩니다. 중간 배열을 만들지 않습니다. - zero-cost — 이 추상화는 손으로 쓴 인덱스 루프와 동일한 기계어로 컴파일됩니다. 읽기 좋으면서 느리지 않습니다.
Iteratortrait(⑥) 하나로 이 모든 게 굴러갑니다.
collect()는 “어디로 모을지”를 타입으로 정합니다 — Vec, HashMap, String, Result 등. 처음엔 타입 추론이 헷갈리는데, 반환 타입이나 터보피시(collect::<Vec<_>>())로 알려 주면 됩니다.
C++ 전환으로 정리
| C++ | Rust | 핵심 차이 |
|---|---|---|
람다 캡처 + operator() const 여부 | 클로저 Fn/FnMut/FnOnce | 캡처 방식보다 본문의 사용 방식이 call trait 결정 |
이동 캡처 [x = std::move(x)] | move 클로저 | 소유권째 가져가기 |
<algorithm> + 범위 for | iter().map().filter() 체인 | lazy·zero-cost, 흐르는 체인 |
std::transform/copy_if | map/filter | 이름이 더 직관적, 연결됨 |
std::accumulate | fold / sum | 동일 개념 |
자주 막히는 지점
- 인덱스
for루프 습관 — C++ 감각으로for i in 0..v.len() { v[i] }를 쓰게 됩니다. Rust에서는 반복자 체인이 대부분 더 짧고 안전하고(경계 검사 불필요) 빠릅니다. iter()vsinto_iter()vsiter_mut()—iter()는&T(빌림),into_iter()는T(소유권 이동),iter_mut()는&mut T. 뒤에서 원본을 또 쓸 거면iter().collect()타입 추론 실패 — “어디로 모을지” 못 정하면 에러. 반환 타입 명시나 터보피시로.- lazy를 잊고 소비자 누락 —
map만 쓰고collect/sum을 안 붙이면 아무 일도 안 일어납니다(경고가 뜸).
통과 기준
- 인덱스
for루프로 짠 로직을map/filter/collect체인으로 바꿔 쓸 수 있다. move캡처와Fn/FnMut/FnOncecall trait이 왜 별개인지 설명할 수 있다.- 반복자가 왜 lazy하고 zero-cost인지 말할 수 있다.
여기까지 오면 “Rust식 사고”가 붙기 시작한 것입니다. 다음은 ⑧ 스마트 포인터로, ②의 소유권 규칙이 빡빡할 때 푸는 도구입니다(C++ 스마트 포인터와 거의 1:1).
Reference
- The Rust Book Ch.13 — 클로저·반복자의 정본.
- rustlings — iterators / closures
- 클로저란 무엇인가 — 언어 무관 클로저 개념.