Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

클로저

러스트의 클로저는 변수에 저장하거나 다른 함수의 인수로 넘길 수 있는 익명 함수입니다. 한곳에서 클로저를 만든 뒤, 나중에 다른 맥락에서 호출해 실행할 수 있습니다. 함수와 달리, 클로저는 자신이 정의된 스코프의 값을 캡처할 수 있습니다. 이 장에서는 이런 클로저의 성질이 코드 재사용과 동작 맞춤화를 어떻게 가능하게 하는지 보여 줍니다.

환경 캡처하기

먼저, 클로저가 정의된 환경의 값을 캡처해 나중에 사용할 수 있다는 사실을 살펴보겠습니다. 상황은 이렇습니다. 우리 티셔츠 회사는 가끔 홍보 이벤트로, 메일링 리스트에 있는 사람 가운데 한 명에게 한정판 티셔츠를 무료로 나눠 줍니다. 메일링 리스트 사용자는 자신의 프로필에 선호 색상을 선택적으로 적어 둘 수 있습니다. 무료 티셔츠 당첨자가 선호 색상을 설정해 두었다면 그 색상의 셔츠를 받고, 그렇지 않다면 회사에 현재 가장 많이 남아 있는 색상의 셔츠를 받습니다.

이 상황을 구현하는 방법은 여러 가지가 있습니다. 여기서는 클로저에 집중하기 위해, 클로저를 사용하는 giveaway 메서드 본문 외에는 여러분이 이미 배운 개념만 사용하겠습니다. 예제에서는 색상을 단순화하기 위해 RedBlue 두 variant를 가진 ShirtColor enum을 사용하고, 회사 재고는 현재 보유 중인 셔츠 색상을 담은 Vec<ShirtColor> 필드를 가진 Inventory 구조체로 표현합니다. Inventory 에 정의된 giveaway 메서드는 무료 셔츠 당첨자의 선택적 선호 색상을 받아, 그 사람이 받게 될 셔츠 색상을 반환합니다. 목록 13-1이 이 설정을 보여 줍니다.

Filename: src/main.rs
#[derive(Debug, PartialEq, Copy, Clone)]
enum ShirtColor {
    Red,
    Blue,
}

struct Inventory {
    shirts: Vec<ShirtColor>,
}

impl Inventory {
    fn giveaway(&self, user_preference: Option<ShirtColor>) -> ShirtColor {
        user_preference.unwrap_or_else(|| self.most_stocked())
    }

    fn most_stocked(&self) -> ShirtColor {
        let mut num_red = 0;
        let mut num_blue = 0;

        for color in &self.shirts {
            match color {
                ShirtColor::Red => num_red += 1,
                ShirtColor::Blue => num_blue += 1,
            }
        }
        if num_red > num_blue {
            ShirtColor::Red
        } else {
            ShirtColor::Blue
        }
    }
}

fn main() {
    let store = Inventory {
        shirts: vec![ShirtColor::Blue, ShirtColor::Red, ShirtColor::Blue],
    };

    let user_pref1 = Some(ShirtColor::Red);
    let giveaway1 = store.giveaway(user_pref1);
    println!(
        "The user with preference {:?} gets {:?}",
        user_pref1, giveaway1
    );

    let user_pref2 = None;
    let giveaway2 = store.giveaway(user_pref2);
    println!(
        "The user with preference {:?} gets {:?}",
        user_pref2, giveaway2
    );
}
Listing 13-1: 셔츠 회사 경품 이벤트 상황

main 안의 store 는 이번 한정판 이벤트에서 나눠 줄 수 있는 파란 셔츠 두 장과 빨간 셔츠 한 장을 가지고 있습니다. 우리는 빨간 셔츠를 선호하는 사용자 한 명과, 선호 색상이 없는 사용자 한 명에 대해 각각 giveaway 메서드를 호출합니다.

이 코드는 여러 방식으로 구현할 수 있지만, 여기서는 클로저에 집중하기 위해 giveaway 본문 외에는 이미 배운 개념만 사용했습니다. giveaway 메서드 안에서는 사용자의 선호 색상을 Option<ShirtColor> 타입 매개변수로 받고, 그 값에 unwrap_or_else 메서드를 호출합니다. 표준 라이브러리에 정의된 [Option<T>unwrap_or_else 메서드][unwrap-or-else]는 인수 하나를 받는데, 그 인수는 매개변수가 없는 클로저이며 값 T 를 반환합니다 (여기서는 Option<T>Some 안에 저장되는 타입이 ShirtColor 입니다). Option<T>Some variant 이면 unwrap_or_else 는 그 안의 값을 그대로 반환합니다. 반대로 Option<T>None 이면 unwrap_or_else 는 클로저를 호출하고, 클로저가 반환한 값을 반환합니다.

우리는 unwrap_or_else 에 인수로 || self.most_stocked() 라는 클로저 식을 전달했습니다. 이것은 스스로는 아무 매개변수도 받지 않는 클로저입니다(매개변수가 있었다면 두 개의 세로줄 사이에 나왔을 것입니다). 클로저 본문은 self.most_stocked() 를 호출합니다. 여기서 우리는 클로저를 정의 하고 있을 뿐이며, unwrap_or_else 의 구현이 나중에 필요할 때 이 클로저를 평가합니다.

이 코드를 실행하면 다음과 같이 출력됩니다.

$ cargo run
   Compiling shirt-company v0.1.0 (file:///projects/shirt-company)
    Finished `dev` profile [unoptimized + debuginfo] target(s) in 0.27s
     Running `target/debug/shirt-company`
The user with preference Some(Red) gets Red
The user with preference None gets Blue

여기서 흥미로운 점은, 현재 Inventory 인스턴스에 대해 self.most_stocked() 를 호출하는 클로저를 넘겼다는 것입니다. 표준 라이브러리는 우리가 정의한 InventoryShirtColor 타입에 대해 아무것도 알 필요가 없습니다. 클로저는 현재 self Inventory 인스턴스에 대한 불변 참조를 캡처하고, 우리가 지정한 코드와 함께 그 참조를 unwrap_or_else 메서드에 넘깁니다. 일반 함수는 이런 식으로 환경을 캡처할 수 없습니다.

클로저 타입 추론과 주석

함수와 클로저 사이에는 또 다른 차이도 있습니다. 클로저는 보통 함수(fn)처럼 매개변수 타입이나 반환값 타입을 주석으로 적을 필요가 없습니다. 함수는 사용자에게 노출되는 명시적인 인터페이스의 일부이기 때문에 타입 주석이 필요합니다. 인터페이스를 엄격히 정의해 두어야 모두가 함수가 어떤 타입을 받고 어떤 타입을 반환하는지 동의할 수 있기 때문입니다. 반면 클로저는 이런 식으로 외부에 노출되는 인터페이스에 사용되지 않습니다. 클로저는 변수에 저장되고, 이름을 통해 외부 사용자에게 드러나는 것이 아니라 내부적으로만 쓰입니다.

클로저는 보통 짧고, 어떤 임의의 상황 전반이 아니라 좁은 맥락 안에서만 의미가 있습니다. 이런 제한된 맥락에서는, 컴파일러가 대부분의 변수 타입을 추론하듯 매개변수 타입과 반환 타입도 추론할 수 있습니다(드물게는 클로저에도 타입 주석이 필요한 경우가 있습니다).

변수와 마찬가지로, 조금 더 명확하게 표현하고 싶다면 타입 주석을 달 수도 있습니다. 물론 그만큼 장황해지긴 합니다. 목록 13-2는 클로저에 타입 주석을 붙이면 어떤 모양인지 보여 줍니다. 여기서는 목록 13-1처럼 다른 함수 인수 자리에 바로 클로저를 넣는 대신, 클로저를 먼저 정의한 뒤 변수에 저장합니다.

Filename: src/main.rs
use std::thread;
use std::time::Duration;

fn generate_workout(intensity: u32, random_number: u32) {
    let expensive_closure = |num: u32| -> u32 {
        println!("calculating slowly...");
        thread::sleep(Duration::from_secs(2));
        num
    };

    if intensity < 25 {
        println!("Today, do {} pushups!", expensive_closure(intensity));
        println!("Next, do {} situps!", expensive_closure(intensity));
    } else {
        if random_number == 3 {
            println!("Take a break today! Remember to stay hydrated!");
        } else {
            println!(
                "Today, run for {} minutes!",
                expensive_closure(intensity)
            );
        }
    }
}

fn main() {
    let simulated_user_specified_value = 10;
    let simulated_random_number = 7;

    generate_workout(simulated_user_specified_value, simulated_random_number);
}
Listing 13-2: 클로저 안의 매개변수 타입과 반환값 타입에 선택적으로 주석 달기

타입 주석을 붙이면 클로저 문법은 함수 문법과 더 비슷해 보입니다. 비교를 위해, 다음은 매개변수에 1을 더하는 함수 하나와, 같은 동작을 하는 클로저들입니다. 관련된 부분이 보기 쉽도록 공백을 조금 맞춰 두었습니다. 세로줄 사용과 일부 문법이 선택사항이라는 점을 제외하면 클로저 문법이 함수 문법과 얼마나 비슷한지 잘 보입니다.

fn  add_one_v1   (x: u32) -> u32 { x + 1 }
let add_one_v2 = |x: u32| -> u32 { x + 1 };
let add_one_v3 = |x|             { x + 1 };
let add_one_v4 = |x|               x + 1  ;

첫 줄은 함수 정의이고, 둘째 줄은 모든 타입 주석을 포함한 클로저 정의입니다. 셋째 줄에서는 클로저 정의의 타입 주석을 제거했습니다. 넷째 줄에서는 본문이 식 하나뿐이라 중괄호도 생략했습니다. 모두 유효한 정의이며, 호출했을 때 똑같이 동작합니다. add_one_v3add_one_v4 는 실제로 평가되어야만 컴파일될 수 있는데, 타입이 사용 방식으로부터 추론되기 때문입니다. 이는 let v = Vec::new(); 가 벡터 안에 어떤 타입의 값이 들어가거나, 타입 주석이 추가되기 전까지는 타입을 알 수 없는 것과 비슷합니다.

클로저 정의에 대해 컴파일러는 각 매개변수와 반환값에 대해 하나의 구체적인 타입만 추론합니다. 예를 들어 목록 13-3에는 인수로 받은 값을 그대로 반환하는 짧은 클로저를 정의합니다. 이 클로저는 예제 외에는 별 쓸모가 없지만, 타입 추론 방식을 설명하기에는 좋습니다. 정의 자체에는 타입 주석이 없다는 점에 주의하세요. 타입 주석이 없기 때문에 처음에는 어떤 타입으로도 호출할 수 있을 것처럼 보이지만, 실제로는 첫 호출에서 타입이 고정됩니다. 여기서는 첫 번째 호출에서 String 을 사용했고, 그 다음에 정수로 호출하려 하면 오류가 납니다.

Filename: src/main.rs
fn main() {
    let example_closure = |x| x;

    let s = example_closure(String::from("hello"));
    let n = example_closure(5);
}
Listing 13-3: 타입이 추론된 클로저를 서로 다른 두 타입으로 호출하려 시도하기

컴파일러는 다음과 같은 오류를 냅니다.

$ cargo run
   Compiling closure-example v0.1.0 (file:///projects/closure-example)
error[E0308]: mismatched types
 --> src/main.rs:5:29
  |
5 |     let n = example_closure(5);
  |             --------------- ^ expected `String`, found integer
  |             |
  |             arguments to this function are incorrect
  |
note: expected because the closure was earlier called with an argument of type `String`
 --> src/main.rs:4:29
  |
4 |     let s = example_closure(String::from("hello"));
  |             --------------- ^^^^^^^^^^^^^^^^^^^^^ expected because this argument is of type `String`
  |             |
  |             in this closure call
note: closure parameter defined here
 --> src/main.rs:2:28
  |
2 |     let example_closure = |x| x;
  |                            ^
help: try using a conversion method
  |
5 |     let n = example_closure(5.to_string());
  |                              ++++++++++++

For more information about this error, try `rustc --explain E0308`.
error: could not compile `closure-example` (bin "closure-example") due to 1 previous error

String 값을 넣어 example_closure 를 처음 호출한 순간, 컴파일러는 x 의 타입과 클로저 반환 타입을 모두 String 으로 추론합니다. 그 뒤 이 타입은 example_closure 안에서 고정되며, 이후 같은 클로저에 다른 타입을 사용하려 하면 타입 오류가 납니다.

참조를 캡처하거나 소유권을 이동하기

클로저는 자신이 정의된 환경의 값을 세 가지 방식으로 캡처할 수 있습니다. 이는 함수가 매개변수를 받는 세 방식, 즉 불변 대여, 가변 대여, 소유권 가져오기와 정확히 대응합니다. 클로저는 본문에서 캡처된 값을 어떻게 사용하는지에 따라 이 셋 중 하나를 결정합니다.

목록 13-4에서는 list 라는 벡터 값에 대한 불변 참조만 있으면 출력을 수행할 수 있기 때문에, list 에 대한 불변 참조를 캡처하는 클로저를 정의합니다.

Filename: src/main.rs
fn main() {
    let list = vec![1, 2, 3];
    println!("Before defining closure: {list:?}");

    let only_borrows = || println!("From closure: {list:?}");

    println!("Before calling closure: {list:?}");
    only_borrows();
    println!("After calling closure: {list:?}");
}
Listing 13-4: 불변 참조를 캡처하는 클로저를 정의하고 호출하기

이 예제는 또 하나 중요한 점을 보여 줍니다. 변수는 클로저 정의 자체에 바인딩될 수 있고, 이후 그 변수 이름에 괄호를 붙여 함수 이름처럼 클로저를 호출할 수도 있습니다.

list 에 대한 불변 참조를 여러 개 동시에 가질 수 있으므로, 클로저 정의 전에도 list 를 사용할 수 있고, 정의한 뒤 호출하기 전에도 사용할 수 있으며, 호출한 뒤에도 여전히 사용할 수 있습니다. 이 코드는 컴파일되고 실행되며, 다음을 출력합니다.

$ cargo run
   Compiling closure-example v0.1.0 (file:///projects/closure-example)
    Finished `dev` profile [unoptimized + debuginfo] target(s) in 0.43s
     Running `target/debug/closure-example`
Before defining closure: [1, 2, 3]
Before calling closure: [1, 2, 3]
From closure: [1, 2, 3]
After calling closure: [1, 2, 3]

다음으로 목록 13-5에서는, 클로저 본문이 list 벡터에 요소를 추가하도록 바꾸어 클로저가 가변 참조를 캡처하게 합니다.

Filename: src/main.rs
fn main() {
    let mut list = vec![1, 2, 3];
    println!("Before defining closure: {list:?}");

    let mut borrows_mutably = || list.push(7);

    borrows_mutably();
    println!("After calling closure: {list:?}");
}
Listing 13-5: 가변 참조를 캡처하는 클로저를 정의하고 호출하기

이 코드는 컴파일되고 실행되며, 다음을 출력합니다.

$ cargo run
   Compiling closure-example v0.1.0 (file:///projects/closure-example)
    Finished `dev` profile [unoptimized + debuginfo] target(s) in 0.43s
     Running `target/debug/closure-example`
Before defining closure: [1, 2, 3]
After calling closure: [1, 2, 3, 7]

여기서는 클로저 정의와 호출 사이에 더 이상 println! 이 없습니다. borrows_mutably 가 정의되는 순간 list 에 대한 가변 참조를 캡처하기 때문입니다. 그리고 클로저를 호출한 뒤에는 더 이상 다시 사용하지 않으므로 가변 대여도 거기서 끝납니다. 클로저 정의와 호출 사이에 불변 참조로 출력하려고 하면, 가변 대여가 살아 있는 동안 다른 참조를 만들 수 없기 때문에 허용되지 않습니다. 직접 println! 을 추가해 어떤 오류가 나는지 확인해 보세요.

클로저 본문이 꼭 소유권을 필요로 하지 않더라도, 환경의 값을 반드시 클로저 안으로 이동시키고 싶다면 매개변수 목록 앞에 move 키워드를 사용할 수 있습니다.

이 기법은 주로 클로저를 새 스레드로 넘길 때 유용합니다. 데이터를 새 스레드가 소유하게 만들기 위해서입니다. 스레드와 스레드를 왜 사용하는지는 16장의 동시성 장에서 자세히 다루겠지만, 지금은 move 키워드가 필요한 간단한 스레드 생성 예제를 짧게만 살펴보겠습니다. 목록 13-6은 목록 13-4를 수정해, 메인 스레드가 아니라 새 스레드 안에서 벡터를 출력합니다.

Filename: src/main.rs
use std::thread;

fn main() {
    let list = vec![1, 2, 3];
    println!("Before defining closure: {list:?}");

    thread::spawn(move || println!("From thread: {list:?}"))
        .join()
        .unwrap();
}
Listing 13-6: move 로 스레드용 클로저가 list 소유권을 가져가게 하기

우리는 새 스레드를 생성하고, 그 스레드가 실행할 클로저를 인수로 넘깁니다. 클로저 본문은 리스트를 출력합니다. 목록 13-4에서는 리스트를 출력하는 데 필요한 최소한의 접근만을 선택해, list 를 불변 참조로 캡처했습니다. 하지만 여기서는 클로저 본문이 여전히 불변 참조만 필요하더라도, move 키워드를 붙여 list 가 클로저 안으로 이동되도록 명시해야 합니다. 만약 메인 스레드가 새 스레드에 join 하기 전에 더 많은 작업을 수행한다면, 새 스레드가 먼저 끝나고 그 뒤 메인 스레드 쪽의 list 가 더 이상 유효하지 않을 가능성도 있기 때문입니다. move 로 소유권을 넘기면 이런 문제를 피할 수 있습니다.