DaleSchool

클로저: 이름 없는 함수

중급⏱ 15분

학습 목표

  • ✓클로저 문법(|x| x + 1)을 이해하고 사용할 수 있다
  • ✓클로저가 주변 환경을 캡처하는 방식을 설명할 수 있다
  • ✓Fn / FnMut / FnOnce의 차이를 간단히 구분할 수 있다

동작하는 코드

Rust에서는 이름 없는 함수를 만들 수 있어요. 이걸 클로저(closure) 라고 불러요.

fn main() {
    let add_one = |x: i32| x + 1;
    println!("{}", add_one(5)); // 6

    // 여러 줄도 가능해요
    let add_and_print = |a: i32, b: i32| -> i32 {
        let sum = a + b;
        println!("{a} + {b} = {sum}");
        sum
    };
    add_and_print(3, 4); // 3 + 4 = 7
}

|매개변수| 본문 — 이게 클로저의 기본 형태예요. 일반 함수와 비교해볼까요?

// 일반 함수
fn add_one_fn(x: i32) -> i32 {
    x + 1
}

// 클로저
let add_one = |x: i32| x + 1;

클로저는 타입을 생략할 수도 있어요. 컴파일러가 추론해줘요.

fn main() {
    let numbers = vec![1, 2, 3, 4, 5];
    let doubled: Vec<i32> = numbers.iter().map(|x| x * 2).collect();
    println!("{:?}", doubled); // [2, 4, 6, 8, 10]
}

직접 해보기

  1. |x| x * x로 제곱을 계산하는 클로저를 만들어보세요.
  2. |a, b| if a > b { a } else { b }로 두 수 중 큰 값을 반환하는 클로저를 만들어보세요.

"왜?" — 클로저는 주변을 기억해요

클로저가 일반 함수와 다른 핵심 포인트는 환경 캡처예요. 클로저는 자신이 정의된 곳의 변수를 "잡아서" 사용할 수 있어요.

fn main() {
    let threshold = 10;

    // 클로저가 바깥의 threshold를 캡처!
    let is_big = |x: i32| x > threshold;

    println!("{}", is_big(5));   // false
    println!("{}", is_big(15));  // true
}

일반 함수에서는 이렇게 할 수 없어요. threshold를 매개변수로 명시적으로 넘겨야 해요.

캡처 방식 3가지

클로저가 변수를 캡처하는 방식에 따라 트레이트가 달라져요. 소유권 규칙과 연결되는 개념이에요!

| 캡처 방식 | 트레이트 | 비유 | 예시 | | ---------------- | -------- | ---------------- | ----- | --- | ------------------ | | 불변 참조로 빌림 | Fn | 책을 읽기만 함 | | x | x + threshold | | 가변 참조로 빌림 | FnMut | 책에 메모를 적음 | | x | { count += 1; x } | | 소유권을 가져감 | FnOnce | 책을 가져가 버림 | move | x | { drop(name); x } |

fn main() {
    // Fn — 불변 참조로 캡처
    let name = String::from("Rust");
    let greet = || println!("안녕, {name}!");
    greet();
    greet(); // 여러 번 호출 가능
    println!("{name}"); // name 아직 사용 가능

    // FnMut — 가변 참조로 캡처
    let mut count = 0;
    let mut increment = || {
        count += 1;
        println!("count: {count}");
    };
    increment(); // count: 1
    increment(); // count: 2

    // FnOnce — 소유권을 가져감
    let message = String::from("bye");
    let consume = move || {
        println!("{message}");
        // message의 소유권이 클로저 안으로 이동
    };
    consume();
    // consume(); // 두 번째 호출도 가능할 수 있지만...
    // println!("{message}"); // ❌ message는 이미 이동됨
}

팁: 대부분의 경우 Rust가 캡처 방식을 자동으로 결정해요. Fn → FnMut → FnOnce 순서로 가장 제한적인 것부터 시도해요. 직접 지정할 필요는 거의 없어요!

move 키워드

move를 붙이면 클로저가 캡처하는 모든 변수의 소유권을 강제로 가져가요.

fn main() {
    let name = String::from("민수");

    // move 없이: name을 빌려서 사용
    let greet = || println!("안녕, {name}!");
    greet();
    println!("아직 사용 가능: {name}");

    // move 있으면: name의 소유권이 클로저로 이동
    let greet_move = move || println!("안녕, {name}!");
    greet_move();
    // println!("{name}"); // ❌ 소유권이 이동됨
}

move는 스레드에 데이터를 넘길 때 특히 중요해요. 나중에 배울 내용이에요!

클로저를 인자로 받는 함수

클로저를 다른 함수에 전달할 수 있어요. 이때 트레이트 바운드를 사용해요.

fn apply_twice<F: Fn(i32) -> i32>(f: F, x: i32) -> i32 {
    f(f(x))
}

fn main() {
    let double = |x| x * 2;
    let result = apply_twice(double, 3);
    println!("{result}"); // 12 (3 → 6 → 12)

    // 일반 함수도 넘길 수 있어요
    fn add_ten(x: i32) -> i32 { x + 10 }
    let result2 = apply_twice(add_ten, 5);
    println!("{result2}"); // 25 (5 → 15 → 25)
}

모듈 21에서 배운 제네릭과 트레이트 바운드가 여기서 쓰여요! F: Fn(i32) -> i32는 "i32를 받아서 i32를 반환하는 함수(또는 클로저)"라는 뜻이에요.

실전 활용: 캐시 클로저

클로저와 구조체를 결합하면 캐시(memoization) 패턴을 만들 수 있어요.

struct Cacher<F: Fn(i32) -> i32> {
    calculation: F,
    value: Option<i32>,
}

impl<F: Fn(i32) -> i32> Cacher<F> {
    fn new(calculation: F) -> Cacher<F> {
        Cacher {
            calculation,
            value: None,
        }
    }

    fn get(&mut self, arg: i32) -> i32 {
        match self.value {
            Some(v) => v,
            None => {
                let v = (self.calculation)(arg);
                self.value = Some(v);
                v
            }
        }
    }
}

fn main() {
    let mut expensive = Cacher::new(|num| {
        println!("계산 중...");
        num * 2
    });

    println!("결과: {}", expensive.get(5)); // "계산 중..." 출력
    println!("결과: {}", expensive.get(5)); // 캐시된 값 사용, 출력 없음
}

비용이 큰 계산을 한 번만 실행하고 결과를 저장해둘 수 있어요!

연습 1. 아래 함수를 완성하세요. 벡터의 각 요소에 클로저를 적용한 새 벡터를 반환해요.

fn transform<F: Fn(i32) -> i32>(numbers: &[i32], f: F) -> Vec<i32> {
    // 여기를 완성하세요
    // 힌트: 빈 Vec을 만들고 for 루프로 f(n)을 push
}

fn main() {
    let nums = vec![1, 2, 3, 4, 5];
    let squared = transform(&nums, |x| x * x);
    let tripled = transform(&nums, |x| x * 3);
    println!("제곱: {:?}", squared);  // [1, 4, 9, 16, 25]
    println!("3배: {:?}", tripled);   // [3, 6, 9, 12, 15]
}

연습 2. 아래 코드에서 filter_by 함수를 완성하세요. 조건을 만족하는 요소만 모아서 반환해요.

fn filter_by<F: Fn(&i32) -> bool>(numbers: &[i32], predicate: F) -> Vec<i32> {
    // 여기를 완성하세요
}

fn main() {
    let nums = vec![1, 2, 3, 4, 5, 6, 7, 8, 9, 10];
    let evens = filter_by(&nums, |x| x % 2 == 0);
    let big = filter_by(&nums, |x| *x > 5);
    println!("짝수: {:?}", evens);  // [2, 4, 6, 8, 10]
    println!("5 초과: {:?}", big);  // [6, 7, 8, 9, 10]
}

클로저와 일반 함수의 가장 큰 차이는?