Wann ist die Schwanzrekursion in Rust garantiert?

8

C Sprache

In der Programmiersprache C ist es einfach, eine Schwanzrekursion durchzuführen :

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

Geben Sie einfach den Rückgabewert des rekursiven Aufrufs zurück. Es ist besonders wichtig, wenn sich diese Rekursion tausend- oder sogar millionenfach wiederholen kann. Es würde viel Speicher auf dem Stapel verbrauchen .

Rost

Jetzt habe ich eine Rust-Funktion, die sich millionenfach rekursiv aufruft:

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),
    }
}

(Dies ist ein minimales Beispiel, das reale ist komplexer, aber es erfasst die Hauptidee)

Hier wird der Rückgabewert des rekursiven Aufrufs unverändert zurückgegeben, aber:

Garantiert es, dass der Rust-Compiler eine Schwanzrekursion anwendet?

Wenn wir beispielsweise eine Variable deklarieren, die wie a zerstört werden muss std::Vec, wird sie dann unmittelbar vor dem rekursiven Aufruf (der eine Schwanzrekursion ermöglicht) oder nach der Rückkehr des rekursiven Aufrufs (der die Schwanzrekursion verbietet) zerstört?

uben
quelle
2
Ich glaube , Sie sprechen über Schwanz Rekursion , könnten Sie besser Eingabe auf Ihre Frage, wenn Sie „Schwanz“ anstelle von „Terminal“ sagen
Tadhg McDonald-Jensen
7
"In der Programmiersprache C ist eine Terminalrekursion leicht zu garantieren." Ich wäre überrascht, wenn C eine solche Garantie geben würde.
Mcarton
5
Ich denke, Sie mischen die Tail-Rekursion mit der Tail-Call-Optimierung. Z.B. Ihr C-Code ist Schwanz rekursiv, aber es könnte den Stapel sprengen, da es keine Garantie für Schwanzaufrufe
Sylwester

Antworten:

10

Endaufruf sind garantiertwenn Ihre rekursive Funktion in genannt wird Schwanz Position (im Grunde die letzte Anweisung der Funktion).

Die Tail-Call- Optimierung wird von Rust niemals garantiert, obwohl der Optimierer sie möglicherweise ausführen möchte.

wenn wir eine Variable deklarieren, die zerstört werden muss

Nach meinem Verständnis ist dies einer der Knackpunkte, da eine Änderung der Position zerstörter Stapelvariablen umstritten wäre.

Siehe auch:

Shepmaster
quelle
8

Die Antwort von Shepmaster erklärt, dass die Optimierung von Tail Calls, die ich lieber als Eliminierung von Tail Calls bezeichne, in Rust nicht garantiert ist. Aber das ist nicht die ganze Geschichte! Es gibt viele Möglichkeiten zwischen "nie passiert" und "garantiert". Schauen wir uns an, was der Compiler mit echtem Code macht.

Kommt es in dieser Funktion vor?

Ab jetzt ist die neueste Version von Rust auf Compiler Explorer ist 1,39, und es hat nicht den Schwanz Anruf zu beseitigen 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

Beachten Sie diese Zeile : call qword ptr [rip + example::read_all@GOTPCREL]. Das ist der rekursive Aufruf. Wie Sie an seiner Existenz erkennen können, wurde es nicht beseitigt.

Vergleichen Sie dies mit einer äquivalenten Funktion mit einem explizitenloop :

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),
        }
    }
}

die keinen zu eliminierenden Tail-Aufruf hat und daher zu einer Funktion mit nur einer kompiliert wird call(zur berechneten Adresse von input.read).

Naja. Vielleicht ist Rust nicht so gut wie C. Oder doch?

Kommt es in C vor?

Hier ist eine rekursive Funktion in C, die eine sehr ähnliche Aufgabe ausführt:

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

Dies sollte für den Compiler sehr einfach zu beseitigen sein. Der rekursive Aufruf befindet sich ganz unten in der Funktion und C muss sich nicht um die Ausführung von Destruktoren kümmern. Trotzdem gibt es diesen rekursiven Tail Call , der ärgerlicherweise nicht beseitigt wird:

        call    read_all

Es stellt sich heraus, dass die Tail-Call-Optimierung auch in C nicht garantiert ist. Ich habe Clang und gcc unter verschiedenen Optimierungsstufen ausprobiert, aber nichts, was ich versucht habe, würde diese ziemlich einfache rekursive Funktion in eine Schleife verwandeln.

Passiert das jemals ?

Okay, das ist nicht garantiert. Kann der Compiler das überhaupt? Ja! Hier ist eine Funktion, die Fibonacci-Zahlen über eine schwanzrekursive innere Funktion berechnet:

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)
}

Der Tail-Aufruf wird nicht nur eliminiert, fibonacci_lrsondern die gesamte Funktion wird eingebunden fibonacci, was nur 12 Anweisungen ergibt (und keine callin Sicht ist):

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

Wenn Sie dies mit einer äquivalenten whileSchleife vergleichen , generiert der Compiler fast dieselbe Assembly.

Was ist der Punkt?

Sie sollten sich wahrscheinlich nicht auf Optimierungen verlassen, um Tail Calls zu eliminieren, weder in Rust noch in C. Es ist schön, wenn es passiert, aber wenn Sie sicher sein müssen, dass eine Funktion in einer engen Schleife kompiliert wird, ist dies zumindest für den sichersten Weg Jetzt ist eine Schleife zu verwenden.

trentcl
quelle