Rust의 Iterator와 Closure
1. 개요
Closure와 iterator는 Rust에서 함수형 스타일로 데이터를 처리하면서도 손으로 작성한 루프와 동일한 성능을 유지할 수 있도록 도와주는 두 가지 도구입니다. 이 글에서는 closure가 주변 환경에서 변수를 캡처하는 방식과 Fn, FnMut, FnOnce 세 특성을 설명합니다. 이후 Iterator 특성, map, filter 같은 일반적인 adapter와 직접 iterator를 작성하는 방법을 다룹니다. 이 글을 읽고 나면 filter → map → collect 같은 파이프라인을 자신 있게 작성할 수 있을 것입니다. 또한 컴파일러가 때때로 당신의 코드를 거부하는 이유도 알게 될 것입니다.
모든 예제는 Rust 1.80+ (edition 2021)을 사용하며 표준 라이브러리만 필요합니다.
2. Closure의 기초
2.1. 문법
Closure는 주변 스코프의 변수를 “볼 수 있는” 익명 함수입니다:
fn main() {
let discount = 10; // 퍼센트
let apply_discount = |price: u32| price - price * discount / 100;
println!("{}", apply_discount(250));
println!("{}", apply_discount(1000));
}
225
900
매개변수는 두 개의 |...| 사이에 위치하고, closure 본문은 바로 뒤에 옵니다. Closure는 전달되지 않은 discount 변수를 사용합니다. 일반 fn 함수는 이런 일을 할 수 없습니다.
매개변수 타입과 반환 타입은 보통 자동으로 추론됩니다. 컴파일러에 충분한 정보가 있으면 |price| ...로 작성할 수 있습니다. 하지만 타입은 한 번만 추론됩니다. 처음에 closure를 u32로 호출하고 나중에 i64로 호출하면 컴파일 오류가 발생합니다.
2.2. 세 가지 변수 캡처 방식
Closure는 본문이 필요로 하는 “가장 가벼운” 방식으로 변수를 캡처합니다. 우선순위는 불변 빌림, 다음으로 가변 빌림, 마지막으로 소유권 이전입니다.
fn main() {
let mut cart = vec!["펜"];
let print_cart = || println!("카트: {:?}", cart); // &cart를 빌리기
print_cart();
let mut add_item = |item| cart.push(item); // &mut cart를 빌리기
add_item("노트북");
add_item("자");
println!("최종: {:?}", cart);
}
카트: ["펜"]
최종: ["펜", "노트북", "자"]
각 closure를 분석해봅시다:
print_cart는cart만 읽으므로&cart를 보유합니다.add_item은push를 호출하므로&mut cart가 필요합니다. Closure 변수도mut로 선언해야 합니다. 호출할 때 내부 상태가 변하기 때문입니다.- Non-lexical lifetimes 덕분에 코드가 컴파일됩니다.
print_cart는 첫 번째 호출 후에 더 이상 사용되지 않으므로 불변 빌림은 가변 빌림이 시작하기 전에 종료됩니다.
Closure가 데이터의 소유권을 가져야 할 때는 move 키워드를 추가합니다. 전형적인 경우는 closure를 다른 스레드로 보낼 때입니다:
use std::thread;
fn main() {
let customer = String::from("Lan");
let handle = thread::spawn(move || {
println!("{}의 주문을 처리 중입니다", customer);
});
handle.join().unwrap();
}
Lan의 주문을 처리 중입니다
move가 없으면 컴파일러가 오류를 보고합니다. 새로운 스레드는 main보다 오래 살 수 있으므로 customer에 대한 참조는 안전하지 않습니다. move를 사용하면 String이 closure로 완전히 이전됩니다. 그 후 main에서 customer를 사용할 수 없습니다.
3. Fn, FnMut, FnOnce 세 가지 특성
각 closure는 자동으로 다음 세 특성 중 하나 이상을 구현합니다. 어떤 특성이 구현되는지는 closure가 캡처한 변수로 무엇을 하는지에 따라 달라집니다:
| 특성 | Closure가 캡처한 변수에 하는 일 | 호출 가능 횟수 |
|---|---|---|
Fn |
읽기만 함 | 여러 번 |
FnMut |
변경함 | 여러 번 |
FnOnce |
소비(밖으로 이전) | 한 번 |
세 특성 간에는 상속 관계가 있습니다: 모든 Fn은 FnMut이고, 모든 FnMut은 FnOnce입니다. Closure를 받는 함수를 작성할 때는 함수가 필요로 하는 가장 느슨한 특성을 선택하세요. 이렇게 하면 호출자가 더 많은 선택지를 가집니다.
fn run_twice<F: Fn()>(f: F) {
f();
f();
}
fn run_twice_mut<F: FnMut()>(mut f: F) {
f();
f();
}
fn run_once<F: FnOnce() -> String>(f: F) -> String {
f()
}
fn main() {
let name = String::from("Minh");
run_twice(|| println!("안녕 {name}"));
let mut count = 0;
run_twice_mut(|| count += 1);
println!("count = {count}");
let receipt = run_once(move || name + " - 결제 완료");
println!("{receipt}");
}
안녕 Minh
안녕 Minh
count = 2
Minh - 결제 완료
세 호출은 세 가지 특성에 해당합니다:
- 첫 번째 closure는
name만 읽으므로Fn입니다. - 두 번째 closure는
count를 증가시키므로FnMut입니다. 이를run_twice에 전달하면 오류가 발생합니다. - 마지막 closure는
name + "..."를 사용합니다.String의+연산자는name의 소유권을 가지므로 closure는 한 번만 호출할 수 있으며FnOnce만 구현합니다.
3.1. 함수에서 closure 반환하기
각 closure는 고유의 익명 타입을 가집니다. 따라서 그 타입의 이름을 작성할 수 없습니다. Closure를 반환하는 두 가지 방법이 있습니다:
fn make_discount(percent: u32) -> impl Fn(u32) -> u32 {
move |price| price - price * percent / 100
}
fn pick_rule(is_vip: bool) -> Box<dyn Fn(u32) -> u32> {
if is_vip {
Box::new(|price| price * 80 / 100)
} else {
Box::new(|price| price)
}
}
fn main() {
let summer_sale = make_discount(15);
println!("{}", summer_sale(200));
let rule = pick_rule(true);
println!("{}", rule(500));
}
170
400
impl Fn(...)는 함수가 항상 하나의 고정된 closure 타입을 반환할 때 사용합니다. 이 방법은 힙 할당이 없고 동적 호출 오버헤드가 없습니다.Box<dyn Fn(...)>는 각 분기에서 다른 closure를 반환할 때 사용합니다.pick_rule의 두 closure는 서로 다른 타입이므로 특성 객체로 통일해야 합니다.make_discount의move는 필수입니다. 없으면 closure는percent를 빌리는데,percent는 함수가 끝날 때 사라집니다.
4. Iterator 특성
4.1. 핵심: next 함수
Rust의 모든 iterator는 매우 작은 특성을 중심으로 돌아갑니다:
pub trait Iterator {
type Item;
fn next(&mut self) -> Option<Self::Item>;
// ... map, filter, sum 같은 수십 개의 내장 메서드 ...
}
next는 데이터가 남아있을 때 Some(원소)를 반환하고, 데이터가 끝나면 None을 반환합니다. for 루프는 사실상 계속해서 next를 호출하는 것을 단축한 문법일 뿐입니다.
4.2. iter, iter_mut, into_iter
컬렉션은 세 가지 종류의 iterator를 생성할 수 있습니다:
fn main() {
let mut prices = vec![100, 200, 300];
for p in prices.iter() { // p: &i32
print!("{p} ");
}
println!();
for p in prices.iter_mut() { // p: &mut i32
*p += 10;
}
for p in prices.into_iter() { // p: i32, vector 이전됨
print!("{p} ");
}
println!();
// println!("{:?}", prices); // 오류: move 후 사용
}
100 200 300
110 210 310
iter()는 불변 참조를 제공합니다. 컬렉션은 이후에도 사용할 수 있습니다.iter_mut()은 가변 참조를 제공하여 제자리에서 원소를 수정합니다.into_iter()은 소유권을 가집니다.for p in prices로 작성하면 암묵적으로into_iter()을 호출합니다.
5. Adapter와 consumer
Iterator의 메서드는 두 그룹으로 나뉩니다:
- Adapter (
map,filter,take,zip,enumerate…)는 이 iterator를 다른 iterator로 변환합니다. 이들은 게으른 평가를 하므로 consumer가 있을 때까지 아무것도 실행하지 않습니다. - Consumer (
collect,sum,count,fold,for_each,find…)는 실제로next를 호출하고 결과를 반환합니다.
예제 데이터로 Order 구조체를 사용합니다:
#[derive(Debug)]
pub struct Order {
pub id: u32,
pub customer: String,
pub total: u32,
pub paid: bool,
}
pub fn unpaid_total(orders: &[Order]) -> u32 {
orders.iter()
.filter(|o| !o.paid)
.map(|o| o.total)
.sum()
}
pub fn big_customers(orders: &[Order], threshold: u32) -> Vec<&str> {
orders.iter()
.filter(|o| o.total >= threshold)
.map(|o| o.customer.as_str())
.collect()
}
fn main() {
let orders = vec![
Order { id: 1, customer: "An".into(), total: 150_000, paid: true },
Order { id: 2, customer: "Bình".into(), total: 80_000, paid: false },
Order { id: 3, customer: "Chi".into(), total: 320_000, paid: false },
];
println!("미수금: {}", unpaid_total(&orders));
println!("큰 고객: {:?}", big_customers(&orders, 100_000));
let summary = orders.iter()
.fold(String::new(), |acc, o| acc + &format!("#{} ", o.id));
println!("주문: {summary}");
}
미수금: 400000
큰 고객: ["An", "Chi"]
주문: #1 #2 #3
예제의 각 단계:
filter(|o| !o.paid)는bool을 반환하는 closure를 받습니다. 매개변수o의 타입은&&Order입니다.filter가 원소에 대한 참조를 전달하는데iter()의 원소가 이미&Order이기 때문입니다. 자동 역참조로o.paid를 평상 방법으로 작성할 수 있습니다.map(|o| o.total)은 각 주문을 금액으로 변환합니다.sum()이 모든 값을 더합니다.collect()은 결과를 컬렉션으로 모읍니다. 대상 타입은 함수의 반환 타입Vec<&str>에서 추론됩니다.fold는 초기값과 closure(acc, 원소) -> acc_새로움을 받습니다. 이는 가장 일반적인 consumer입니다.
5.1. 게으른 평가
다음 예제는 adapter가 consumer에 의해서만 실행됨을 보여줍니다:
fn main() {
let ids = vec![1, 2, 3];
let doubled = ids.iter().map(|x| {
println!("map {x}");
x * 2
});
println!("아직 아무것도 실행되지 않음");
let result: Vec<i32> = doubled.collect();
println!("{result:?}");
}
아직 아무것도 실행되지 않음
map 1
map 2
map 3
[2, 4, 6]
게으른 평가 덕분에 filter → map → sum 체인은 데이터를 한 번만 순회하며 중간 컬렉션을 생성하지 않습니다. 컴파일러는 보통 이 파이프라인을 손으로 작성한 루프와 동등한 기계 코드로 최적화합니다. 이것이 iterator가 제로 오버헤드 추상화라고 불리는 이유입니다.
6. 직접 iterator 작성하기
next만 구현하면 됩니다. 다른 모든 adapter는 무료로 제공됩니다. 다음 예제는 HD-0001 형식의 송장 번호를 생성합니다:
pub struct InvoiceNumbers {
current: u32,
end: u32,
}
impl InvoiceNumbers {
pub fn new(start: u32, end: u32) -> Self {
Self { current: start, end }
}
}
impl Iterator for InvoiceNumbers {
type Item = String;
fn next(&mut self) -> Option<Self::Item> {
if self.current > self.end {
return None;
}
let number = format!("HD-{:04}", self.current);
self.current += 1;
Some(number)
}
}
fn main() {
let customers = ["An", "Bình", "Chi"];
for (invoice, customer) in InvoiceNumbers::new(7, 100).zip(customers) {
println!("{invoice} -> {customer}");
}
}
HD-0007 -> An
HD-0008 -> Bình
HD-0009 -> Chi
설명:
type Item = String은 iterator가 반환하는 원소의 타입을 선언합니다.next는&mut self를 통해 상태를 업데이트하고end를 초과하면None을 반환합니다.zip은 두 iterator를 쌍으로 연결하고 더 짧은 iterator가 끝나면 중지합니다. 따라서 숫자 범위가 100까지 이르더라도 세 줄만 출력됩니다.
7. 테스트 작성하기
Rust는 기본 제공 테스트 프레임워크를 가지고 있습니다. unpaid_total과 InvoiceNumbers를 포함하는 파일에 바로 테스트 모듈을 배치합니다:
#[cfg(test)]
mod tests {
use super::*;
fn order(id: u32, total: u32, paid: bool) -> Order {
Order { id, customer: format!("고객{id}"), total, paid }
}
#[test]
fn unpaid_total_only_counts_unpaid_orders() {
let orders = vec![order(1, 100, true), order(2, 50, false), order(3, 70, false)];
assert_eq!(unpaid_total(&orders), 120);
}
#[test]
fn unpaid_total_of_empty_slice_is_zero() {
assert_eq!(unpaid_total(&[]), 0);
}
#[test]
fn invoice_numbers_stop_after_end() {
let numbers: Vec<String> = InvoiceNumbers::new(9, 11).collect();
assert_eq!(numbers, vec!["HD-0009", "HD-0010", "HD-0011"]);
}
}
cargo test
running 3 tests
test tests::invoice_numbers_stop_after_end ... ok
test tests::unpaid_total_of_empty_slice_is_zero ... ok
test tests::unpaid_total_only_counts_unpaid_orders ... ok
test result: ok. 3 passed; 0 failed; 0 ignored
두 번째 테스트는 경계 사례를 확인합니다. 빈 iterator에 대해 sum()은 0을 반환하며 panic이 발생하지 않습니다.
8. 흔한 오류와 iterator를 사용하지 말아야 할 때
주의: Consumer를 빠뜨리는 것이 가장 흔한 오류입니다.
orders.iter().map(|o| println!("{}", o.id));줄은 아무것도 출력하지 않습니다. 컴파일러는 “iterators are lazy and do nothing unless consumed” 경고를 보냅니다. 부작용만 필요할 때는for루프나for_each를 사용하세요.
기타 흔한 오류들:
collect가 대상 타입을 모를 때.let v = it.collect();라고 작성하면 “type annotations needed” 오류가 발생합니다.let v: Vec<_> = ...로 명시하거나 turbofishcollect::<Vec<_>>()를 사용해야 합니다.into_iter()를 사용한 후 컬렉션을 다시 사용하고 싶을 때. 읽기만 필요하면iter()를 사용하세요.- Closure가 빌림을 너무 오래 유지할 때.
FnMut인 closure가&mut cart를 캡처하면 마지막으로 사용할 때까지cart를 읽을 수 없습니다. Closure의 범위를 좁히거나 호출을 마친 후에 읽으세요. - Closure 내에서
?가 필요할 때. Closure 내의?연산자는 closure에서만 반환되고 외부 함수에서 반환되지 않습니다.collect::<Result<Vec<_>, _>>()를 사용하세요. 이 방식은 첫 오류에서 멈추고Err을 반환합니다.
iterator 체인을 사용하지 말아야 할 때:
- 로직이 많은 분기, 많은
break/continue, 또는 동시에 많은 변수를 업데이트해야 할 때. 명확한for루프가 10줄짜리fold보다 읽기 쉽습니다. - 많은 분기에서 closure를 반환해야 하지만 성능을 걱정할 때.
Box<dyn Fn>은 힙 할당 및 동적 호출 오버헤드가 있습니다. 대신enum이나 generic을 고려하세요.
9. 결론
Rust의 closure는 변수를 가장 가벼운 방식으로 캡처합니다: 빌림, 가변 빌림, 또는 move. 이로부터 closure는 자동으로 Fn, FnMut, FnOnce를 구현합니다. Closure를 받는 함수를 작성할 때는 함수가 필요로 하는 가장 느슨한 특성을 선택하세요.
Iterator는 단 하나의 메서드 next에만 의존합니다. map, filter, zip 같은 adapter는 게으르고, collect, sum, fold 같은 consumer는 파이프라인을 실제로 실행합니다. 이 두 도구를 조합하면 데이터 처리 코드를 간결하고 메모리 안전하며 손으로 작성한 루프만큼 빠르게 작성할 수 있습니다. 또한 간단한 for 루프가 더 나은 선택일 때를 알아야 합니다.