Rust · 심화
트레이트와 동시성으로 깊어지는 Rust
클로저 - Fn·FnMut·FnOnce 와 캡처
캡처 방식(빌림·가변 빌림·이동), move 클로저, 세 트레이트의 관계, 클로저를 인자·반환값으로, impl Fn 과 Box<dyn Fn>
개발자KR · 원고 갱신
이 장에서 배우는 것
앞 장에서 반복자 어댑터에 |qty| qty < 50 같은 짧은 함수식을 넘겼다. 그 식이 클로저(closure)다. 이 장은 클로저가 주변 변수를 가져가는 방식과, 그 방식이 결정하는 세 트레이트 Fn, FnMut, FnOnce의 관계를 다룬다. 여기에 클로저를 함수 인자와 반환값으로 주고받는 방법을 더한다. 창고 재고 프로그램에 부족 품목 집계, 로그 수집, 주문 번호 발급, 주문 검증 규칙을 클로저로 붙여 가며 익힌다.
- 클로저가 변수를 빌림, 가변 빌림, 이동 중 어느 방식으로 캡처하는지 본문 코드에서 읽어 낸다.
move클로저가 하는 일과 하지 않는 일을 구분한다.Fn,FnMut,FnOnce의 포함 관계를 설명하고 인자 자리에 알맞은 트레이트 경계를 고른다.- 클로저를
impl Fn으로 돌려줄 때와Box<dyn Fn>으로 돌려줄 때를 구분한다.
문제 상황
창고 프로그램에 요구가 계속 붙는다. 처음에는 "재고가 50개 미만인 품목이 몇 종인지" 세는 기능이었다. 다음에는 "품목별 재고를 한 줄 문자열로 모아 달라"는 요구가 왔고, 그다음에는 "주문 번호를 ORD-101, ORD-102처럼 순서대로 발급해 달라"와 "주문마다 수량 한도와 판매 중지 품목을 검사하되 규칙은 운영 중에 바뀐다"는 요구가 왔다.
요구마다 함수를 새로 만들면 비슷한 순회 코드가 반복된다. 순회는 한 곳에 두고 판단 기준만 바꿔 끼우고 싶다. 이때 바꿔 끼우는 대상은 임계값 같은 상태를 들고 다녀야 한다. 함수 포인터는 주변 변수를 가져가지 못하므로 상태를 들고 다니는 코드에는 클로저가 필요하다. 클로저를 쓰면 곧바로 두 가지 질문이 나온다. 클로저가 변수를 어떻게 가져가는지, 그리고 그것을 받는 함수가 클로저를 몇 번, 어떤 방식으로 호출하는지다. 두 질문의 답이 세 트레이트로 정리된다.
캡처 방식과 move
컴파일러가 고르는 캡처 방식
클로저는 본문에서 쓰는 바깥 변수를 가져간다(capture). 가져가는 방식은 변수마다 컴파일러가 본문을 보고 가장 약한 방식으로 정한다. 읽기만 하면 공유 빌림 &T, 값을 바꾸면 가변 빌림 &mut T, 값을 소유권째 써 버리면 이동(move)이다. 개발자가 방식을 적지 않아도 된다.
그림처럼 본문이 하는 일이 캡처 방식을 정하고, 캡처한 값을 가지고 무엇을 하느냐가 세 트레이트 중 어느 것을 구현하는지 정한다. 변수가 여러 개면 각각 따로 판단한다. 한 클로저가 어떤 변수는 빌리고 다른 변수는 가변으로 빌릴 수 있다. 구조체의 필드를 쓰면 구조체 전체가 아니라 그 필드만 캡처된다. 이 동작은 edition 2021부터 적용되며 edition 2024에서도 같다.
빌림 캡처 중에는 빌림 규칙이 그대로 적용된다. 가변 빌림으로 캡처한 클로저가 살아 있는 동안에는 그 변수를 다른 곳에서 읽을 수 없다. 클로저를 마지막으로 호출한 뒤에는 빌림이 끝나므로 다시 쓸 수 있다.
move 클로저
move를 앞에 붙이면 본문에서 읽기만 하는 변수까지 모두 소유권을 클로저로 옮긴다. Copy 타입은 복사되므로 원본이 그대로 남는다. 예를 들어 u32 임계값은 move로 가져가도 바깥에서 계속 쓸 수 있고, String이나 Vec은 원본을 더 쓸 수 없다.
가장 흔한 용도는 클로저가 만들어진 함수보다 오래 살아야 하는 경우다. 함수에서 클로저를 반환하면 지역 변수는 함수가 끝날 때 사라지므로, 빌린 채로는 반환할 수 없다. 이때 move로 값을 클로저 안에 넣는다.
자주 하는 오해가 하나 있다. move는 캡처 방식을 정할 뿐 클로저의 종류를 정하지 않는다. move 클로저도 본문이 캡처한 값을 읽기만 하면 Fn이다. 아래 세 트레이트는 본문이 하는 일로 정해진다.
Fn, FnMut, FnOnce
세 트레이트는 "이 클로저를 어떻게 호출할 수 있는가"를 나타낸다. 호출 메서드의 self 형태가 서로 다르다.
| 트레이트 | 호출 시 self | 호출 횟수 | 본문에서 하는 일 |
|---|---|---|---|
Fn | &self | 여러 번, 동시에 가능 | 캡처한 값을 읽기만 한다 |
FnMut | &mut self | 여러 번, 한 번에 하나씩 | 캡처한 값을 바꾼다 |
FnOnce | self | 한 번 | 캡처한 값을 밖으로 내보내거나 소비한다 |
세 트레이트는 포함 관계다. Fn을 구현한 클로저는 FnMut도, FnOnce도 구현한다. 읽기만 하는 클로저는 가변 호출 자리에도 한 번 호출하는 자리에도 쓸 수 있기 때문이다. 반대로 값을 소비하는 클로저는 FnOnce만 구현한다. 표준 라이브러리에서도 FnMut: FnOnce, Fn: FnMut로 선언돼 있다. 정확한 정의는 표준 라이브러리 문서의 Fn 항목에서 확인할 수 있다.
함수 인자로 받을 때
받는 함수는 자신이 클로저를 쓰는 방식에 맞춰 가장 약한 경계를 고른다. 한 번만 호출하면 FnOnce, 여러 번 호출하면서 상태 변경을 허용하면 FnMut, 여러 곳에서 공유해 호출해야 하면 Fn이다. 경계가 약할수록 호출하는 쪽이 넘길 수 있는 클로저가 늘어난다. 본문에서 count_where는 순회하며 여러 번 호출하되 상태를 바꿀 이유가 없으므로 Fn을 받는다. for_each_sorted는 호출자가 합계나 목록에 누적할 수 있도록 FnMut를 받는다. run_once는 한 번만 호출하므로 FnOnce를 받는다.
인자 타입은 보통 제네릭 F: Fn(...) 형태로 쓴다. 이 경우 클로저마다 F가 단형화되어 호출이 인라인될 수 있다. 같은 자리를 &dyn Fn(...)으로 받으면 코드 크기는 줄지만 호출이 간접 호출이 된다. 어느 쪽이 맞는지는 앞서 트레이트 객체를 다룬 장의 기준과 같다.
반환값으로 돌려줄 때
클로저의 타입은 이름이 없고 클로저마다 다르다. 그래서 반환 타입은 impl Fn(...)이나 Box<dyn Fn(...)> 중 하나로 적는다.
| 항목 | impl Fn | Box<dyn Fn> |
|---|---|---|
| 구체 타입 | 컴파일 때 하나로 고정 | 실행 때 달라질 수 있음 |
| 힙 할당 | 없음 | 있음 |
| 호출 방식 | 직접 호출, 인라인 가능 | vtable 을 거친 간접 호출 |
| 서로 다른 클로저를 한 컬렉션에 | 불가 | 가능 |
impl Fn은 함수가 항상 같은 모양의 클로저를 돌려줄 때 쓴다. 분기마다 다른 클로저를 돌려주거나 서로 다른 클로저를 Vec에 담아야 하면 Box<dyn Fn>이 필요하다. 두 분기가 각각 클로저를 만들면 둘은 서로 다른 타입이므로 impl Fn 하나로는 돌려줄 수 없다.
완성 코드
아래 프로그램은 src/main.rs 한 파일이다. 재고를 HashMap에 두고, 순회는 Stock의 메서드에 맡기며, 판단 기준은 클로저로 넘긴다.
use std::collections::HashMap;
type Rule = Box<dyn Fn(&str, u32) -> Option<String>>;
struct Stock {
items: HashMap<String, u32>,
}
impl Stock {
fn new() -> Self {
Stock {
items: HashMap::new(),
}
}
fn add(&mut self, sku: &str, qty: u32) {
*self.items.entry(sku.to_string()).or_insert(0) += qty;
}
fn sorted(&self) -> Vec<(&String, &u32)> {
let mut pairs: Vec<_> = self.items.iter().collect();
pairs.sort();
pairs
}
fn count_where<F: Fn(&str, u32) -> bool>(&self, pred: F) -> usize {
self.items
.iter()
.filter(|(sku, qty)| pred(sku.as_str(), **qty))
.count()
}
fn for_each_sorted<F: FnMut(&str, u32)>(&self, mut visit: F) {
for (sku, qty) in self.sorted() {
visit(sku, *qty);
}
}
}
fn repeat<F: FnMut()>(times: u32, mut action: F) {
for _ in 0..times {
action();
}
}
fn run_once<F: FnOnce() -> String>(make: F) -> String {
make()
}
fn at_least(min: u32) -> impl Fn(u32) -> bool {
move |qty| qty >= min
}
fn id_generator(prefix: String, start: u32) -> impl FnMut() -> String {
let mut n = start;
move || {
n += 1;
format!("{prefix}-{n}")
}
}
fn max_qty_rule(limit: u32) -> Rule {
Box::new(move |_sku: &str, qty: u32| {
if qty > limit {
Some(format!("수량 {qty}이 한도 {limit}을 넘는다"))
} else {
None
}
})
}
fn blocked_rule(blocked: Vec<String>) -> Rule {
Box::new(move |sku: &str, _qty: u32| {
if blocked.iter().any(|b| b.as_str() == sku) {
Some(format!("{sku}은 판매 중지"))
} else {
None
}
})
}
fn main() {
let mut stock = Stock::new();
stock.add("bolt", 120);
stock.add("nut", 15);
stock.add("washer", 40);
stock.add("bolt", 30);
let low_limit = 50;
let low = stock.count_where(|_, qty| qty < low_limit);
println!("부족 품목 수: {low}");
let mut total = 0;
stock.for_each_sorted(|_, qty| total += qty);
println!("총 재고: {total}");
let mut lines: Vec<String> = Vec::new();
stock.for_each_sorted(|sku, qty| lines.push(format!("{sku}={qty}")));
println!("{}", lines.join(", "));
let enough = at_least(40);
let enough_count = stock.count_where(|_, qty| enough(qty));
println!("40개 이상 품목 수: {enough_count}");
let mut restock_calls = 0;
repeat(3, || restock_calls += 1);
println!("보충 호출: {restock_calls}");
let mut next_id = id_generator("ORD".to_string(), 100);
let first = next_id();
let second = next_id();
println!("{first} {second}");
let summary = format!("재고 {}종", stock.items.len());
let handoff = move || summary;
let text = run_once(handoff);
println!("{text}");
let rules: Vec<Rule> = vec![
max_qty_rule(100),
blocked_rule(vec!["gasket".to_string()]),
];
let orders = [("bolt", 30), ("gasket", 5), ("nut", 500)];
for (sku, qty) in orders {
let reasons: Vec<String> = rules.iter().filter_map(|rule| rule(sku, qty)).collect();
if reasons.is_empty() {
println!("{sku} x{qty}: 접수");
} else {
println!("{sku} x{qty}: 거절 ({})", reasons.join("; "));
}
}
}
줄별 해설
type Rule은 긴 타입에 이름을 붙인 별칭이다. 규칙은 상품 코드와 수량을 받아 거절 사유가 있으면 Some(문자열), 없으면 None을 돌려준다. 규칙마다 캡처하는 값이 달라 타입이 다르므로, 한 Vec에 담으려고 Box<dyn Fn>으로 통일했다.
sorted는 HashMap의 순회 순서가 일정하지 않으므로 키와 값 쌍을 모아 정렬한다. 출력이 항상 같아야 해서 순회하는 메서드는 모두 이 함수를 거친다.
count_where의 filter도 클로저를 받는다. 그 안에서 바깥 클로저 pred를 호출한다. pred는 Fn이므로 filter의 클로저가 pred를 공유 빌림으로 캡처한다. for_each_sorted의 visit는 FnMut라서 mut로 선언해야 호출할 수 있다.
repeat와 run_once는 각각 FnMut와 FnOnce 경계의 최소 예제다. run_once는 make()를 한 번만 부르고, 호출하면 make의 소유권이 소비된다.
at_least는 min을 move로 클로저에 넣어 돌려준다. u32는 Copy라서 이동이라기보다 복사다. move를 빼면 함수가 끝난 뒤 사라질 min을 빌리게 되어 컴파일되지 않는다. 뒤의 "자주 틀리는 것"에서 다시 본다.
id_generator의 클로저는 prefix와 n을 모두 move로 가져가며, 호출할 때마다 n을 바꾼다. 상태를 바꾸므로 FnMut이고 반환 타입도 impl FnMut() -> String이다. 호출하는 쪽에서 next_id를 let mut로 선언한 이유가 이것이다. 접두사를 &str이 아니라 String으로 받은 것도 이유가 있다. edition 2024에서는 impl Trait 반환 타입이 인자의 수명을 자동으로 붙들기 때문에, 소유한 값으로 받으면 반환된 클로저가 인자에 묶이지 않는다.
max_qty_rule과 blocked_rule은 클로저를 Box::new로 힙에 올려 Rule로 돌려준다. 클로저 매개변수에 &str, u32를 직접 적은 것은 인자의 수명 관계를 컴파일러가 확실히 추론하게 하기 위해서다. blocked_rule의 클로저는 Vec 전체를 가져가고, 본문에서는 읽기만 하므로 Fn이다.
main의 첫 부분을 차례로 보자. |_, qty| qty < low_limit은 low_limit을 읽기만 하므로 공유 빌림 캡처다. 호출이 끝난 뒤에도 low_limit을 쓸 수 있다. |_, qty| total += qty는 total을 가변 빌림으로 캡처한다. 클로저는 인자로 넘겨져 호출이 끝나면 사라지므로, 다음 줄의 println에서 total을 읽을 수 있다. lines.push(...)도 같은 모양이다.
enough는 impl Fn이라 count_where 클로저 안에서 몇 번이든 호출된다. repeat(3, || restock_calls += 1)은 FnMut 경계에 가변 캡처 클로저를 넘기는 예다.
let handoff = move || summary;는 캡처한 String을 반환값으로 내보내므로 FnOnce만 구현한다. run_once(handoff)로 넘기면 한 번 호출되고, 이후 handoff는 쓸 수 없다.
마지막 반복문에서 rules.iter()가 돌려주는 것은 &Box<dyn Fn>이고 rule(sku, qty)로 바로 호출된다. filter_map은 앞 장의 어댑터 그대로이며 None인 결과를 걸러 사유만 모은다. 사유가 비면 접수, 하나라도 있으면 거절한다.
실행 결과
$ cargo run
부족 품목 수: 2
총 재고: 205
bolt=150, nut=15, washer=40
40개 이상 품목 수: 2
보충 호출: 3
ORD-101 ORD-102
재고 3종
bolt x30: 접수
gasket x5: 거절 (gasket은 판매 중지)
nut x500: 거절 (수량 500이 한도 100을 넘는다)
실무에서 자주 틀리는 것
move 한 값을 원본으로 다시 쓴다
틀린 코드는 다음과 같다.
let names = vec![String::from("bolt")];
let show = move || println!("{}", names.len());
show();
println!("{}", names.len());
컴파일러는 error[E0382]: borrow of moved value: names를 낸다. move로 names가 클로저 안으로 옮겨졌기 때문이다. 클로저가 지금 함수 안에서만 쓰이면 move를 빼면 된다. 소유권이 정말 필요하면 클로저를 만들기 전에 복제본을 넘긴다.
let names = vec![String::from("bolt")];
let show = || println!("{}", names.len());
show();
println!("{}", names.len());
상태를 바꾸는 클로저를 Fn 자리에 넘긴다
fn call_twice<F: Fn()>(f: F) {
f();
f();
}
let mut n = 0;
call_twice(|| n += 1);
error[E0594]: cannot assign to n, as it is a captured variable in a Fn closure가 나온다. 경계가 Fn이면 클로저가 값을 바꿀 수 없다. 함수가 정말 상태 변경을 허용해야 하면 경계를 FnMut로 바꾸고 매개변수를 mut f로 선언한다.
fn call_twice<F: FnMut()>(mut f: F) {
f();
f();
}
지역 변수를 빌린 클로저를 반환한다
fn at_least(min: u32) -> impl Fn(u32) -> bool {
|qty| qty >= min
}
error[E0373]: closure may outlive the current function, but it borrows min가 나온다. 클로저가 min을 빌린 채 함수 밖으로 나가려 하기 때문이다. 컴파일러가 move를 붙이라고 안내한다.
fn at_least(min: u32) -> impl Fn(u32) -> bool {
move |qty| qty >= min
}
서로 다른 클로저를 그냥 Vec 에 담는다
let low = 5;
let high = 100;
let checks = vec![move |q: u32| q >= low, move |q: u32| q <= high];
error[E0308]: mismatched types가 나오고 "no two closures, even if identical, have the same type"라는 설명이 붙는다. 캡처하는 클로저는 하나하나가 서로 다른 타입이다. 원소 타입을 트레이트 객체로 명시하면 담을 수 있다.
let low = 5;
let high = 100;
let checks: Vec<Box<dyn Fn(u32) -> bool>> = vec![
Box::new(move |q| q >= low),
Box::new(move |q| q <= high),
];
한눈에 보기
| 개념 | 핵심 | 쓰는 자리 |
|---|---|---|
| 빌림 캡처 | 본문이 읽기만 하면 &T | 임계값 비교, 조건 검사 |
| 가변 빌림 캡처 | 본문이 값을 바꾸면 &mut T | 합계, 목록 누적 |
| 이동 캡처 | 값을 소비하면 이동, move는 강제 이동 | 클로저를 반환하거나 오래 보관할 때 |
Fn / FnMut / FnOnce | Fn ⊂ FnMut ⊂ FnOnce | 인자 경계는 가장 약한 것을 고른다 |
impl Fn 반환 | 타입 하나, 힙 할당 없음 | 항상 같은 모양의 클로저를 돌려줄 때 |
Box<dyn Fn> | 타입 소거, 힙 할당, 간접 호출 | 서로 다른 클로저를 모으거나 분기할 때 |
연습 문제
- 다음 세 클로저가
Fn,FnMut,FnOnce중 각각 어디까지 구현하는지 쓰라. (a)|sku: &str| sku.len()(b)let mut count = 0;뒤에 오는move || { count += 1; count }(c)names가Vec<String>일 때move || names - 단가와 할인율을 다루는 함수
make_discount(rate_percent: u32)를 작성하라. 반환값은 단가를 받아 할인가를 돌려주는 클로저다.impl Fn을 쓰고, 할인율 25에 단가 200이면 150이 나와야 한다. Vec<Box<dyn Fn(u32) -> bool>>에 "10 이상"과 "100 이하" 조건을 담고, 모든 조건을 만족하는지 검사하는 함수all_pass를 작성하라.- 본문의
let mut next_id = id_generator(...)에서mut를 지우면 컴파일이 실패한다. 이유를 설명하라.
정답과 해설
1번. (a)는 캡처하는 값이 없고 읽기만 하므로 세 트레이트를 모두 구현한다. (b)는 move로 가져간 count를 호출마다 바꾸므로 FnMut와 FnOnce를 구현하고 Fn은 아니다. count는 Copy라서 바깥 원본은 바뀌지 않는다. (c)는 캡처한 Vec을 반환값으로 내보내므로 FnOnce만 구현한다.
2번.
fn make_discount(rate_percent: u32) -> impl Fn(u32) -> u32 {
move |price| price * (100 - rate_percent) / 100
}
fn main() {
let discount = make_discount(25);
println!("{}", discount(200));
}
출력은 150이다. rate_percent는 함수가 끝나면 사라지므로 move가 필요하다.
3번.
fn all_pass(checks: &[Box<dyn Fn(u32) -> bool>], value: u32) -> bool {
checks.iter().all(|check| check(value))
}
fn main() {
let checks: Vec<Box<dyn Fn(u32) -> bool>> = vec![
Box::new(|q| q >= 10),
Box::new(|q| q <= 100),
];
println!("{} {}", all_pass(&checks, 50), all_pass(&checks, 5));
}
출력은 true false다. 두 클로저는 타입이 다르지만 Box<dyn Fn>으로 통일돼 한 Vec에 들어간다. all은 조건 하나라도 거짓이면 멈춘다.
4번. 이 클로저는 호출할 때마다 캡처한 n을 바꾸므로 FnMut다. FnMut를 호출하려면 &mut self가 필요하고, 그러려면 클로저를 담은 변수가 가변이어야 한다. 클로저도 값이므로 내부 상태를 바꾸는 호출은 그 값을 가변으로 빌리는 일이다. 컴파일러는 "cannot borrow next_id as mutable, as it is not declared as mutable" 계열의 오류(E0596)를 낸다.
READER FEEDBACK
질문·의견
내용에 관한 질문이나 더 나은 설명을 위한 의견을 남겨 주세요. 오탈자는 위의 제보 양식이 더 빨리 반영됩니다. 이 댓글은 원래 게시글과 같은 자리에 쌓입니다.
댓글 0
아직 댓글이 없습니다. 첫 댓글을 남겨 보세요.