</>PatchNote
목록으로

2024-08-18

HSPACE Rust 특강 #8 — 클로저

RustHSPACE 2024ClosuresTraits

클로저는 다른 언어에도 다 있는 기능이지만, 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캡처한 값을 읽기만여러 번

포함 관계가 중요하다. FnFnMutFnOnce. 읽기만 하는 클로저는 수정도 소비도 요구하지 않으므로 셋 다 만족한다. 그래서 함수를 쓸 때는 가장 느슨한 바운드를 쓰는 게 좋다.

첫 회차에서 봤던 예제가 여기서 완전히 설명된다.

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 가 거부한다. "두 번 호출하면 해제된 메모리를 쓴다"는 사실이 타입으로 잡힌다.

여기서 헷갈렸던 걸 정리하면, moveFnOnce 는 별개다.

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가 없으면 에러
}

movecount 를 클로저 안으로 가져와야 한다. 그러면 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 가 함수보다 오래 살아야 하기 때문이다. 그리고 인자 funcFn 이면 충분하다 — 읽기만 하므로 가장 느슨한 바운드를 골랐다.

호출하는 쪽에서 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본문에서 캡처를 어떻게 쓰는지로 결정된다
  • moveFnOnce 는 다른 축이다
  • 클로저는 익명 구조체라 힙 할당이 없고, 제네릭으로 받으면 인라인된다
  • 여러 클로저를 한 컨테이너에 담으려면 Box<dyn Fn...>
  • 상태 공유가 필요하면 Rc<RefCell<T>> — 빌림 검사가 런타임으로

다음 편은 동시성 — 스레드, 채널, 뮤텍스, 그리고 Send/Sync.