Ownership en Rust – Aprende Rust 6/x

Muchos lenguajes de programación implementan out-of-the-box un recolector de basura (garbage collector o GC) que se usa, precisamente, para liberar memoria de variables que ya no vayamos a usar. Otros lenguajes necesitan que indiquemos explícitamente la memoria que vamos a usar y cuándo la vamos a liberar. En Rust, esto se aplica de manera un poco diferente, a través de lo que se llama Ownership y se hace en tiempo de compilación. Si alguna de las reglas del Ownership en Rust se rompe, el programa no va a compilar y nos lo hará saber.

En este post vamos a explorar conceptos complejos, pero que sirven para entender el enfoque de Rust y por qué es tan eficiente.

¿Pero qué es? Ya lo veremos, pero quedémonos con esto: en Rust una variable solo puede tener un dueño (Owner) a la vez, es decir, un valor solo puede pertenecer a un variable y no se comparte.

Stack y Heap son conceptos usados para determinar el “tipo” de memoria que usaremos durante la ejecución del programa (runtime). Las dos son memoria, pero se organizan y acceden de diferente manera. Veamos cómo influye este en el Ownership en Rust.

Stack

Stack usa la estructura LIFO (Last In First Out), o sea, el último elemento en entrar es el primero en salir. ¿Pero qué es esto y por qué es importante? Porque en tiempo de compilación, todo tiene un orden, es decir, la variable A está antes que la B y la función X es llamada desde el main. Todo ya está escrito.

Rust lo reconoce y le pone un orden para determinar cuándo entra un valor al stack y cuándo sale, de manera que no tenemos que estar buscando variables o funciones que “meter” o “sacar” entre medio de estas, ya que sería más complicado. Es por eso que aquí todos los datos almacenados tienen una longitud fija. Si algún dato no lo tenemos claro o cambiará durante el runtime se va al heap.

Heap

Aquí hay piezas de información por todos lados, entrando y saliendo. Si queremos, por ejemplo, agregar un elemento a un String, nuestro programa tiene que encontrar un lugar adecuado en la memoria para estos valores, según las vayamos creando, agregando o destruyendo. Una vez encontrado el lugar adecuado, nos regresa un apuntador o puntero (pointer). Este proceso se llama allocating.

¿Qué es más rápido? Obviamente el stack porque siempre se sabe que el lugar más adecuado es al principio del espacio reservado. En cambio el HEAP debe buscar en la memoria y encontrar un lugar adecuado donde quepan mi valores. Es por esto mismo que acceder al heap es mucho más lento ya que tiene que seguir el pointer, recuperar el valor y continuar.

Reglas

Primero veamos las reglas que surgen al utilizar el concepto de Ownership en Rust:

  1. Cada valor tiene un owner
  2. Solo puede tener un owner a la vez
  3. Cuando el owner ya no se encuentra en el scope que estamos ejecutando, ese valor se desecha.

Scope en Rust

Es scope es la porción de código donde la variable vive y puede ser utilizada:

fn main(){
    let s = "Manuel"
}

Entre las llaves del main, la variable s puede ser usada. Fuera de estas llaves el valor no existe y no puede ser usada o referenciado. Decimos que:

  • s entra al scope y es válida
  • Se mantiene válida hasta que salga del scope

Hasta acá nada nuevo. Igual que en otros lenguajes.

Tipo String

Cuando vimos los tipos de datos en Rust, solo mencionamos este tipo de valor muy por encima, solo para confirmar que Rust tiene un tipo String. Con el ownership cobra más sentido y nos ayuda a entender este concepto.

En ese post vimos más que nada, los valores que tienen un valor fijo como i32, usize o en casos de Strings, el string literal, por lo que pueden ser almacenados en el stack cuando los necesitamos y removidos de él cuando ya no los necesitamos. Sin problema.

En nuestro código de más arriba, vimos que declaramos s con un valor fijo, pero en la vida real, las cosas no son tan sencillas, ya que debemos capturar nombres, direcciones o datos cuyas longitudes desconocemos por completo y no podemos usar valores fijos e inmutables, ahora necesitamos valores mutables y con una longitud desconocida.

El tipo String maneja los datos en el heap. Declaremos una variable así:

let s = String::from("hello");

Aquí construimos un String desde una cadena de texto fija. Esto nos permitirá crear un texto que puede cambiar en el futuro y lo haremos de la siguiente manera:

let mut s = String::from("hello");
s.push_str(", world!");
println!("{}", s);

Al imprimir el valor será hello, world!.

Esta es la principal diferencia entre las cadenas que pueden cambiar y las que no: podemos, mediante push_str, modificar su valor de origen.

Memoria y Allocation

Cuando usamos un string literal conocemos su longitud y valor, es por ello que son muy eficientes. Pero esto solo se aplica para este tipo de string, es decir, cadenas de texto que no cambian su valor. Para lo que son mutables no aplican, ya que no podemos “guardar” un montón de espacio en el stack esperando que ahí se guarden los posibles valores.

Esto significa que tenemos que guardar el valor en el heap ya que no conocemos su longitud en tiempo de compilación:

  • La memoria requerida debe ser llamada en runtime: este se llama cuando hacemos String::from.
  • Necesitamos devolver esa memoria cuando ya no necesitemos el string. Esto es un poco más complejo. En algunos lenguajes entra el GC, pero si no tiene debemos eliminar esos datos de memoria nosotros mismos. Ya se imaginarán los problemas de esto.

Veamos cómo Rust trata esta liberación de memoria:

fn main(){
    let s = String::from("hello");
}

Hasta aquí vemos el programa normal. Declaramos s y ahora podemos usarla, agregarle, quitarle, lo que sea, pero una vez que el scope se va y s ya no es válida, Rust llama a una función llamada drop para eliminarla de la memoria.

Veamos otro ejemplo:

let x = 5;
let y = x;

Aquí lo que vemos es que asignamos el valor 5 a x, luego a y le asignamos lo que tiene x y ahora tenemos dos variables (x y y) con el valor 5. Y pues sí, aquí no hay nada diferente. El por qué es que ya sabemos el tipo asignado, por lo tanto su longitud, entonces agregamos estos dos valores al stack y se acabó.

¿Pero cómo sería en el ejemplo de nuestro String?

let s1 = String::from("hello");
let s2 = s1;

Aquí todo parece similar al ejemplo de los enteros, pero no. En realidad aquí comienza lo bueno.

declarar string
Imagen 1.

Como vemos, s1 consta de 3 partes:

  • ptr: el apuntador a la dirección de memoria donde encontraremos el valor.
  • len: en la longitud actual de s1.
  • capacity: es la capacidad máxima que la variable puede tener antes de tener que cambiar su tamaño. Luego veremos su diferencia interna con len.

Al lado derecho vemos el propio valor, comenzado del index 0 hasta el 4.

Cuando nosotros hacemos la asignación de s2 = s1 lo que hacemos es copiar la estructura de la izquierda (que va al stack), no el valor de la derecha (que está en el heap). Como lo vemos aquí:

dos strings en rust
Imagen 2.

Si copiáramos el valor se vería algo así:

deep copy en rust
Imagen 3

Esto es demasiado costoso en recursos para nuestros programas.

En la Imagen 2 vemos que tanto s1 como s2 apuntan a donde mismo. Esto puede ser un problema, ya que cuando las dos variables salen del scope las dos tratarán de liberar su valor, pero no podrán porque una ya lo habrá liberado antes. ¿Y luego? Bueno pues acá Rust lo que hace es que una vez asignamos el valor de s1 a s2, s1 ya no es válido, es decir, esa variable ya no existe, aunque se encuentre dentro del scope.

Intentemos lo siguiente:

fn main() {
    let s1 = String::from("hello");
    let s2 = s1;

    println!("{}, world!", s1);
}

Ahora lo ejecutamos con cargo run. El horror:

error move en rust
Imagen 4

Aquí vemos el error move. Esto quiere decir que mediante la asignación movimos (moved) el valor de s1 a s2.

ownership en rust
Imagen 5

Haciendo esto, nos deja solamente la variable s2 viva y esta será la que libere el hello de la memoria.

¿Qué pasa si quieres crear el comportamiento que planteamos antes? Es decir, que s1 tenga el valor hello y también s2 tenga el valor hello. Bueno pues esto se llama “deep copy”. En el error podemos ver que para realizar esto, debemos llamar al método clone.

fn main() {
    let s1 = String::from("hello");
    let s2 = s1.clone();

    println!("s1 = {}, s2 = {}", s1, s2);
}

Este ejemplo funciona y se comporta como en la Imagen 3. Lo que quiere decir que cada variable tiene su proprio espacio en memoria y cuando se vaya el scope cada una liberará su propio valor, sin errores.

Ahora veamos lo siguiente:

fn main() {
    let x = 5;
    let y = x;

    println!("x = {}, y = {}", x, y);
}

Si compilamos esto, no nos dará error. ¿Pero por qué si no hemos llamado clone en la asignación de y? ¿Qué no se supone que solo y debería ser válido y x ya estará fuera de scope? La razón es que los enteros (i32 en este caso) tienen una longitud fija y el compilador sabe esto en tiempo de compilación, así que no hay problema en copiar el valor directamente.

Rust tiene una notación especial llamada Copy. Esta se usa en los tipos que pueden ser guardados en el stack, como i32. Si un tipo lo implementa, las variables no se pueden hacer el move que vimos anteriormente al cambiar de ownership el tipo String, pero pueden copiar su valor, como en el ejemplo anterior.

Como mencionamos antes, al salir de scope, una variable puede llamar Drop, que elimina esa variable y ya no está accesible. Sin embargo, no todos los tipos lo implementan y aquellos que sí lo hacen no pueden llamar a Copy.

En resumen, como el tipo String implementa Drop, ya que cambia el ownership cuando se asigna a una variable, no se puede implementar Copy y por ende no puede realizar el “deep copy” si no es indicado explícitamente mediante clone.

Tipos que implementan Copy.

  • Todos los enteros
  • El tipo Boolean
  • Todos los tipos flotantes, como f64
  • El tipo char
  • Las tuplas, siempre y cuando solo contenga tipos que de origen ya lo implementen. Por ejemplo es válido (i32,u32) pero sería inválido (i32, String)

Funciones y Ownership en Rust

Para pasar un valor a una función, funciona similar a la asignación que ya vimos.

fn main() {
    let s = String::from("hello");  // s entra al scope

    takes_ownership(s);             // s se mueve (move) a la función
                                           // s aquí ya no vale

    let x = 5;                       // x entra al scope

    makes_copy(x);                  // x se envía a la función,
                                    // i32 es copiado Copy, podemos usar
                                    // x después

} // x sale del scope y luego s.

fn takes_ownership(some_string: String) { // some_string entra al scope
    println!("{}", some_string);
} // some_string sale del scope y `drop` es llamado. Se libera la memoria

fn makes_copy(some_integer: i32) { // some_integer entra al scope
    println!("{}", some_integer);
} // some_integer sale del scope

Si queremos usar s después de takes_ownership no vamos a poder. ¿Por qué? Porque ahora quien tiene el ownership es la función. Lo que nos daría el error “move occurs because s has type String, which does not implement the Copy trait”. En pocas palabras, como no hemos hecho un deep copy del String y lo hemos asignado a una variable, ya no podemos usar s aunque esté dentro del scope de main.

Regresar valores al scope en Rust

¿Entonces qué hago si quiero procesar un valor y luego volver a usarlo, sin tener que cambiar de variable de ownership en Rust?

Veamos el siguiente ejemplo:

fn main() {
    let s1 = gives_ownership();         // gives_ownership moves its return
                                        // value into s1

    let s2 = String::from("hello");     // s2 comes into scope

    let s3 = takes_and_gives_back(s2);  // s2 is moved into
                                        // takes_and_gives_back, which also
                                        // moves its return value into s3
    println!("s3 = {} --- s1 = {}", s3, s1);
} // Here, s3 goes out of scope and is dropped. s2 was moved, so nothing
  // happens. s1 goes out of scope and is dropped.

fn gives_ownership() -> String {             // gives_ownership will move its
                                             // return value into the function
                                             // that calls it

    let some_string = String::from("yours"); // some_string comes into scope

    some_string                              // some_string is returned and
                                             // moves out to the calling
                                             // function
}

// This function takes a String and returns one
fn takes_and_gives_back(a_string: String) -> String { // a_string comes into
                                                      // scope

    a_string  // a_string is returned and moves out to the calling function
}

El ownership de una variable siempre sigue el mismo patrón: cuando la asignas a otra variable, esta hace el move. Si una variable sale de scope y ya no será usada nunca, ni por la variable a la que fue movida, será liberada aplicando el drop.

Pero esto de andar moviendo las variables y pasando el ownership puede ser complicado trabajar con variables.

Tenemos este ejemplo:

fn main() {
    let s1 = String::from("hello");

    let (s2, len) = calculate_length(s1);

    println!("The length of '{}' is {}.", s2, len);
}

fn calculate_length(s: String) -> (String, usize) {
    let length = s.len(); // len() returns the length of a String

    (s, length)
}

Aquí vemos lo siguiente:

  1. Creamos el String “hello” asignado a s1
  2. Enviamos s1 a la función calculate_length
  3. Esta función recibe un String y regresa una tupla con un String y un usize
  4. Luego creamos dos variables: s2 y len para que sean llenadas con el resultado de calculate_length

Para “recuperar” el ownership de s1 debemos crear la variable s2. s1 ya no sería válido, pero ahora el apuntador hacia el valor lo tiene s2.

Con esto resolvemos, parcialmente el problema. Y ahora ya podemos usar el mismo valor que fue enviado a la función después de haberle pasado el ownership a la función. No es muy elegante, pero funciona.

Continuaremos con este tema en el siguiente post de Ownership en Rust.

Gracias por leer.


Posted

in

, , ,

by