¿Cuándo se garantiza la recurrencia de la cola en Rust?

8

Lenguaje C

En el lenguaje de programación C, es fácil tener una recursión de cola :

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

Simplemente regrese como es el valor de retorno de la llamada recursiva. Es especialmente importante cuando esta recursión puede repetirse mil o incluso un millón de veces. Usaría mucha memoria en la pila .

Oxido

Ahora, tengo una función Rust que podría llamarse recursivamente un millón de veces:

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

(este es un ejemplo mínimo, el real es más complejo, pero captura la idea principal)

Aquí, el valor de retorno de la llamada recursiva se devuelve tal cual, pero:

¿Garantiza que el compilador Rust aplicará una recursión de cola?

Por ejemplo, si declaramos alguna variable que necesita ser destruida como a std::Vec, ¿será destruida justo antes de la llamada recursiva (que permite la recursión de cola) o después de que la llamada recursiva regrese (que prohíbe la recursividad de cola)?

uben
fuente
2
Creo que está hablando de la recursividad de la cola , podría tener una mejor entrada en su pregunta si dice "cola" en lugar de "terminal"
Tadhg McDonald-Jensen
77
"En lenguaje de programación C, una recursión terminal es fácil de garantizar" Me sorprendería si C hiciera tal garantía.
mcarton
55
Creo que está mezclando la recursividad de cola con la optimización de llamadas de cola. P.ej. su código C es recursivo de cola pero puede volar la pila porque no garantiza la optimización de la cola de cola
Sylwester

Respuestas:

10

Las llamadas de cola están garantizadas siempre que se llame a su función recursiva en posición de cola (básicamente la última declaración de la función).

Rust nunca garantiza la optimización de llamadas de cola , aunque el optimizador puede optar por realizarla.

si declaramos alguna variable que necesita ser destruida

Tengo entendido que este es uno de los puntos conflictivos, ya que cambiar la ubicación de las variables de pila destruidas sería polémico.

Ver también:

Shepmaster
fuente
8

La respuesta de Shepmaster explica que la optimización de llamadas de cola, que prefiero llamar eliminación de llamadas de cola, no está garantizada en Rust. ¡Pero esa no es toda la historia! Hay muchas posibilidades entre "nunca sucede" y "garantizado". Echemos un vistazo a lo que hace el compilador con un código real.

¿Sucede en esta función?

A partir de ahora, la última versión de Rust disponible en Compiler Explorer es 1.39, y no elimina la llamada de cola 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

Observe esta línea: call qword ptr [rip + example::read_all@GOTPCREL]. Esa es la llamada recursiva. Como se puede ver por su existencia, no fue eliminado.

Compare esto con una función equivalente con un explícitoloop :

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

que no tiene una llamada de cola para eliminar, y por lo tanto compila a una función con solo una call(a la dirección calculada de input.read).

Oh bien. Quizás Rust no sea tan bueno como C. ¿O sí?

¿Sucede en C?

Aquí hay una función recursiva de cola en C que realiza una tarea muy similar:

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

Esto debería ser súper fácil de eliminar para el compilador. La llamada recursiva está justo en la parte inferior de la función y C no tiene que preocuparse por ejecutar destructores. Pero, sin embargo, está esa llamada recursiva de cola , molestamente no eliminada:

        call    read_all

Resulta que la optimización de la cola de llamadas tampoco está garantizada en C. Probé Clang y gcc bajo diferentes niveles de optimización, pero nada de lo que intenté convertiría esta función recursiva bastante simple en un bucle.

¿ Alguna vez pasa?

Bien, entonces no está garantizado. ¿Puede el compilador hacerlo? ¡Si! Aquí hay una función que calcula los números de Fibonacci a través de una función interna recursiva de cola:

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

No solo se elimina la llamada de cola , sino que fibonacci_lrse integra toda la función fibonacci, produciendo solo 12 instrucciones (y no calla la vista):

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

Si compara esto con un whilebucle equivalente , el compilador genera casi el mismo ensamblaje.

¿Cuál es el punto de?

Probablemente no debería confiar en las optimizaciones para eliminar las llamadas de cola, ya sea en Rust o en C. Es agradable cuando sucede, pero si necesita asegurarse de que una función se compila en un ciclo cerrado, la forma más segura, al menos para ahora, es usar un bucle.

trentcl
fuente