pointers - ¿Por qué se necesitan vidas explícitas en Rust?
reference static-analysis (9)
Como recién llegado a Rust, entiendo que las vidas explícitas tienen dos propósitos.
-
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.
-
Si usted (el compilador) quiere comprobar si un código es válido, usted (el compilador) no tendrá que mirar 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
df
está compartiendo memoria con
ar
, por lo que cuando parte del contenido de
df
cambia en el
work
, ese cambio también infecta a
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ón
second_row
está devolviendo la primera fila en lugar de la segunda;
buena suerte depurando eso.
Considere en cambio 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
''a
y
''b
intercambiados.
Al
second_row
la anotación de
second_row
, 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 una vida útil
''b
(la vida útil 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.
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 la referencia asignada a
x
: después de que se realiza el alcance interno,
f
y, por lo tanto,
&f.x
vuelven inválidos, y no deberían haberse asignado a
x
.
Mi problema es que el problema podría haberse analizado fácilmente
sin
usar el
explícito
''a
toda
''a
vida, 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)?
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 vida útil que su primer argumento (
''a
), por lo que puede sobrevivir a su segundo argumento.
Esto se expresa por los nombres de por vida en la firma de
foo
.
Si cambiaste los argumentos en la llamada a
foo
el compilador se quejaría de que
y
no vive 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
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 el tiempo de vida de una función con múltiples argumentos.
Además, mi propia caja
optional
tiene un tipo
OptionBool
con un método
as_slice
cuya firma en realidad es:
fn as_slice(&self) -> &''static [bool] { ... }
No hay absolutamente ninguna manera de que el compilador haya podido resolverlo.
He encontrado otra gran explicación aquí: http://doc.rust-lang.org/0.12.0/guide-lifetimes.html#returning-references .
En general, solo es posible devolver referencias si se derivan de un parámetro al procedimiento. En ese caso, el resultado del puntero siempre tendrá la misma vida útil que uno de los parámetros; las vidas nombradas indican qué parámetro es ese.
La anotación de por vida en la siguiente estructura:
struct Foo<''a> {
x: &''a i32,
}
especifica que una instancia de
Foo
no debería sobrevivir a la referencia que contiene (campo
x
).
El ejemplo que encontró en el libro Rust no ilustra esto porque las variables
f
e
y
quedan fuera 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 apuntada por
fx
.
La razón por la que 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.
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.
Tenga en cuenta que no hay vidas explícitas en ese 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, aquí hay una ambigüedad:
struct RefPair(&u32, &u32);
¿Deberían ser vidas diferentes o deberían ser las mismas?
Importa desde la perspectiva de 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.
Todas las otras respuestas tienen puntos destacados ( 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 es un parámetro genérico de vida útil que puede especializarse con un alcance específico en el momento de la compilación, en función del sitio de la llamada.
¿son realmente necesarias vidas explícitas para evitar [...] errores?
De ningún modo. Se necesitan vidas para evitar errores, pero se necesitan vidas explícitas para proteger lo poco que tienen los programadores de cordura.