Introduction

러스트에서는 마치 어떤 값을 다루는 것처럼 함수를 변수에 저장하거나, 다른 함수에 인자로 전달할 수도 있습니다. 이번 글에서는 값처럼 다루어지는 익명 함수, 클로저closure에 대해 알아봅니다.

논리를 저장하다

// unwrap_or_else를 사용하면 Result가 Ok일 때 Ok 안의 값을 반환하고,
// Err일 때 클로저 안의 코드를 호출한다.
let config = Config::build(&args).unwrap_or_else(|err| {
	eprintln!("Problem parsing arguments: {err}");
	process::exit(1);
});

위에 주어진 예시 코드에서 unwrap_or_else 메서드 안에 클로저 표현식이 사용되었는데, 이 메서드를 호출한 인스턴스가 의도된 타입이 아닐 때 인자로 주어진 클로저를 실행하게 된다.

사실 클로저 표현식은 함수와 유사한데, fn과 소괄호()를 쓰지 않는 대신 || 사이에 매개변수가 들어가고 중괄호를 통해 함수 본문을 정의하면 된다. 그럼에도 불구하고 클로저가 함수와 동떨어져 보이는 것은 일반적으로 클로저를 작성할 때 매개변수와 반환 값의 타입을 명시하지 않고 때에 따라서는 중괄호{}도 생략하기 때문인데, 생략된 코드를 추가하면 다음과 같다.

let config = Config::build(&args).unwrap_or_else(|err: &str| -> () {
	eprintln!("Problem parsing arguments: {err}");
	process::exit(1);
});

이렇듯 클로저에서 사용되는 매개변수와 반환 값의 타입은 컴파일러가 알아서 추론하기 때문에, 같은 클로저를 여러 번 사용할 때 처음과 다른 타입의 인자를 넣으면 에러가 발생한다. 컴파일러는 클로저가 처음 호출될 때 타입을 확정하고, 이후 호출에서는 그 타입을 강제한다.

let example_closure = |x| x;
let s = example_closure(String::from("hello"));
let n = example_closure(5); // 에러: String 타입이 확정되었으므로 정수를 넣을 수 없다
error[E0308]: mismatched types
 --> src/main.rs:5:29
  |
5 |     let n = example_closure(5);
  |             --------------- ^- help: try using a conversion method: `.to_string()`
  |             |               |
  |             |               expected struct `String`, found integer
  |             arguments to this function are incorrect

여기까지만 보면 클로저는 이름 없는 함수에 불과해 보인다. 하지만 클로저에는 일반 함수와 근본적으로 다른 특성이 하나 있다.

환경 캡처

클로저가 일반 함수와 구분되는 핵심적인 차이점은 클로저가 정의된 환경environment에 있는 값을 함께 포착capture할 수 있다는 것이다.1 일반 함수는 자신의 매개변수와 지역 변수만 사용할 수 있지만, 클로저는 자신이 정의된 범위에 있는 변수를 가져와서 사용할 수 있다.

fn main() {
    let list = vec![1, 2, 3];
    println!("클로저 정의 전: {:?}", list);

    let only_borrows = || println!("클로저 내부에서: {:?}", list);

    println!("클로저 호출 전: {:?}", list);
    only_borrows();
    println!("클로저 호출 후: {:?}", list);
}

이 예시에서 only_borrows 클로저는 매개변수가 없지만, 바깥 스코프에 있는 list를 사용하고 있다. 클로저가 list를 읽기만 하므로 불변 참조로 캡처하며, 클로저 정의 이후에도 list를 계속 사용할 수 있다. 이러한 환경 캡처는 소유권 시스템과 밀접하게 연결되어 있는데, 클로저가 캡처한 값을 어떻게 사용하느냐에 따라 캡처 방식이 달라지기 때문이다.

캡처의 세 가지 방식

클로저가 환경의 값을 캡처하는 방식은 참조와 대여의 규칙을 그대로 따른다. 러스트 컴파일러는 클로저 본문에서 캡처한 값을 어떻게 사용하는지 분석하여, 가능한 한 가장 제약이 적은 방식을 자동으로 선택한다.

불변 대여로 캡처하는 경우, 클로저는 캡처한 값을 읽기만 한다. 앞서 본 only_borrows 클로저가 이에 해당한다. 클로저가 살아있는 동안 원본 변수에 대한 불변 참조가 유지되므로, 클로저 정의와 마지막 호출 사이에서도 원본 변수를 읽을 수 있다.

가변 대여로 캡처하는 경우, 클로저는 캡처한 값을 변경한다.

fn main() {
    let mut list = vec![1, 2, 3];
    println!("클로저 정의 전: {:?}", list);

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

    // 여기서 println!("{:?}", list)를 사용하면 컴파일 에러가 발생한다.
    // 가변 대여가 활성화된 동안 불변 대여를 할 수 없기 때문이다.
    borrows_mutably();
    println!("클로저 호출 후: {:?}", list);
}

borrows_mutably 클로저는 list에 값을 추가하므로 가변 참조로 캡처한다. 여기서 주목할 점은 가변 참조자의 규칙이 그대로 적용된다는 것이다. 클로저가 가변 참조를 쥐고 있는 동안에는 다른 참조가 존재할 수 없으므로, 클로저 정의와 마지막 호출 사이에서 list를 사용하면 컴파일 에러가 발생한다. 클로저의 마지막 호출이 끝나면 가변 대여가 해제되어 다시 list를 자유롭게 사용할 수 있다.

소유권 이전으로 캡처하는 경우, 클로저가 값의 소유권을 완전히 가져간다. 이 경우 원본 변수는 더 이상 사용할 수 없다.

move 키워드

클로저가 캡처한 값을 읽기만 하더라도 소유권을 강제로 이전시켜야 하는 상황이 있다. 대표적인 경우가 클로저를 다른 스레드thread로 보낼 때인데, 이때는 move 키워드를 사용한다.

use std::thread;

fn main() {
    let list = vec![1, 2, 3];
    println!("클로저 정의 전: {:?}", list);

    thread::spawn(move || println!("스레드에서: {:?}", list))
        .join()
        .unwrap();
}

move 키워드를 || 앞에 붙이면, 클로저 본문에서 캡처한 값을 어떻게 사용하든 상관없이 소유권이 클로저로 이전된다.2 이 예시에서 list는 읽기만 하지만, 새로 생성된 스레드가 메인 스레드보다 먼저 끝날지 나중에 끝날지 알 수 없으므로 참조자의 유효성을 보장할 수 없다. 따라서 move로 소유권 자체를 넘겨야 한다. 만약 move를 빼면, 클로저가 list의 불변 참조를 캡처하는데 그 참조자가 다른 스레드에서 유효하다는 보장이 없으므로 컴파일러가 에러를 발생시킨다.

Fn 트레이트

클로저가 환경을 캡처하는 방식은 곧 클로저가 어떤 트레이트를 구현하느냐를 결정한다. 러스트는 클로저의 동작 방식을 세 가지 Fn 트레이트로 분류하며, 모든 클로저는 이 중 하나 이상을 자동으로 구현한다.3

FnOnce

FnOnce는 캡처한 값의 소유권을 클로저 밖으로 이동시키는 클로저에 구현된다. 이름에 Once가 붙은 이유는 소유권을 이동시키면 그 값을 다시 사용할 수 없으므로, 이 클로저는 한 번만 호출할 수 있기 때문이다.

impl<T> Option<T> {
    pub fn unwrap_or_else<F>(self, f: F) -> T
    where
        F: FnOnce() -> T
    {
        match self {
            Some(x) => x,
            None => f(),
        }
    }
}

unwrap_or_else의 트레이트 바운드가 FnOnce() -> T인 이유는 이 메서드가 클로저를 최대 한 번만 호출하기 때문이다. OptionSome이면 클로저를 아예 호출하지 않고, None일 때만 한 번 호출한다. 모든 클로저는 최소한 FnOnce를 구현한다. 한 번은 호출할 수 있어야 클로저로서 의미가 있기 때문이다.

FnMut

FnMut는 캡처한 값을 밖으로 이동시키지 않으면서 값을 변경하는 클로저에 구현된다. 여러 번 호출할 수 있다.

fn main() {
    let mut list = vec![1, 2, 3];
    println!("정렬 전: {:?}", list);

    list.sort_by_key(|r| r.abs());
    println!("정렬 후: {:?}", list);
}

sort_by_key는 각 요소마다 클로저를 호출하므로 여러 번 호출이 가능해야 하며, 트레이트 바운드로 FnMut를 요구한다. 만약 클로저 내부에서 캡처한 값을 밖으로 이동시키려 하면 FnMut를 구현할 수 없으므로 컴파일 에러가 발생한다.

fn main() {
    let mut list = vec![
        String::from("hello"),
        String::from("world"),
    ];
    let mut sort_operations = vec![];

    let value = String::from("by key called");

    // 이 클로저는 value의 소유권을 sort_operations로 이동시키므로
    // FnOnce만 구현하며, FnMut가 필요한 sort_by_key에서는 사용할 수 없다.
    list.sort_by_key(|r| {
        sort_operations.push(value); // 에러!
        r.clone()
    });
}

이 코드에서 valuepush에 의해 sort_operations로 소유권이 이동하기 때문에 클로저는 FnOnce만 구현한다. sort_by_keyFnMut를 요구하므로 컴파일이 실패한다.

Fn

Fn은 캡처한 값을 이동시키지도, 변경하지도 않는 클로저에 구현된다. 환경에서 아무것도 캡처하지 않는 클로저도 여기에 해당한다. Fn을 구현하는 클로저는 부작용side effect 없이 여러 번 동시에 호출할 수 있어서, 동시성concurrency 환경에서 특히 유용하다.

fn main() {
    let list = vec![1, 2, 3];

    // Fn을 구현하는 클로저: list를 불변 참조로만 캡처
    let print_list = || println!("{:?}", list);

    print_list();
    print_list(); // 여러 번 호출 가능
}

트레이트 계층

세 트레이트는 다음과 같은 포함 관계를 형성한다.

FnOnce  ⊇  FnMut  ⊇  Fn

Fn을 구현하는 클로저는 자동으로 FnMutFnOnce도 구현하고, FnMut를 구현하는 클로저는 자동으로 FnOnce도 구현한다.4 따라서 FnOnce를 요구하는 자리에 Fn 클로저를 넣을 수 있지만, 반대로 Fn을 요구하는 자리에 FnOnce 클로저를 넣을 수는 없다.

클로저를 매개변수로 받기

클로저를 함수의 매개변수로 받을 때는 제네릭과 트레이트 바운드를 사용한다. 아래 함수는 Fn 트레이트를 구현하는 어떤 클로저든 받을 수 있다.

fn apply_to_3<F>(f: F) -> i32
where
    F: Fn(i32) -> i32,
{
    f(3)
}

fn main() {
    let double = |x| x * 2;
    println!("{}", apply_to_3(double)); // 6
}

여기서 F: Fn(i32) -> i32는 "Fi32를 받아 i32를 반환하는 클로저"라는 뜻이다. 필요에 따라 Fn 대신 FnMutFnOnce를 사용하면 된다. 어떤 트레이트 바운드를 선택할지는 클로저를 함수 내부에서 어떻게 사용하느냐에 따라 달라진다.

클로저를 반환하기

함수에서 클로저를 반환할 때는 impl Fn 문법을 사용한다.5

fn make_adder(x: i32) -> impl Fn(i32) -> i32 {
    move |y| x + y
}

fn main() {
    let add_five = make_adder(5);
    println!("{}", add_five(3)); // 8
}

make_adderx를 캡처한 클로저를 반환한다. 함수가 끝나면 x는 스코프를 벗어나므로 move를 사용하여 소유권을 클로저로 이전해야 한다. 반환 타입에 impl Fn(i32) -> i32를 쓰면 "이 함수는 Fn(i32) -> i32를 구현하는 어떤 타입을 반환한다"는 의미가 된다.

함수 포인터와의 차이

함수 포인터 fn과 클로저의 Fn 트레이트는 비슷해 보이지만 근본적으로 다르다. fn은 환경을 캡처하지 않는 함수만 가리킬 수 있는 구체적인 타입이고, Fn은 클로저와 함수 모두를 포괄하는 트레이트다. 함수 포인터 fn은 세 가지 Fn 트레이트를 모두 구현하므로, 클로저를 인자로 받는 함수에 일반 함수를 전달하는 것도 가능하다.

fn add_one(x: i32) -> i32 {
    x + 1
}

fn apply<F: Fn(i32) -> i32>(f: F, val: i32) -> i32 {
    f(val)
}

fn main() {
    let closure = |x| x + 1;
    println!("{}", apply(closure, 5));  // 클로저 전달: 6
    println!("{}", apply(add_one, 5));  // 함수 전달: 6
}

일반적으로는 Fn 트레이트 바운드를 사용하는 것이 더 유연한데, 클로저와 일반 함수를 모두 받을 수 있기 때문이다.


참고 자료 & 더보기

Footnotes

  1. 환경 캡처란, 클로저가 정의된 렉시컬 스코프lexical scope에 존재하는 변수의 값을 클로저 내부로 가져오는 것을 말한다. 이 개념은 함수형 프로그래밍에서 비롯되었으며, JavaScript나 Python 등 다른 언어의 클로저에서도 동일한 원리가 적용된다.

  2. move는 모든 캡처 변수의 소유권을 이전시킨다. 만약 i32처럼 Copy 트레이트를 구현한 타입이라면 소유권 이전 대신 값의 복사가 이루어진다.

  3. 컴파일러가 클로저 본문을 분석하여 캡처 방식을 결정하고, 그에 맞는 트레이트를 자동으로 구현한다. 개발자가 직접 Fn 트레이트를 구현할 필요는 없다.

  4. 이 계층 관계는 직관적으로도 이해할 수 있다. 값을 변경하지 않는 클로저(Fn)는 당연히 값을 변경하는 자리(FnMut)에서도 안전하게 작동하며, 값을 변경하는 클로저는 한 번만 호출하는 자리(FnOnce)에서도 문제없이 작동한다.

  5. impl Trait 문법은 제네릭의 한 형태로, 호출자가 구체적인 타입을 알 필요 없이 트레이트만 만족하면 되는 상황에서 사용한다. 클로저는 각각 고유한 익명 타입을 가지므로 구체적인 타입 이름을 쓸 수 없어 이 문법이 필수적이다.