fn 與 Fn

Wu Yu Wei published on
3 min, 590 words

大家在其他語言中大部分的時候可能可以將 fnFn 視為同樣的語意來處理,可是這在 Rust 中會需要多一些操作,比方說以下的做法就是不可行的:

fn foo<A, B>(f1: Fn(A) -> B, f2: Fn(B) -> A) {
    todo!()
}

因為 fn 才是函式(更確切地說是函式指標),而 Fn 是 trait,我們得改成用 fn 或是真的要用 Fn 的話則是採用泛型 impl trait 甚至是 Trait Object &dyn trait 等等。選擇 impl trait 的話會寫成像這樣:

fn foo<A, B>(f1: impl Fn(A) -> B, f2: impl Fn(B) -> A) {
    todo!()
}

會有這樣的差別的原因是,除了普通的函式以外,閉包也擁有 Fn trait,閉包通常會再有個 context struct 來保存狀態,在大多數的語言中會選擇 Box 起來變成同樣的指標,讓型別之間的區別抹去。在 Rust 預設則不是如此,型別之間的區別是存在的,所以真的想在執行時視為同樣的型別處理,我們就會用 Trait Object Box<dyn trait>,但這樣就是動態調度(Dynamic Dispatch),和其他語言一樣會有額外的開銷。

Fn 預設的行為則不然,它是 trait,用在泛型上會是靜態調度(Static Dispatch),在執行時是沒有任何額外的開銷(不過因為是靜態的,你的程式碼大小的確會增加)。有趣的是 fn 因為是指標,所以拿來當作參數使用的話其實是動態調度的,和使用 Fn 的泛型靜態調度有一點不一樣。詳細的區別可以看看以下例子:https://godbolt.org/z/bexE6v

#![feature(test)]

#[inline(never)]
fn foo() {
    std::hint::black_box(());
}

#[inline(never)]
fn bar() {
    std::hint::black_box(());
    std::hint::black_box(());
}

pub fn run1() {
    #[inline(never)]
    fn run(f: fn()) { f() }
    run(foo);
    run(bar);
}

pub fn run2() {
    #[inline(never)]
    fn run(f: impl Fn()) { f() }
    run(foo);
    run(bar);
}

而產生的組合語言則是如此:

example::foo:
        push    rax
        mov     rax, rsp
        pop     rax
        ret

example::bar:
        push    rax
        mov     rax, rsp
        pop     rax
        ret

example::run1:
        push    rax
        lea     rdi, [rip + example::foo]
        call    example::run1::run
        lea     rdi, [rip + example::bar]
        pop     rax
        jmp     example::run1::run

example::run1::run:
        jmp     rdi

example::run2:
        push    rax
        call    example::run2::run
        pop     rax
        jmp     example::run2::run

example::run2::run:
        jmp     example::foo

example::run2::run:
        jmp     example::bar

run1 會接受的參數是個指標,而 run2 才會確切呼叫實際個別函式。