2024-08-18
HSPACE Rust 특강 #8 — 클로저
클로저는 다른 언어에도 다 있는 기능이지만, Rust에서는 소유권과 만나면서 훨씬 흥미로워진다. 이번 회차의 핵심 질문은 하나다. 클로저가 캡처한 변수는 누구 것인가?
변수 캡처하기
클로저는 주변 스코프의 변수를 캡처한다. 그리고 캡처 방식은 두 가지다.
빌리는 클로저
기본은 빌림이다.
let mut cities = vec!["Seoul", "Busan", "Incheon"];
let print_first = || println!("{}", cities[0]); // cities를 &로 빌림
print_first();
println!("{:?}", cities); // 여전히 쓸 수 있다
컴파일러는 클로저 본문이 변수를 어떻게 쓰는지 보고 필요한 최소한의 방식으로
캡처한다. 읽기만 하면 &T, 수정하면 &mut T 다.
let mut counter = 0;
let mut increment = || counter += 1; // counter를 &mut로 빌림
increment();
increment();
println!("{counter}"); // 2
increment 자체가 mut 여야 한다는 게 처음엔 이상했다. 클로저가 가변 빌림을
들고 있으니, 클로저를 호출하는 것 자체가 상태를 바꾸는 행위이기 때문이다.
그리고 이 동안에는 원본을 못 쓴다.
let mut counter = 0;
let mut increment = || counter += 1;
// println!("{counter}"); // 에러: increment가 가변으로 빌리고 있다
increment();
println!("{counter}"); // OK — increment의 빌림이 여기서 끝났다
빌림 규칙이 클로저에도 그대로 적용된다. 특별한 예외가 없다.
훔치는 클로저
move 키워드를 붙이면 캡처한 값의 소유권을 가져간다.
let cities = vec!["Seoul", "Busan", "Incheon"];
let print_all = move || println!("{:?}", cities); // cities를 이동
print_all();
// println!("{:?}", cities); // 에러: cities는 클로저로 이동됨
왜 이게 필요한가? 클로저가 원본 변수보다 오래 살아야 할 때다. 대표적인 게 스레드다.
use std::thread;
let data = vec![1, 2, 3];
thread::spawn(move || {
println!("{:?}", data); // 새 스레드에서 사용
}).join().unwrap();
move 없이 빌리기만 하면, 원래 스레드의 스택 프레임이 먼저 사라질 수 있으므로
컴파일러가 거부한다. thread::spawn 이 'static 을 요구하는 이유다.
Copy 타입은 move 를 써도 원본이 남는다. 이동이 아니라 복사가 일어나기 때문이다.
Fn, FnMut, FnOnce
클로저의 타입은 컴파일러가 자동 생성하는 익명 구조체다. 그래서 함수 인자로 받으려면 트레잇 바운드로 받아야 하고, 세 가지가 있다.
| 트레잇 | 캡처를 어떻게 쓰는가 | 호출 |
|---|---|---|
FnOnce | 캡처한 값을 소비 | 딱 한 번 |
FnMut | 캡처한 값을 수정 | 여러 번 (호출자가 mut 로 들고 있어야) |
Fn | 캡처한 값을 읽기만 | 여러 번 |
포함 관계가 중요하다. Fn ⊂ FnMut ⊂ FnOnce. 읽기만 하는 클로저는 수정도
소비도 요구하지 않으므로 셋 다 만족한다. 그래서 함수를 쓸 때는 가장 느슨한
바운드를 쓰는 게 좋다.
첫 회차에서 봤던 예제가 여기서 완전히 설명된다.
fn call_twice<F>(closure: F) where F: Fn() {
closure();
closure();
}
let name = String::from("Chris");
let hello = || {
println!("Hello, {name}");
drop(name); // name을 소비 → FnOnce
};
call_twice(hello); // 에러: FnOnce는 Fn을 만족하지 않는다
drop(name) 이 캡처를 소비하니 이 클로저는 두 번 호출될 수 없다. 컴파일러가
FnOnce 로 분류하고, Fn 을 요구하는 call_twice 가 거부한다. "두 번 호출하면
해제된 메모리를 쓴다"는 사실이 타입으로 잡힌다.
여기서 헷갈렸던 걸 정리하면, move 와 FnOnce 는 별개다.
let s = String::from("hi");
let f = move || println!("{s}"); // move지만 s를 읽기만 함 → Fn
f();
f(); // 여러 번 호출 가능
move 는 "어떻게 캡처하는가"이고, Fn/FnMut/FnOnce 는 "캡처한 것을 본문에서
어떻게 쓰는가"다. 소유권을 가져왔어도 읽기만 하면 Fn 이다.
드롭하는 클로저
클로저가 값을 소유하면, 클로저가 드롭될 때 그 값도 드롭된다.
let name = String::from("Chris");
let f = move || println!("{name}");
drop(f); // 여기서 name도 해제된다
클로저를 "값을 담은 구조체"로 보면 당연한 결과다. 실제로 컴파일러가 만드는 것이 정확히 그것이다.
클로저의 성능
클로저가 비쌀까? 결론부터: 거의 항상 비용이 0이다.
클로저는 익명 구조체이고, 캡처한 변수가 그 구조체의 필드가 된다. 힙 할당은 없다.
Box<dyn Fn> 으로 감쌀 때만 할당이 생긴다.
제네릭으로 받으면 (fn call<F: Fn()>(f: F)) 단형화되어 호출 지점이 정적으로
결정되고, 대부분 인라인된다.
let v: Vec<i32> = (1..=100).collect();
let sum: i32 = v.iter().filter(|&&x| x % 2 == 0).map(|&x| x * x).sum();
이 이터레이터 체인은 클로저 두 개와 어댑터 세 개를 쓰지만, 최적화된 빌드에서는
수동으로 쓴 for 루프와 같은 어셈블리가 나온다. 이게 Rust가 말하는 "제로 비용
추상화" 다. 앞에서 Rust Playground의 ASM 출력을 소개한 이유가 여기서 드러난다.
믿을 필요 없이 직접 확인할 수 있다.
클로저의 안전성
C++의 람다와 비교하면 차이가 확실하다.
// C++ — 컴파일되고, 실행하면 댕글링 참조
auto make_counter() {
int count = 0;
return [&count]() { return ++count; }; // count는 함수가 끝나면 사라진다
}
Rust에서는 같은 코드가 컴파일되지 않는다.
fn make_counter() -> impl FnMut() -> i32 {
let mut count = 0;
move || { count += 1; count } // move가 없으면 에러
}
move 로 count 를 클로저 안으로 가져와야 한다. 그러면 count 는 클로저가
소유하므로 함수가 끝나도 살아 있다. C++에서 [&] 와 [=] 중 무엇을 쓸지 신중히
골라야 했던 문제가, 여기서는 틀리면 컴파일이 안 되는 문제로 바뀐다.
콜백 저장하기: 두 가지 방법
클로저를 자료구조에 넣으려면 크기를 알아야 하는데, 클로저마다 타입이 다르다. 예제로 나온 라우터가 이 문제를 잘 보여준다.
type BoxedCallback = Box<dyn Fn(&Request) -> Response>;
struct BasicRouter {
routes: HashMap<String, BoxedCallback>,
}
impl BasicRouter {
fn new() -> BasicRouter {
BasicRouter { routes: HashMap::new() }
}
fn add_route<C>(&mut self, url: &str, callback: C)
where
C: Fn(&Request) -> Response + 'static,
{
self.routes.insert(url.to_string(), Box::new(callback));
}
fn handle_request(&self, request: &Request) -> Response {
match self.routes.get(&request.url) {
None => not_found_response(),
Some(callback) => callback(request),
}
}
}
let mut router = BasicRouter::new();
router.add_route("/", |_| get_form_response());
router.add_route("/gcd", |req| get_gcd_response(req));
assert_eq!(router.handle_request(&req("/piano")).code, 404);
assert_eq!(router.handle_request(&req("/")).code, 200);
assert_eq!(router.handle_request(&req("/gcd")).code, 500);
여기서 두 가지 기법이 함께 쓰인다.
add_route<C>는 제네릭 — 각 클로저의 구체 타입을 그대로 받는다- 저장은
Box<dyn Fn(...)>— 서로 다른 타입의 클로저를 하나의HashMap에 담기 위해 트레잇 오브젝트로 지운다
'static 바운드는 "이 클로저는 빌린 레퍼런스를 들고 있지 않다"는 요구다. 라우터가
얼마나 오래 살지 모르니 캡처한 것들이 라우터보다 먼저 죽으면 안 된다.
정리하면 이렇다.
impl Fn(...)반환 / 제네릭 매개변수 — 정적 디스패치. 빠르고 인라인 가능. 타입 하나만 다룰 때Box<dyn Fn(...)>— 동적 디스패치. 힙 할당 + 간접 호출. 여러 종류를 한 컨테이너에 담을 때
과제
이번 과제들이 클로저의 성격을 정확히 겨냥한다.
1. Cache System — 계산 결과를 기억하는 클로저 만들기.
fn check_cache<F>(func: F) -> impl FnMut(i64) -> (i64, bool)
where
F: Fn(i64) -> i64,
{
let mut cache = HashMap::new();
move |x| {
if let Some(&ret) = cache.get(&x) {
(ret, true) // 캐시 적중
} else {
let ret = func(x);
cache.insert(x, ret); // 캐시를 수정 → FnMut
(ret, false)
}
}
}
let mut compute_func = check_cache(|x| x * x);
assert_eq!(compute_func(4), (16, false)); // 계산
assert_eq!(compute_func(4), (16, true)); // 캐시 사용
assert_eq!(compute_func(5), (25, false)); // 계산
이 짧은 함수에 이번 회차가 다 들어 있다. 반환 타입이 impl FnMut 인 이유는
클로저가 cache 를 수정하기 때문이고, move 가 필요한 이유는 cache 가
함수보다 오래 살아야 하기 때문이다. 그리고 인자 func 는 Fn 이면 충분하다 —
읽기만 하므로 가장 느슨한 바운드를 골랐다.
호출하는 쪽에서 let mut compute_func 이라고 mut 를 붙여야 한다는 것도 자연스럽게
따라온다. FnMut 을 호출하는 건 상태를 바꾸는 일이니까.
2. Order Filtering System — 조건을 클로저로 받아 주문을 거르는 시스템.
3. State-Tracking Task Scheduler — 상태를 가진 태스크들을 등록하고 순서대로 실행하는 스케줄러. 요구사항이 재미있다.
type SharedState = Rc<RefCell<i32>>;
type Task = Box<dyn FnMut() -> String>;
pub struct TaskScheduler {
tasks: Vec<Task>,
shared_state: SharedState,
}
impl TaskScheduler {
pub fn register_task<F>(&mut self, task: F)
where
F: FnMut() -> String + 'static,
{
self.tasks.push(Box::new(task));
}
pub fn execute_tasks(&mut self) -> Vec<String> {
let mut ret = Vec::new();
for task in self.tasks.iter_mut() {
let result = task();
ret.push(result.clone());
if result == "fail" {
break; // 실패하면 중단
}
}
ret
}
pub fn restart(&mut self) -> Vec<String> {
*self.shared_state.borrow_mut() = 0;
self.execute_tasks()
}
}
Vec<Box<dyn FnMut() -> String>> 에 서로 다른 클로저를 담고, iter_mut() 로
가변 순회하며 호출한다. FnMut 이므로 iter() 가 아니라 iter_mut() 여야 한다.
여러 태스크가 상태를 공유하는 부분은 Rc<RefCell<i32>> 다. Rc 로 여러 클로저가
같은 상태를 소유하고, RefCell 로 불변 레퍼런스를 통해서도 값을 바꾼다
(내부 가변성). 빌림 검사가 컴파일 시점에서 런타임으로 옮겨가고, 규칙을
어기면 패닉한다.
왜 여기서 RefCell 이 필요한가? Rc<T> 는 &T 만 주는데, 태스크는 상태를 바꿔야
한다. 컴파일러는 여러 Rc 중 누가 언제 접근하는지 알 수 없으므로 정적으로 허용할
수 없고, 그래서 검사를 런타임으로 미루는 것이다. 소유권 규칙을 우회하는 게 아니라
검사 시점을 바꾸는 것이라는 점이 중요하다.
정리
- 클로저는 기본적으로 빌리고,
move를 붙이면 소유권을 가져간다 Fn/FnMut/FnOnce는 본문에서 캡처를 어떻게 쓰는지로 결정된다move와FnOnce는 다른 축이다- 클로저는 익명 구조체라 힙 할당이 없고, 제네릭으로 받으면 인라인된다
- 여러 클로저를 한 컨테이너에 담으려면
Box<dyn Fn...> - 상태 공유가 필요하면
Rc<RefCell<T>>— 빌림 검사가 런타임으로
다음 편은 동시성 — 스레드, 채널, 뮤텍스, 그리고 Send/Sync.