본문 바로가기

고급 함수와 클로저

함수 포인터가 함수와 클로저를 받는 방식을 익히고 서로 다른 클로저 타입을 트레이트 객체로 반환합니다.

이 글에서는 함수 포인터와 클로저를 돌려주는 것을 비롯해, 함수와 클로저에 관련된 몇 가지 고급 기능을 살펴봅니다.

함수 포인터

클로저를 함수에 넘기는 방법은 이야기했습니다. 보통의 함수도 함수에 넘길 수 있습니다. 새 클로저를 정의하는 대신 이미 정의한 함수를 넘기고 싶을 때 이 기법이 쓸모가 있습니다. 함수는 소문자 f로 쓰는 fn 타입으로 강제 변환되며, 이것을 Fn 클로저 트레이트와 헷갈리면 안 됩니다. fn 타입을 함수 포인터(function pointer)라고 부릅니다. 함수 포인터로 함수를 넘기면 함수를 다른 함수의 인수로 쓸 수 있습니다.

매개변수가 함수 포인터임을 지정하는 문법은 클로저의 문법과 비슷합니다. 다음 코드에서는 매개변수에 1을 더하는 add_one 함수를 정의했습니다. do_twice 함수는 두 매개변수를 받습니다. i32 매개변수를 받아 i32를 돌려주는 어떤 함수의 함수 포인터와, i32 값 하나입니다. do_twice 함수는 함수 f를 arg 값으로 두 번 부르고, 두 함수 호출의 결과를 더합니다. main 함수는 add_one과 5를 인수로 do_twice를 부릅니다.

src/main.rs
fn add_one(x: i32) -> i32 {
    x + 1
}

fn do_twice(f: fn(i32) -> i32, arg: i32) -> i32 {
    f(arg) + f(arg)
}

fn main() {
    let answer = do_twice(add_one, 5);

    println!("The answer is: {answer}");
}

이 코드는 The answer is: 12를 출력합니다. do_twice의 매개변수 f가 i32 타입의 매개변수 하나를 받아 i32를 돌려주는 fn이라고 지정했습니다. 그다음 do_twice의 본문에서 f를 부를 수 있습니다. main에서는 함수 이름 add_one을 do_twice의 첫 인수로 넘길 수 있습니다.

클로저와 달리 fn은 트레이트가 아니라 타입이므로, Fn 트레이트 가운데 하나를 트레이트 바운드로 가진 제네릭 타입 매개변수를 선언하는 대신, 매개변수의 타입으로 fn을 직접 지정합니다.

함수 포인터는 세 클로저 트레이트(Fn, FnMut, FnOnce)를 모두 구현합니다. 곧 클로저를 기대하는 함수에는 언제나 함수 포인터를 인수로 넘길 수 있습니다. 함수가 함수와 클로저를 모두 받을 수 있도록, 제네릭 타입과 클로저 트레이트 가운데 하나를 써서 함수를 쓰는 것이 가장 좋습니다. 아래 그림은 함수나 클로저를 받거나 돌려줄 때 고를 수 있는 타입들을 비교한 것입니다.

RUST · 호출 가능한 값 비교

함수 포인터·제네릭·impl Fn·트레이트 객체의 역할

인자로 받을 때는 캡처와 호출 방식을, 반환할 때는 하나의 구체 타입인지 여러 구현을 묶는지 생각합니다.

인자 · 함수 포인터 fn

fn(i32) -> i32

이 시그니처에 맞는 함수를 받습니다. 캡처하지 않는 일반 클로저도 함수 포인터로 변환할 수 있습니다. 캡처한 환경은 함수 포인터에 담을 수 없습니다.

인자 · 제네릭 호출 트레이트

F: Fn(i32) -> i32

함수 포인터와 캡처하는 클로저를 모두 받을 수 있습니다. 캡처를 변경하면 FnMut, 캡처 값을 소비할 수 있으면 FnOnce처럼 필요한 호출 계약을 고릅니다.

반환 · 하나의 타입을 impl Fn으로 숨기기

fn make(add: i32)
    -> impl Fn(i32) -> i32
{
    move |x| x + add
}

반환되는 클로저의 구체 타입은 하나이며 이름을 직접 쓰지 않습니다. 서로 다른 클로저 표현식은 같은 타입이 아닙니다.

반환 · 여러 구현을 Box로 묶기

Box<dyn Fn(i32) -> i32>

구체 타입이 다른 호출 가능 값을 같은 트레이트 객체 타입에 담는 방법입니다. Box 뒤에서 dyn Fn을 사용하며 호출은 동적 디스패치를 거칩니다.

함수 포인터도 호출 트레이트를 만족한다

일반적인 안전 Rust 함수 포인터는 Fn·FnMut·FnOnce를 모두 구현합니다. 환경 캡처가 없어도 제네릭으로 받을 수 있습니다. C 함수와의 상호작용에는 extern "C" fn 같은 ABI 지정이 따로 필요합니다.

fn은 함수 포인터 타입이고 Fn은 호출 트레이트입니다. 선택지는 서로 배타적이지 않으며 API가 허용할 호출 계약에 따라 정합니다.

인수로 받을 때 함수 포인터 fn(i32) -> i32는 이 시그니처에 맞는 함수를 받으며, 캡처하지 않는 클로저도 함수 포인터로 변환할 수 있지만 캡처한 환경은 담을 수 없습니다. 제네릭 F: Fn(i32) -> i32는 함수 포인터와 캡처하는 클로저를 모두 받으며, 캡처한 값을 바꿔야 하면 FnMut를, 소비할 수 있으면 FnOnce를 고릅니다. 돌려줄 때 impl Fn(i32) -> i32는 하나의 구체적인 클로저 타입을 이름 없이 숨기며, 서로 다른 클로저 표현식은 같은 타입이 아닙니다. 구체 타입이 다른 여러 클로저를 같은 타입으로 묶으려면 Box<dyn Fn(i32) -> i32> 같은 트레이트 객체를 쓰며, 호출은 동적 디스패치를 거칩니다. 소문자 fn은 함수 포인터 타입이고 Fn은 호출 트레이트이며, C 함수와 주고받으려면 extern "C" fn처럼 ABI도 따로 지정해야 합니다.

다만 클로저가 아니라 fn만 받고 싶은 예로는, 클로저가 없는 외부 코드와 상호작용할 때가 있습니다. C 함수는 함수를 인수로 받을 수 있지만, C에는 클로저가 없습니다.

인라인으로 정의한 클로저나 이름 붙은 함수를 쓸 수 있는 곳의 예로, 표준 라이브러리의 Iterator 트레이트가 제공하는 map 메서드의 쓰임을 봅니다. map 메서드로 숫자의 벡터를 문자열의 벡터로 바꾸려면, 다음처럼 클로저를 쓸 수 있습니다.

let list_of_numbers = vec![1, 2, 3];
let list_of_strings: Vec<String> =
    list_of_numbers.iter().map(|i| i.to_string()).collect();

또는 다음처럼 클로저 대신 함수 이름을 map의 인수로 넘길 수 있습니다. map은 원소마다 부를 함수를 받으므로, 클로저 대신 시그니처가 맞는 함수의 이름을 넘겨도 결과는 같습니다.

let list_of_numbers = vec![1, 2, 3];
let list_of_strings: Vec<String> =
    list_of_numbers.iter().map(ToString::to_string).collect();

to_string이라는 이름의 함수가 여럿 있을 수 있으므로, 고급 트레이트에서 이야기한 완전 정규화 문법을 써야 합니다. 여기서는 ToString 트레이트에 정의된 to_string 함수를 씁니다. 표준 라이브러리는 Display를 구현하는 모든 타입에 이 트레이트를 구현해 두었습니다.

열거형 정의에서, 정의한 열거형 배리언트의 이름이 초기화 함수도 된다는 것을 떠올려 보세요. 이 초기화 함수는 클로저 트레이트를 구현하는 함수 포인터로 쓸 수 있으므로, 다음처럼 클로저를 받는 메서드의 인수로 초기화 함수를 지정할 수 있습니다.

enum Status {
    Value(u32),
    Stop,
}

let list_of_statuses: Vec<Status> = (0u32..20).map(Status::Value).collect();

여기서는 map이 부르는 범위의 각 u32 값으로 Status::Value의 초기화 함수를 써서 Status::Value 인스턴스를 만듭니다. 이 스타일을 좋아하는 사람도 있고 클로저를 좋아하는 사람도 있습니다. 둘은 같은 코드로 컴파일되므로, 더 분명해 보이는 쪽을 쓰면 됩니다.

더 깊이: 함수 이름의 타입은 함수 포인터가 아닙니다

add_one이라는 이름을 쓰면 곧바로 fn(i32) -> i32 값이 생기는 것은 아닙니다. Reference의 함수 아이템 타입에 따르면 함수 이름은 크기가 0인 함수 아이템 타입(function item type)의 값이 되며, 이 타입은 함수의 이름과 타입 인수까지 정확히 가리킵니다. 그래서 값 안에 실제 함수 포인터가 없어도 되고, 부를 때 포인터를 따라가는 간접 호출도 필요 없습니다. 이 타입은 코드에 직접 적을 수 없고, 오류 메시지에서 fn(u32) -> i32 {fn_name} 같은 모양으로만 보입니다.

함수마다, 그리고 같은 제네릭 함수라도 타입 인수마다 함수 아이템 타입이 다르므로, 그대로는 같은 타입으로 섞을 수 없습니다. 대신 시그니처가 같은 함수 포인터로 바뀌는 강제 변환이 있어서, do_twice(add_one, 5)처럼 함수 포인터를 기대하는 자리에 넘기거나, 같은 if나 match의 갈래에서 시그니처가 같은 서로 다른 함수 아이템이 만나면 함수 포인터로 바뀝니다. 함수 아이템은 모두 Copy, Clone, Send, Sync를 구현하며, unsafe 함수나 target_feature 속성이 붙은 함수, "Rust"가 아닌 ABI의 함수가 아니라면 Fn, FnMut, FnOnce도 구현합니다. 그래서 제네릭 F: Fn(i32) -> i32 매개변수에 함수 이름을 넘기면 함수 포인터를 거치지 않고 그 함수의 아이템 타입으로 단형화되어, 컴파일러가 호출을 인라인할 수도 있습니다.

클로저 반환하기

클로저는 트레이트로 표현되므로, 클로저를 직접 돌려줄 수는 없습니다. 트레이트를 돌려주고 싶은 대부분의 경우, 그 트레이트를 구현하는 구체적인 타입을 함수의 반환값으로 쓸 수 있습니다. 하지만 클로저에는 돌려줄 수 있는 구체적인 타입이 없으므로 대개 그렇게 할 수 없습니다. 클로저가 스코프의 값을 캡처한다면, 예를 들어 함수 포인터 fn을 반환 타입으로 쓸 수 없습니다.

대신 트레이트와 공통 동작에서 배운 impl Trait 문법을 보통 씁니다. Fn, FnOnce, FnMut를 써서 어떤 함수 타입이든 돌려줄 수 있습니다. 예를 들어 다음 코드는 동작합니다.

fn returns_closure() -> impl Fn(i32) -> i32 {
    |x| x + 1
}

하지만 클로저는 캡처한 값을 담은 이름 없는 구조체입니다에서 이야기했듯, 정의가 똑같은 클로저도 각각 서로 다른 타입입니다. 시그니처는 같지만 구현이 다른 여러 함수를 다뤄야 한다면, 그것들에 트레이트 객체를 써야 합니다. 다음 코드를 쓰면 무슨 일이 일어나는지 봅니다.

src/main.rs (컴파일되지 않음)
fn main() {
    let handlers = vec![returns_closure(), returns_initialized_closure(123)];
    for handler in handlers {
        let output = handler(5);
        println!("{output}");
    }
}

fn returns_closure() -> impl Fn(i32) -> i32 {
    |x| x + 1
}

fn returns_initialized_closure(init: i32) -> impl Fn(i32) -> i32 {
    move |x| x + init
}

여기에는 둘 다 impl Fn(i32) -> i32를 돌려주는 두 함수 returns_closure와 returns_initialized_closure가 있습니다. 두 함수가 돌려주는 클로저는 같은 타입을 구현하지만 서로 다릅니다. 이것을 컴파일하려 하면, 러스트는 동작하지 않는다고 알려 줍니다.

error[E0308]: mismatched types
  --> src/main.rs:2:44
   |
 2 |     let handlers = vec![returns_closure(), returns_initialized_closure(123)];
   |                                            ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ expected opaque type, found a different opaque type
...
 9 | fn returns_closure() -> impl Fn(i32) -> i32 {
   |                         ------------------- the expected opaque type
...
13 | fn returns_initialized_closure(init: i32) -> impl Fn(i32) -> i32 {
   |                                              ------------------- the found opaque type
   |
   = note: expected opaque type `impl Fn(i32) -> i32`
              found opaque type `impl Fn(i32) -> i32`
   = note: distinct uses of `impl Trait` result in different opaque types

For more information about this error, try `rustc --explain E0308`.

오류 메시지는 impl Trait를 돌려줄 때마다 러스트가 고유한 불투명 타입(opaque type)을 만든다고 알려 줍니다. 불투명 타입은 러스트가 우리를 위해 무엇을 만드는지 그 세부 사항을 볼 수 없는 타입입니다. 그래서 두 함수가 모두 같은 트레이트 Fn(i32) -> i32를 구현하는 클로저를 돌려주더라도, 러스트가 각각에 만드는 불투명 타입은 서로 다릅니다. 같은 시그니처를 가졌어도 서로 다른 클로저의 구체 타입이 다른 것과 같습니다. 이 문제의 해결책은 이미 몇 번 보았습니다. 다음처럼 트레이트 객체를 쓸 수 있습니다.

src/main.rs
fn main() {
    let handlers = vec![returns_closure(), returns_initialized_closure(123)];
    for handler in handlers {
        let output = handler(5);
        println!("{output}");
    }
}

fn returns_closure() -> Box<dyn Fn(i32) -> i32> {
    Box::new(|x| x + 1)
}

fn returns_initialized_closure(init: i32) -> Box<dyn Fn(i32) -> i32> {
    Box::new(move |x| x + init)
}
6
128

이 코드는 잘 컴파일됩니다. 첫 클로저는 5에 1을 더한 6을, 둘째 클로저는 캡처한 init 값 123을 더한 128을 돌려줍니다. 트레이트 객체는 트레이트 객체와 다형성에서 더 이야기했습니다.

다음으로 매크로를 봅니다.

정리

  • 함수 포인터 fn(i32) -> i32는 함수를 인수로 넘기게 해 주며, Fn, FnMut, FnOnce를 모두 구현하므로 클로저를 기대하는 곳에도 넘길 수 있습니다.
  • 함수와 클로저를 모두 받으려면 F: Fn(i32) -> i32 같은 제네릭과 클로저 트레이트를 쓰고, 클로저가 없는 C 코드와 주고받을 때는 fn 타입을 씁니다.
  • map(ToString::to_string)이나 map(Status::Value)처럼 함수 이름이나 열거형 배리언트의 초기화 함수를 클로저 대신 넘길 수 있습니다.
  • 클로저 하나를 돌려줄 때는 impl Fn(i32) -> i32를 쓰지만, impl Trait는 쓸 때마다 다른 불투명 타입이 되므로 서로 다른 클로저를 한 컬렉션에 담을 수 없습니다.
  • 서로 다른 클로저를 같은 타입으로 다루려면 Box<dyn Fn(i32) -> i32> 트레이트 객체로 돌려줍니다.

연습 문제

1. 무엇을 넘길 수 있을까

fn apply(f: fn(i32) -> i32, x: i32) -> i32 { f(x) }에 (가) add_one, (나) |x| x * 2, (다) let k = 3; 뒤의 |x| x * k를 넘기면 각각 컴파일되나요?

해설 보기

(가)는 함수이므로 함수 포인터로 넘길 수 있습니다. (나)는 아무것도 캡처하지 않는 클로저라 함수 포인터로 변환되어 컴파일됩니다. (다)는 k를 캡처하므로 함수 포인터에 담을 수 없어 컴파일되지 않으며, apply를 fn apply<F: Fn(i32) -> i32>(f: F, x: i32) -> i32처럼 제네릭으로 바꾸면 세 경우를 모두 받을 수 있습니다.

2. 조건에 따라 다른 클로저 돌려주기

fn make(double: bool) -> impl Fn(i32) -> i32에서 double이 참이면 |x| x * 2를, 거짓이면 |x| x + 1을 돌려주려 했더니 컴파일되지 않습니다. 까닭은 무엇이고 어떻게 고치나요?

해설 보기

impl Fn(i32) -> i32는 함수가 하나의 구체적인 타입을 돌려준다는 뜻인데, 두 클로저 표현식은 서로 다른 타입이므로 if의 두 갈래 타입이 맞지 않습니다. 반환 타입을 Box<dyn Fn(i32) -> i32>로 바꾸고 두 갈래에서 각각 Box::new(...)로 감싸면 됩니다. 두 클로저가 모두 아무것도 캡처하지 않으므로, 반환 타입을 함수 포인터 fn(i32) -> i32로 바꾸는 방법도 있습니다.

다음 글 매크로에서는 컴파일 시점에 코드를 만들어 내는 선언적 매크로와 절차적 매크로를 살펴봅니다.