rust move move-semantics ownership

¿Qué son las semánticas de movimiento en Rust?



move move-semantics (4)

En Rust, hay dos posibilidades para tomar una referencia.

  1. Pedir prestado , es decir, tomar una referencia pero no permitir la mutación del destino de referencia. El operador & toma prestada la propiedad de un valor.

  2. Pedir prestado de forma mutable , es decir, tomar una referencia para mutar el destino. El operador &mut toma prestada la propiedad de un valor.

La documentación de Rust sobre las reglas de préstamo dice:

Primero, cualquier préstamo debe durar un alcance no mayor que el del propietario. En segundo lugar, puede tener uno u otro de estos dos tipos de préstamos, pero no ambos al mismo tiempo:

  • una o más referencias ( &T ) a un recurso,
  • exactamente una referencia mutable ( &mut T ).

Creo que tomar una referencia es crear un puntero al valor y acceder al valor mediante el puntero. Esto podría ser optimizado por el compilador si hay una implementación equivalente más simple.

Sin embargo, no entiendo qué significa el movimiento y cómo se implementa.

Para los tipos que implementan el rasgo Copy , significa copiar, por ejemplo, asignando la estructura miembro-sabio de la fuente, o un memcpy() . Para estructuras pequeñas o para primitivas, esta copia es eficiente.

¿Y para moverse ?

Esta pregunta no es un duplicado de ¿Qué son las semánticas de movimiento? porque Rust y C ++ son lenguajes diferentes y la semántica de movimiento es diferente entre los dos.


Al pasar un valor a la función, también se transfiere la propiedad; Es muy similar a otros ejemplos:

struct Example { member: i32 } fn take(ex: Example) { // 2) Now ex is pointing to the data a was pointing to in main println!("a.member: {}", ex.member) // 3) When ex goes of of scope so as the access to the data it // was pointing to. So Rust frees that memory. } fn main() { let a = Example { member: 42 }; take(a); // 1) The ownership is transfered to the function take // 4) We can no longer use a to access the data it pointed to println!("a.member: {}", a.member); }

De ahí el error esperado:

post_test_7.rs:12:30: 12:38 error: use of moved value: `a.member`


Cuando mueves un artículo, estás transfiriendo la propiedad de ese artículo. Ese es un componente clave de Rust.

Digamos que tenía una estructura, y luego asigno la estructura de una variable a otra. Por defecto, esto será un movimiento, y he transferido la propiedad. El compilador hará un seguimiento de este cambio de propiedad y me impedirá usar la variable anterior:

pub struct Foo { value: u8, } fn main() { let foo = Foo { value: 42 }; let bar = foo; println!("{}", foo.value); // error: use of moved value: `foo.value` println!("{}", bar.value); }

cómo se implementa

Conceptualmente, mover algo no necesita hacer nada. En el ejemplo anterior, no habría una razón para asignar espacio en algún lugar y luego mover los datos asignados cuando asigno a una variable diferente. En realidad, no sé qué hace el compilador, y probablemente cambie según el nivel de optimización.

Sin embargo, a efectos prácticos, puede pensar que cuando mueve algo, los bits que representan ese elemento se duplican como si fuera a través de memcpy . Esto ayuda a explicar qué sucede cuando pasa una variable a una función que la consume , o cuando devuelve un valor de una función (nuevamente, el optimizador puede hacer otras cosas para que sea eficiente, esto es solo conceptualmente):

// Ownership is transferred from the caller to the callee fn do_something_with_foo(foo: Foo) {} // Ownership is transferred from the callee to the caller fn make_a_foo() -> Foo { Foo { value: 42 } }

"¡Pero espera!", Dices, "¡ memcpy solo entra en juego con los tipos que implementan Copy !". Esto es mayormente cierto, pero la gran diferencia es que cuando un tipo implementa Copy , ¡tanto el origen como el destino son válidos para usar después de la copia!

Una forma de pensar en la semántica de movimiento es la misma que la semántica de copia, pero con la restricción adicional de que la cosa que se está moviendo ya no es un elemento válido para usar.

Sin embargo, a menudo es más fácil pensarlo de otra manera: lo más básico que puede hacer es mudarse / ceder la propiedad, y la capacidad de copiar algo es un privilegio adicional. Así lo modela Rust.

¡Esta es una pregunta difícil para mí! Después de usar Rust por un tiempo, la semántica de movimiento es natural. Déjame saber qué partes he omitido o explicado mal.


Por favor, déjame responder mi propia pregunta. Tuve problemas, pero al hacer una pregunta aquí, resolví los problemas de Rubber Duck . Ahora entiendo:

Un movimiento es una transferencia de propiedad del valor.

Por ejemplo, la asignación let x = a; Transferencia de propiedad: Al principio, a propietario del valor. Después de let , es x quien posee el valor. Rust prohíbe usar a partir de entonces.

De hecho, si haces println!("a: {:?}", a); después de let el compilador Rust diga:

error: use of moved value: `a` println!("a: {:?}", a); ^

Ejemplo completo:

#[derive(Debug)] struct Example { member: i32 } fn main() { let a = Example { member: 42 }; // A struct is moved let x = a; println!("a: {:?}", a); println!("x: {:?}", x); }

¿Y qué significa este movimiento ?

Parece que el concepto proviene de C ++ 11. Un documento sobre la semántica de movimiento de C ++ dice:

Desde el punto de vista del código del cliente, elegir mover en lugar de copiar significa que no le importa lo que ocurra con el estado de la fuente.

Ajá. A C ++ 11 no le importa lo que ocurra con la fuente. Entonces, en este sentido, Rust es libre de decidir prohibir el uso de la fuente después de un movimiento.

¿Y cómo se implementa?

No lo sé. Pero me imagino que Rust literalmente no hace nada. x es solo un nombre diferente para el mismo valor. Los nombres generalmente se compilan (excepto, por supuesto, los símbolos de depuración). Entonces, es el mismo código de máquina si el enlace tiene el nombre a o x .

Parece que C ++ hace lo mismo en la copia del constructor elision.

No hacer nada es lo más eficiente posible.


Semántica

Rust implementa lo que se conoce como un sistema de tipo afín :

Los tipos afines son una versión de tipos lineales que imponen restricciones más débiles, correspondientes a la lógica afín. Un recurso afín solo se puede usar una vez , mientras que uno lineal se debe usar una vez.

Los tipos que no son Copy y, por lo tanto, se mueven, son Tipos afines: puede usarlos una vez o nunca, nada más.

Rust califica esto como una transferencia de propiedad en su visión del mundo centrada en la propiedad (*).

(*) Algunas de las personas que trabajan en Rust están mucho más calificadas que yo en CS e implementaron a sabiendas un sistema de tipo afín; Sin embargo, a diferencia de Haskell, que expone los conceptos matemático-y / cs-y, Rust tiende a exponer conceptos más pragmáticos.

Nota: se podría argumentar que los tipos afines devueltos por una función etiquetada con #[must_use] son en realidad tipos lineales de mi lectura.

Implementación

Depende. Tenga en cuenta que Rust es un lenguaje creado para la velocidad, y hay numerosos pases de optimización en juego aquí que dependerán del compilador utilizado (rustc + LLVM, en nuestro caso).

Dentro de un cuerpo funcional (área de playground ):

fn main() { let s = "Hello, World!".to_string(); let t = s; println!("{}", t); }

Si marca el LLVM IR (en Debug), verá:

%_5 = alloca %"alloc::string::String", align 8 %t = alloca %"alloc::string::String", align 8 %s = alloca %"alloc::string::String", align 8 %0 = bitcast %"alloc::string::String"* %s to i8* %1 = bitcast %"alloc::string::String"* %_5 to i8* call void @llvm.memcpy.p0i8.p0i8.i64(i8* %1, i8* %0, i64 24, i32 8, i1 false) %2 = bitcast %"alloc::string::String"* %_5 to i8* %3 = bitcast %"alloc::string::String"* %t to i8* call void @llvm.memcpy.p0i8.p0i8.i64(i8* %3, i8* %2, i64 24, i32 8, i1 false)

Debajo de las cubiertas, rustc invoca una memcpy del resultado de "Hello, World!".to_string() a s luego a t . Si bien puede parecer ineficiente, verificando el mismo IR en el modo Release, se dará cuenta de que LLVM ha eludido por completo las copias (dándose cuenta de que s no se utilizó).

La misma situación ocurre cuando se llama a una función: en teoría, "mueve" el objeto al marco de la pila de funciones, sin embargo, en la práctica, si el objeto es grande, el compilador rustc podría pasar a pasar un puntero.

Otra situación es regresar de una función, pero incluso entonces el compilador podría aplicar la "optimización del valor de retorno" y construir directamente en el marco de la pila de la persona que llama, es decir, la persona que llama pasa un puntero en el que escribir el valor de retorno, que se usa sin Almacenamiento intermedio.

Las restricciones de propiedad / endeudamiento de Rust permiten optimizaciones que son difíciles de alcanzar en C ++ (que también tiene RVO pero no puede aplicarse en tantos casos).

Entonces, la versión de resumen:

  • mover objetos grandes es ineficiente, pero hay varias optimizaciones en juego que podrían eludir el movimiento por completo
  • mover implica una memcpy de std::mem::size_of::<T>() bytes, por lo que mover una String grande es eficiente porque solo tiene un par de bytes, sea cual sea el tamaño del búfer asignado