Rust 何时保证尾递归?

When is tail recursion guaranteed in Rust?

C语言

在C语言中,很容易有尾递归:

int foo(...) {
    return foo(...);
}

只是 return 作为递归调用的 return 值。当这种递归可能重复一千次甚至一百万次时,这一点尤为重要。它会在堆栈上使用大量 内存

生锈

现在,我有一个可以递归调用自身一百万次的 Rust 函数:

fn read_all(input: &mut dyn std::io::Read) -> std::io::Result<()> {
    match input.read(&mut [0u8]) {
        Ok (  0) => Ok(()),
        Ok (  _) => read_all(input),
        Err(err) => Err(err),
    }
}

(这是一个最小的例子,真实的例子更复杂,但抓住了主要思想)

这里,递归调用的return值按原样return编辑,但是:

是否保证 Rust 编译器将应用尾递归?

例如,如果我们声明一些需要销毁的变量,如std::Vec,它是在递归调用之前销毁(允许尾递归)还是在递归调用之后销毁returns(禁止尾递归)?

tail recursion (reusing a stack frame for a tail call to the same function) nor tail call optimization(重用堆栈帧用于对 any 函数的尾调用)都不是 Rust 保证的,尽管优化器 可能 选择执行它们。

if we declare some variable that needs to be destroyed

据我了解,这是症结之一,因为更改已销毁堆栈变量的位置会引起争议。

另请参阅:

解释说,在 Rust 中,尾调用消除只是一种优化,而不是保证。但“永远不会保证”并不意味着“永远不会发生”。让我们看看编译器对一些真实代码做了什么。

是否发生在这个函数中?

截至目前,Compiler Explorer 上可用的最新 Rust 版本是 1.39,它不会 消除 read_all 中的尾调用。

example::read_all:
        push    r15
        push    r14
        push    rbx
        sub     rsp, 32
        mov     r14, rdx
        mov     r15, rsi
        mov     rbx, rdi
        mov     byte ptr [rsp + 7], 0
        lea     rdi, [rsp + 8]
        lea     rdx, [rsp + 7]
        mov     ecx, 1
        call    qword ptr [r14 + 24]
        cmp     qword ptr [rsp + 8], 1
        jne     .LBB3_1
        movups  xmm0, xmmword ptr [rsp + 16]
        movups  xmmword ptr [rbx], xmm0
        jmp     .LBB3_3
.LBB3_1:
        cmp     qword ptr [rsp + 16], 0
        je      .LBB3_2
        mov     rdi, rbx
        mov     rsi, r15
        mov     rdx, r14
        call    qword ptr [rip + example::read_all@GOTPCREL]
        jmp     .LBB3_3
.LBB3_2:
        mov     byte ptr [rbx], 3
.LBB3_3:
        mov     rax, rbx
        add     rsp, 32
        pop     rbx
        pop     r14
        pop     r15
        ret
        mov     rbx, rax
        lea     rdi, [rsp + 8]
        call    core::ptr::real_drop_in_place
        mov     rdi, rbx
        call    _Unwind_Resume@PLT
        ud2

注意这一行:call qword ptr [rip + example::read_all@GOTPCREL]。那就是(尾部)递归调用。从它的存在可以看出,它并没有被淘汰。

Compare this to an equivalent function with an explicit loop:

pub fn read_all(input: &mut dyn std::io::Read) -> std::io::Result<()> {
    loop {
        match input.read(&mut [0u8]) {
            Ok (  0) => return Ok(()),
            Ok (  _) => continue,
            Err(err) => return Err(err),
        }
    }
}

没有要消除的尾调用,因此编译为一个函数,其中只有一个 call(到 input.read 的计算地址)。

哦,好吧。也许 Rust 不如 C。或者是吗?

它发生在 C 中吗?

这是一个 C 语言的尾递归函数,它执行非常相似的任务:

int read_all(FILE *input) {
    char buf[] = {0, 0};
    if (!fgets(buf, sizeof buf, input))
        return feof(input);
    return read_all(input);
}

这对于编译器来说应该非常容易消除。递归调用就在函数的底部,C 不必担心 运行 析构函数。但尽管如此,there's that recursive tail call,恼人的是没有被淘汰:

        call    read_all

事实证明,在 C 中也不能保证尾调用优化。我在不同的优化级别下尝试了 Clang 和 gcc,但我没有尝试将这个相当简单的递归函数变成循环。

曾经发生过吗?

好的,所以不能保证。编译器完全可以做到吗?是的!这是一个通过尾递归内部函数计算斐波那契数的函数:

pub fn fibonacci(n: u64) -> u64 {
    fn fibonacci_lr(n: u64, a: u64, b: u64) -> u64 {
        match n {
            0 => a,
            _ => fibonacci_lr(n - 1, a + b, a),
        }
    }
    fibonacci_lr(n, 1, 0)
}

不仅消除了尾部调用,而且整个 fibonacci_lr 函数内联到 fibonacci 中,仅产生 12 条指令(看不到 call):

example::fibonacci:
        push    1
        pop     rdx
        xor     ecx, ecx
.LBB0_1:
        mov     rax, rdx
        test    rdi, rdi
        je      .LBB0_3
        dec     rdi
        add     rcx, rax
        mov     rdx, rcx
        mov     rcx, rax
        jmp     .LBB0_1
.LBB0_3:
        ret

如果你compare this to an equivalent while loop,编译器生成几乎相同的程序集。

有什么意义?

你可能不应该依赖优化来消除尾调用,无论是在 Rust 还是在 C 中。它发生时很好,但如果你需要确保一个函数编译成一个紧密的循环,最可靠的至少现在,方法是使用循环。