Rustでテール再帰はいつ保証されますか?


8

C言語

Cプログラミング言語では、末尾再帰を行うのは簡単です。

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

再帰呼び出しの戻り値と同じように戻ります。この再帰が1000回または100万回繰り返される場合は特に重要です。スタックで大量のメモリを使用します

さび

これで、100万回再帰的に呼び出される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),
    }
}

(これは最小限の例であり、実際の例はより複雑ですが、主なアイデアを捉えています)

ここでは、再帰呼び出しの戻り値がそのまま返されますが、次のようになります。

Rustコンパイラが末尾再帰を適用することを保証しますか?

たとえば、のように破棄する必要がある変数を宣言した場合、std::Vecそれは再帰呼び出しの直前(末尾再帰を可能にする)または再帰呼び出しが戻った後(末尾再帰を禁止する)に破棄されますか?


2
私はあなたが末尾再帰について話していると思います、「ターミナル」の代わりに「テール」と言う方があなたの質問により良い入力があるかもしれません
Tadhg McDonald-Jensen

7
「Cプログラミング言語では、端末の再帰が保証されやすい」Cがそのような保証をしたとしたら、私は驚くでしょう。
mcarton


5
末尾再帰と末尾呼び出しの最適化を組み合わせていると思います。例えば。Cコードは末尾再帰ですが、末尾呼び出しの最適化を
Sylwester

回答:


10

末尾呼び出しは、再帰関数が末尾位置(基本的には関数の最後のステートメント)で呼び出されるたびに保証されます。

オプティマイザそれを実行することを選択して、テール呼び出しの最適化はRustによって保証されることはありません。

破棄する必要がある変数を宣言した場合

破壊されたスタック変数の場所を変更すると問題が発生するため、これが問題の1つであると私は理解しています。

以下も参照してください。


8

Shepmasterの答えは、私がテールコールの除去と呼んでいるテールコールの最適化がRustで発生するとは限らないことを説明しています。しかし、それだけではありません。「決して起こらない」と「保証される」の間には多くの可能性があります。コンパイラが実際のコードで何をするかを見てみましょう。

この関数で発生しますか?

現在のところ、コンパイラエクスプローラで利用できる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]。それが再帰呼び出しです。その存在からわかるように、除去されませんでした。

これを明示的な同等の関数と比較してください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),
        }
    }
}

削除する末尾呼び出しがないため、1つだけが含まれる関数にコンパイルさ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はデストラクタの実行について心配する必要はありません。しかし、それにかかわらず、再帰的な末尾呼び出しがあり、迷惑なことに排除されていません。

        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

あなたがいる場合、これは同等と比較whileループ、コンパイラが生成し、ほぼ同じアセンブリを。

ポイントは何ですか?

RustまたはCのいずれかで、末尾呼び出しを排除するために最適化に依存すべきではないでしょう。それが発生するのは良いことですが、関数がタイトループにコンパイルされることを確認する必要がある場合は、少なくとも今、ループを使用することです。

弊社のサイトを使用することにより、あなたは弊社のクッキーポリシーおよびプライバシーポリシーを読み、理解したものとみなされます。
Licensed under cc by-sa 3.0 with attribution required.