¿Por qué se necesitan vidas útiles explícitas en Rust?
Estaba leyendo el capítulo sobre vidas del libro Rust y encontré 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
Para mí está bastante claro que el error que evita el compilador es el uso después de la liberación de la referencia asignada a x
: después de que se completa el alcance interno f
y, por lo tanto &f.x
, deja de ser válida y no debería haberse asignado a x
.
Mi problema es que el problema podría haberse analizado fácilmente sin utilizar la vida útil explícita 'a
, por ejemplo, infiriendo una asignación ilegal de una referencia a un alcance más amplio ( x = &f.x;
).
¿En qué casos se necesitan realmente tiempos de vida explícitos para evitar errores de uso después de la liberación (o de alguna otra clase)?
Todas las otras respuestas tienen puntos destacados ( el ejemplo concreto de fjh donde se necesita una vida útil explícita ), pero les falta una cosa clave: ¿por qué se necesitan vidas útiles explícitas cuando el compilador le dirá que se equivocó ?
En realidad, esta es 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 archivo &'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 fue su intención.
Las funciones son un límite natural para el cortafuegos y los efectos del cambio de código. Si permitiéramos que las duraciones se inspeccionaran completamente desde el código, entonces un cambio aparentemente inocente podría afectar las duraciones, lo que podría causar errores en una función lejana. Este no es un ejemplo hipotético. Según tengo entendido, Haskell tiene este problema cuando confías en la inferencia de tipos para funciones de nivel superior. Rust cortó ese problema particular de raíz.
También hay un beneficio de eficiencia para el compilador: solo es necesario analizar las firmas de funciones para verificar los tipos y la duración. Más importante aún, tiene un beneficio de eficiencia para el programador. Si no tuviéramos tiempos de vida explícitos, ¿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 ámbito más amplio
Los alcances son vidas, esencialmente. Un poco más claramente, una vida útil 'a
es un parámetro de vida útil genérico que se puede especializar con un alcance específico en el momento de la compilación, según el sitio de llamada.
¿Se necesitan realmente tiempos de vida explícitos para evitar [...] errores?
De nada. Se necesitan tiempos de vida para evitar errores, pero se necesitan tiempos de vida explícitos para proteger la poca cordura que tienen los programadores.
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 foo
tiene la misma duración 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 foo
compilador, se quejaría de que y
no 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
La anotación de por vida en la siguiente estructura:
struct Foo<'a> {
x: &'a i32,
}
especifica que una Foo
instancia no debe sobrevivir a la referencia que contiene ( x
campo).
El ejemplo que encontró en el libro de Rust no ilustra esto porque las variables f
y y
salen del alcance 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, f
realmente sobrevive a la variable señalada por f.x
.
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 la duración de los archivos main()
.
Sin embargo, en las definiciones de tipos, las duraciones explícitas son inevitables. Por ejemplo, aquí hay una ambigüedad:
struct RefPair(&u32, &u32);
¿Deberían ser vidas diferentes o deberían ser iguales? Sí 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 usted proporcionó, el compilador teóricamente podría eludir tiempos de vida como lo hace en otros lugares, pero tales casos son muy limitados y no merecen una complejidad adicional en el compilador, y esta ganancia en claridad estaría en el lo menos cuestionable.