¿Por qué se necesitan vidas explícitas en Rust?


199

Estaba leyendo el capítulo de toda la vida del libro Rust, y me encontré con este ejemplo para una vida con nombre / explícita:

struct Foo<'a> {
    x: &'a i32,
}

fn main() {
    let x;                    // -+ x goes into scope
                              //  |
    {                         //  |
        let y = &5;           // ---+ y goes into scope
        let f = Foo { x: y }; // ---+ f goes into scope
        x = &f.x;             //  | | error here
    }                         // ---+ f and y go out of scope
                              //  |
    println!("{}", x);        //  |
}                             // -+ x goes out of scope

Es bastante claro para mí que el compilador previene el error es el uso después de liberar la referencia asignada a x: después de que se hace el alcance interno, fy por lo tanto se &f.xvuelve inválido, y no debería haber sido asignado x.

Mi problema es que el problema podría haberse analizado fácilmente sin usar la vida útil explícita 'a , por ejemplo, al inferir una asignación ilegal de una referencia a un alcance más amplio ( x = &f.x;).

¿En qué casos se necesitan vidas explícitas para evitar errores de uso libre (o alguna otra clase)?



2
Para futuros lectores de esta pregunta, tenga en cuenta que está vinculada a la primera edición del libro y ahora hay una segunda edición :)
carols10cents

Respuestas:


205

Todas las otras respuestas tienen puntos sobresalientes ( ejemplo concreto de fjh donde se necesita una vida útil explícita ), pero les falta una cosa clave: ¿por qué se necesitan vidas explícitas cuando el compilador le dirá que se equivocó ?

Esta es realmente la misma pregunta que "por qué se necesitan tipos explícitos cuando el compilador puede inferirlos". Un ejemplo hipotético:

fn foo() -> _ {  
    ""
}

Por supuesto, el compilador puede ver que estoy devolviendo un &'static str, entonces ¿por qué el programador tiene que escribirlo?

La razón principal es que, si bien el compilador puede ver lo que hace su código, no sabe cuál era su intención.

Las funciones son un límite natural para cortafuegos de los efectos del cambio de código. Si permitiéramos inspeccionar completamente las vidas desde el código, entonces un cambio de aspecto inocente podría afectar las vidas, lo que podría causar errores en una función muy lejana. Este no es un ejemplo hipotético. Según tengo entendido, Haskell tiene este problema cuando confía en la inferencia de tipos para las funciones de nivel superior. Rust cortó ese problema particular de raíz.

También hay un beneficio de eficiencia para el compilador: solo se deben analizar las firmas de funciones para verificar los tipos y la vida útil. Más importante aún, tiene un beneficio de eficiencia para el programador. Si no tuviéramos vidas explícitas, ¿qué hace esta función?

fn foo(a: &u8, b: &u8) -> &u8

Es imposible saberlo sin inspeccionar la fuente, lo que iría en contra de una gran cantidad de mejores prácticas de codificación.

al inferir una asignación ilegal de una referencia a un alcance más amplio

Los ámbitos son vidas, esencialmente. Un poco más claro, una vida útil 'aes un parámetro genérico de vida útil que puede especializarse con un alcance específico en tiempo de compilación, en función del sitio de la llamada.

¿son realmente necesarias vidas explícitas para evitar [...] errores?

De ningún modo. Tiempos de vida son necesarios para evitar errores, pero se necesitan tiempos de vida explícitas para proteger lo que tienen pocos programadores cordura.


18
@jco Imagine que tiene alguna función de nivel superior f x = x + 1sin una firma de tipo que está utilizando en otro módulo. Si luego cambia la definición a f x = sqrt $ x + 1, su tipo cambia de Num a => a -> aa Floating a => a -> a, lo que provocará errores de tipo en todos los sitios de llamada donde fse llama, por ejemplo, con un Intargumento. Tener una firma de tipo asegura que los errores ocurran localmente.
fjh

11
"Los ámbitos son vidas, esencialmente. Un poco más claramente, una vida 'a es un parámetro genérico de vida útil que puede especializarse con un alcance específico en el momento de la llamada". Wow, ese es un gran punto de iluminación. Me gustaría que se incluyera explícitamente en el libro.
corazza

2
@fjh Gracias. Solo para ver si lo asimilé: el punto es que si el tipo se indicó explícitamente antes de agregarlo sqrt $, solo se habría producido un error local después del cambio, y no muchos errores en otros lugares (lo que sería mucho mejor si no lo hubiéramos hecho) ¿No quieres cambiar el tipo real)?
corazza

55
@jco Exactamente. No especificar un tipo significa que puede cambiar accidentalmente la interfaz de una función. Esa es una de las razones por las que se recomienda anotar todos los elementos de nivel superior en Haskell.
fjh

55
Además, si una función recibe dos referencias y devuelve una referencia, a veces podría devolver la primera referencia y, a veces, la segunda. En este caso, es imposible inferir una vida útil para la referencia devuelta. Las vidas explícitas ayudan a evitar / aclarar tal situación.
MichaelMoser

93

Echemos un vistazo al siguiente ejemplo.

fn foo<'a, 'b>(x: &'a u32, y: &'b u32) -> &'a u32 {
    x
}

fn main() {
    let x = 12;
    let z: &u32 = {
        let y = 42;
        foo(&x, &y)
    };
}

Aquí, las vidas explícitas son importantes. Esto se compila porque el resultado de footiene la misma vida útil que su primer argumento ( 'a), por lo que puede sobrevivir a su segundo argumento. Esto se expresa mediante los nombres de por vida en la firma de foo. Si cambiara los argumentos en la llamada al foocompilador, se quejaría de que yno dura lo suficiente:

error[E0597]: `y` does not live long enough
  --> src/main.rs:10:5
   |
9  |         foo(&y, &x)
   |              - borrow occurs here
10 |     };
   |     ^ `y` dropped here while still borrowed
11 | }
   | - borrowed value needs to live until here

16

La anotación de por vida en la siguiente estructura:

struct Foo<'a> {
    x: &'a i32,
}

especifica que una Fooinstancia no debería sobrevivir a la referencia que contiene ( xcampo).

El ejemplo que se encontró en el libro Rust no ilustra esto porque fy yvariables de salir de su ámbito, al mismo tiempo.

Un mejor ejemplo sería este:

fn main() {
    let f : Foo;
    {
        let n = 5;  // variable that is invalid outside this block
        let y = &n;
        f = Foo { x: y };
    };
    println!("{}", f.x);
}

Ahora, frealmente sobrevive a la variable señalada por f.x.


9

Tenga en cuenta que no hay tiempos de vida explícitos en ese fragmento de código, excepto la definición de estructura. El compilador es perfectamente capaz de inferir vidas en main().

En las definiciones de tipo, sin embargo, las vidas explícitas son inevitables. Por ejemplo, hay una ambigüedad aquí:

struct RefPair(&u32, &u32);

¿Deberían ser vidas diferentes o deberían ser las mismas? Importa desde la perspectiva del uso,struct RefPair<'a, 'b>(&'a u32, &'b u32) es muy diferente de struct RefPair<'a>(&'a u32, &'a u32).

Ahora, para casos simples, como el que proporcionó, el compilador teóricamente podría eludir vidas como lo hace en otros lugares, pero estos casos son muy limitados y no valen la pena de una complejidad adicional en el compilador, y esta ganancia en claridad sería Muy menos cuestionable.


2
¿Puedes explicar por qué son muy diferentes?
AB

@AB El segundo requiere que ambas referencias compartan la misma vida útil. Esto significa que refpair.1 no puede vivir más que refpair.2 y viceversa, por lo que ambas referencias deben apuntar a algo con el mismo propietario. Sin embargo, el primero solo requiere que RefPair sobreviva a sus dos partes.
llogiq

2
@AB, compila porque ambas vidas se unifican - debido a tiempos de vida locales son más pequeños que 'static, 'staticse puede utilizar en todas partes donde se pueden utilizar tiempos de vida locales, por lo tanto, en su ejemplo ptendrá su parámetro de tiempo de vida infiere que el curso de la vida local del y.
Vladimir Matveev

55
@AB RefPair<'a>(&'a u32, &'a u32)significa que 'aserá la intersección de las dos vidas de entrada, es decir, en este caso la vida útil de y.
fjh

1
@llogiq "requiere que RefPair sobreviva a sus dos partes"? Pensé que era todo lo contrario ... a & u32 todavía puede tener sentido sin RefPair, mientras que un RefPair con sus referencias muertas sería extraño.
qed

6

El caso del libro es muy simple por diseño. El tema de las vidas se considera complejo.

El compilador no puede inferir fácilmente la duración de una función con múltiples argumentos.

Además, mi propia caja opcional tiene un OptionBooltipo con un as_slicemétodo cuya firma en realidad es:

fn as_slice(&self) -> &'static [bool] { ... }

No hay absolutamente ninguna manera de que el compilador haya podido resolverlo.


IINM, inferir la vida útil del tipo de retorno de una función de dos argumentos será equivalente al problema de detención: IOW, no decidible en una cantidad de tiempo finita.
dstromberg


4

Si una función recibe dos referencias como argumentos y devuelve una referencia, la implementación de la función a veces puede devolver la primera referencia y, a veces, la segunda. Es imposible predecir qué referencia se devolverá para una llamada determinada. En este caso, es imposible inferir un tiempo de vida para la referencia devuelta, ya que cada referencia de argumento puede referirse a un enlace variable diferente con un tiempo de vida diferente. Las vidas explícitas ayudan a evitar o aclarar tal situación.

Del mismo modo, si una estructura contiene dos referencias (como dos campos miembros), una función miembro de la estructura a veces puede devolver la primera referencia y, a veces, la segunda. Una vez más, las vidas explícitas evitan tales ambigüedades.

En algunas situaciones simples, hay una elisión de por vida donde el compilador puede inferir vidas.


1

La razón por la cual su ejemplo no funciona es simplemente porque Rust solo tiene una duración local y una inferencia de tipos. Lo que está sugiriendo exige inferencia global. Siempre que tenga una referencia cuya vida útil no se pueda eludir, debe anotarse.


1

Como recién llegado a Rust, entiendo que las vidas explícitas tienen dos propósitos.

  1. Poner una anotación explícita de por vida en una función restringe el tipo de código que puede aparecer dentro de esa función. Las vidas explícitas permiten que el compilador se asegure de que su programa esté haciendo lo que usted pretendía.

  2. Si usted (el compilador) quiere comprobar si un código es válido, usted (el compilador) no tendrá que buscar de forma iterativa dentro de cada función llamada. Basta con echar un vistazo a las anotaciones de funciones que ese código llama directamente. Esto hace que su programa sea mucho más fácil de razonar para usted (el compilador) y hace que los tiempos de compilación sean manejables.

En el punto 1., considere el siguiente programa escrito en Python:

import pandas as pd
import numpy as np

def second_row(ar):
    return ar[0]

def work(second):
    df = pd.DataFrame(data=second)
    df.loc[0, 0] = 1

def main():
    # .. load data ..
    ar = np.array([[0, 0], [0, 0]])

    # .. do some work on second row ..
    second = second_row(ar)
    work(second)

    # .. much later ..
    print(repr(ar))

if __name__=="__main__":
    main()

que imprimirá

array([[1, 0],
       [0, 0]])

Este tipo de comportamiento siempre me sorprende. Lo que está sucediendo es que dfestá compartiendo memoria ar, por lo que cuando parte del contenido de los dfcambios en workese cambio también infecta ar. Sin embargo, en algunos casos esto puede ser exactamente lo que desea, por razones de eficiencia de memoria (sin copia). El verdadero problema en este código es que la funciónsecond_row está devolviendo la primera fila en lugar de la segunda; buena suerte depurando eso.

Considere en su lugar un programa similar escrito en Rust:

#[derive(Debug)]
struct Array<'a, 'b>(&'a mut [i32], &'b mut [i32]);

impl<'a, 'b> Array<'a, 'b> {
    fn second_row(&mut self) -> &mut &'b mut [i32] {
        &mut self.0
    }
}

fn work(second: &mut [i32]) {
    second[0] = 1;
}

fn main() {
    // .. load data ..
    let ar1 = &mut [0, 0][..];
    let ar2 = &mut [0, 0][..];
    let mut ar = Array(ar1, ar2);

    // .. do some work on second row ..
    {
        let second = ar.second_row();
        work(second);
    }

    // .. much later ..
    println!("{:?}", ar);
}

Compilando esto, obtienes

error[E0308]: mismatched types
 --> src/main.rs:6:13
  |
6 |             &mut self.0
  |             ^^^^^^^^^^^ lifetime mismatch
  |
  = note: expected type `&mut &'b mut [i32]`
             found type `&mut &'a mut [i32]`
note: the lifetime 'b as defined on the impl at 4:5...
 --> src/main.rs:4:5
  |
4 |     impl<'a, 'b> Array<'a, 'b> {
  |     ^^^^^^^^^^^^^^^^^^^^^^^^^^
note: ...does not necessarily outlive the lifetime 'a as defined on the impl at 4:5
 --> src/main.rs:4:5
  |
4 |     impl<'a, 'b> Array<'a, 'b> {
  |     ^^^^^^^^^^^^^^^^^^^^^^^^^^

De hecho, obtienes dos errores, también hay uno con los roles de 'ae 'bintercambiado. Al second_rowobservar la anotación de , encontramos que la salida debería ser &mut &'b mut [i32], es decir, se supone que la salida es una referencia a una referencia con duración 'b(la duración de la segunda fila de Array). Sin embargo, debido a que estamos devolviendo la primera fila (que tiene una vida útil 'a), el compilador se queja de una falta de coincidencia de por vida. En el lugar correcto En el momento adecuado. La depuración es muy fácil.


0

Pienso en una anotación de por vida como un contrato sobre una referencia dada válida en el ámbito de recepción solo mientras siga siendo válida en el ámbito de origen. Declarar más referencias en el mismo tipo de vida fusiona los ámbitos, lo que significa que todas las referencias de origen deben cumplir con este contrato. Dicha anotación permite al compilador verificar el cumplimiento del contrato.

Al usar nuestro sitio, usted reconoce que ha leído y comprende nuestra Política de Cookies y Política de Privacidad.
Licensed under cc by-sa 3.0 with attribution required.