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가 캡처 방식을 자동으로 결정해요. FnFnMutFnOnce 순서로 가장 제한적인 것부터 시도해요. 직접 지정할 필요는 거의 없어요!

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]
}

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

  • A) 클로저는 반환값이 없다
  • B) 클로저는 주변 환경의 변수를 캡처할 수 있다
  • C) 클로저는 매개변수를 받을 수 없다
  • D) 일반 함수가 더 빠르다

Q2. 아래 코드에서 클로저의 캡처 방식은?

let mut total = 0;
let mut add = |x: i32| { total += x; };
add(5);
  • A) Fn — 불변 참조로 캡처
  • B) FnMut — 가변 참조로 캡처
  • C) FnOnce — 소유권을 가져감
  • D) 캡처하지 않음

Q3. move 키워드를 클로저 앞에 붙이면?

  • A) 클로저의 실행 속도가 빨라진다
  • B) 클로저가 캡처하는 변수의 소유권이 클로저로 이동한다
  • C) 클로저를 여러 번 호출할 수 없게 된다
  • D) 클로저가 값을 반환하지 않게 된다