포스트

관용구 ⑦ 반복자·클로저 — Rust다움의 본체

C++ 대응이 흐릿한, Rust에서 새로 배우는 축. 여길 건너뛰면 'C++을 Rust 문법으로 쓴' 코드가 된다. 환경 캡처 방식이 소유권과 맞물리는 클로저(Fn/FnMut/FnOnce), for문 대신 iter().map().filter().collect()로 짜는 lazy하고 zero-cost한 반복자 체인까지. 인덱스 루프 습관을 교정한다.

관용구 ⑦ 반복자·클로저 — Rust다움의 본체

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캡처 상태를 직접 변경
FnOnceself모든 클로저가 구현. 캡처를 소비하는 클로저는 이것만 구현
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를 하나의 흐르는 체인으로 쓰는 셈입니다. 두 가지 특성이 핵심입니다.

  • lazymap·filter는 즉시 계산하지 않습니다. .sum()·.collect() 같은 소비자(consumer)가 붙어야 비로소 한 번의 순회로 전부 처리됩니다. 중간 배열을 만들지 않습니다.
  • zero-cost — 이 추상화는 손으로 쓴 인덱스 루프와 동일한 기계어로 컴파일됩니다. 읽기 좋으면서 느리지 않습니다. Iterator trait(⑥) 하나로 이 모든 게 굴러갑니다.

collect()는 “어디로 모을지”를 타입으로 정합니다 — Vec, HashMap, String, Result 등. 처음엔 타입 추론이 헷갈리는데, 반환 타입이나 터보피시(collect::<Vec<_>>())로 알려 주면 됩니다.

C++ 전환으로 정리

C++Rust핵심 차이
람다 캡처 + operator() const 여부클로저 Fn/FnMut/FnOnce캡처 방식보다 본문의 사용 방식이 call trait 결정
이동 캡처 [x = std::move(x)]move 클로저소유권째 가져가기
<algorithm> + 범위 foriter().map().filter() 체인lazy·zero-cost, 흐르는 체인
std::transform/copy_ifmap/filter이름이 더 직관적, 연결됨
std::accumulatefold / sum동일 개념

자주 막히는 지점

  • 인덱스 for 루프 습관 — C++ 감각으로 for i in 0..v.len() { v[i] }를 쓰게 됩니다. Rust에서는 반복자 체인이 대부분 더 짧고 안전하고(경계 검사 불필요) 빠릅니다.
  • iter() vs into_iter() vs iter_mut()iter()&T(빌림), into_iter()T(소유권 이동), iter_mut()&mut T. 뒤에서 원본을 또 쓸 거면 iter().
  • collect() 타입 추론 실패 — “어디로 모을지” 못 정하면 에러. 반환 타입 명시나 터보피시로.
  • lazy를 잊고 소비자 누락map만 쓰고 collect/sum을 안 붙이면 아무 일도 안 일어납니다(경고가 뜸).

통과 기준

  • 인덱스 for 루프로 짠 로직을 map/filter/collect 체인으로 바꿔 쓸 수 있다.
  • move 캡처와 Fn/FnMut/FnOnce call trait이 왜 별개인지 설명할 수 있다.
  • 반복자가 왜 lazy하고 zero-cost인지 말할 수 있다.

여기까지 오면 “Rust식 사고”가 붙기 시작한 것입니다. 다음은 ⑧ 스마트 포인터로, ②의 소유권 규칙이 빡빡할 때 푸는 도구입니다(C++ 스마트 포인터와 거의 1:1).

Reference

이 기사는 저작권자의 CC BY 4.0 라이센스를 따릅니다.