Fn, FnMut, FnOnce: 클로저의 세 가지 얼굴
러스트의 클로저(Closure)는 단순히 익명 함수가 아닙니다. 클로저는 자신의 주변 환경(environment)에 있는 변수를 ‘캡처(capture)‘하여 자신의 본문 안에서 사용할 수 있는 강력한 기능을 가진 객체입니다.
이 클로저가 어떻게 주변 변수를 캡처하고 상호작용하는지를 정밀하게 정의하는 것이 바로 Fn, FnMut, FnOnce 세 가지 트레이트입니다. 컴파일러는 클로저가 변수를 어떻게 사용하는지를 분석하여, 이 세 트레이트 중 가장 적합한 것을 자동으로 구현해 줍니다.
클로저의 캡처 방식과 세 가지 트레이트
클로저가 외부 변수를 캡처하는 방식은 세 가지가 있으며, 이는 각 Fn 트레이트와 직접적으로 대응됩니다.
- 불변 빌림 (Immutable Borrow):
&T - 가변 빌림 (Mutable Borrow):
&mut T - 소유권 이동 (Move):
T
컴파일러는 항상 가장 관대한(least restrictive) 트레이트부터 적용하려고 시도합니다. 즉, Fn → FnMut → FnOnce 순서로 가능한지 확인합니다.
1. FnOnce: 단 한 번만 호출 가능한 클로저
- 특징: 캡처한 변수의 소유권을 가져가는(move) 클로저입니다. 변수를 소비해버리기 때문에, 이 클로저는 단 한 번만 호출될 수 있습니다.
- 관계: 모든 클로저는 최소한
FnOnce입니다.Fn이나FnMut인 클로저는FnOnce이기도 합니다.
fn main() {
let s = String::from("소유권은 나에게");
// 이 클로저는 s의 소유권을 가져옵니다.
let consumes_s = || {
println!("{}", s);
// s의 소유권이 클로저 내부로 이동되었으므로, 클로저가 끝나면 s는 drop됩니다.
std::mem::drop(s);
};
consumes_s(); // 첫 번째 호출은 성공
// consumes_s(); // 에러! 두 번째 호출은 불가능합니다. `s`가 이미 소비되었기 때문입니다.
}move 키워드를 사용하여 강제로 소유권을 가져오도록 할 수도 있습니다. 이는 주로 스레드를 생성할 때 유용합니다.
let data = vec![1, 2, 3];
// `move` 키워드로 data의 소유권을 스레드로 이전
std::thread::spawn(move || {
println!("스레드 내의 데이터: {:?}", data);
});2. FnMut: 내부 상태를 변경할 수 있는 클로저
- 특징: 캡처한 변수를 가변적으로 빌려오는(&mut T) 클로저입니다.
- 관계: 여러 번 호출될 수 있으며, 호출될 때마다 캡처한 변수의 값을 변경할 수 있습니다.
FnMut클로저는FnOnce도 만족합니다.
fn main() {
let mut count = 0;
// 이 클로저는 `count`를 가변적으로 빌립니다.
let mut increment = || {
count += 1;
println!("카운트: {}", count);
};
increment(); // 카운트: 1
increment(); // 카운트: 2
}3. Fn: 내부 상태를 변경하지 않는 클로저
- 특징: 캡처한 변수를 불변으로 빌려오는(&T) 클로저입니다.
- 관계: 여러 번 호출될 수 있으며, 동시에 여러 곳에서 호출되어도 안전합니다. 값을 읽기만 하고 수정하지 않습니다.
Fn클로저는FnMut과FnOnce를 모두 만족합니다.
fn main() {
let message = "불변 메시지".to_string();
// 이 클로저는 `message`를 불변으로 빌립니다.
let print_message = || {
println!("{}", message);
};
print_message();
print_message();
}함수에서 클로저를 인자로 받기
함수의 인자로 클로저를 받을 때는 제네릭과 Fn 트레이트 경계를 사용합니다. 이를 통해 어떤 종류의 클로저를 받을지 명시할 수 있습니다.
// 이 함수는 정수 하나를 받아 정수를 반환하는,
// 부작용이 없는(상태를 변경하지 않는) 클로저를 기대합니다.
fn apply_math_operation<F>(x: i32, op: F) -> i32
where
F: Fn(i32) -> i32,
{
op(x)
}
fn main() {
let double = |n| n * 2;
let triple = |n| n * 3;
println!("3의 두 배: {}", apply_math_operation(3, double));
println!("4의 세 배: {}", apply_math_operation(4, triple));
}만약 클로저가 상태를 변경해야 한다면 F: FnMut(...)을, 소유권을 가져가야 한다면 F: FnOnce(...)를 사용하면 됩니다.
결론
Fn, FnMut, FnOnce 트레이트는 클로저가 주변 환경과 상호작용하는 방식을 소유권 시스템에 입각하여 정밀하게 분류합니다. 이 세 가지 트레이트 덕분에 러스트는 클로저를 사용한 함수형 프로그래밍을 지원하면서도, C++ 람다 등에서 발생할 수 있는 현수 참조(dangling reference)와 같은 메모리 안전성 문제를 컴파일 타임에 완벽하게 방지할 수 있습니다.