Algoritmos y Estructuras de Datos Herramientas Lenguaje de programación
!Prog C/C++ Rust
Linux Matemáticas
Mates Discretas
Programación Orientada a Objetos Sistemas Operativos

Memoria


[words: 2576] [reading time: 13min] [size: 90348 bytes]

Un resumen del sistema de ownership y borrowing, tipos de memoria (Stack y Heap), lifetimes, Smart Pointers y otros detalles importantes para entender cómo funciona Rust gestiona la memoria a diferencia de C o C++.

Tipos de memoria

Stack

Aquí se guardan datos de la forma último en entrar, primero en salir (LIFO). Para guardar datos, se debe saber con antelación cuanto vamos a guardar y no podrá cambiar de tamaño durante la ejecución del programa.

1fn foo() {
2  let var = 10; // push to the stack
3  // operations with `var`
4} // var goes out of scope, popped from de stack (data deleted)

Heap

Este tipo de memoria es completamente desorganizada. Se guardan los datos en medida que se vayan necesitando, de modo que podemos cambiar su tamaño.

Su funcionamiento es el siguiente:

  1. Se busca un sitio libre del tamaño requerido en memoria y se guardan los datos allí.
  2. Para poder acceder a esos datos, devuelve un puntero a esta memoria. Y como este es de tamaño fijo, podemos guardarlo en la stack, de forma que usamos ese puntero para leer/escribir los datos.
1int * var; // push to the stack a pointer to an int
2
3int main() {
4  var = new int; // allocate in the heap a int
5  // operations with var
6  delete var; // manually delete the memory on the heap
7} // var goes out of scope, popped from de stack (data deleted)

Stack vs Heap

Por lo general, la heap es mucho más lenta que la stack, porque para guardar datos primero tiene que buscar un sitio; y para leer tiene que seguir un puntero; pero sigue siendo necesaria por el hecho de almacenar datos de tamaño variable, pero en caso contrario, se debería evitar su uso.

Ownership and borrowing

Hay varias formas de manejar la memoria:

Ownership

Esto es la asociación de un valor con un nombre (variable). Las reglas son las siguientes:

  1. Cada valor tiene una variable y se le llama su dueño.
  2. Solamente puede haber un dueño a la vez.
  3. Cuando el dueño se va out of scope, los datos son borrados.

Variable scope

1{ // s is not valid here, it's not yet declared
2  let s = "hello"; // s is valid from this point forward
3  // do stuff with s
4} // this scope is now over, and s is no longer valid

"hello" se guarda en la stack porque es de tamaño fijo, pero no se puede cambiar su valor. En cambio, si usamos String, guardamos los datos en la heap y entonces ya podremos modificarla.

1{ // s is not valid here, it's not yet declared
2  let s = String::from("hello"); // s is valid from this point forward
3  // do stuff with s
4} // this scope is now over, and s is no longer valid (popped from the stack
5  // and the data doesn't have an owner, so it gets freed automatically)

Interactivity

1let x = 5; // push to the stack
2let y = x; // copy (it is a value on the stack)
1let s1 = String::from("hello"); // allocate on the heap
2let s2 = s1; // move (all the data is moved from s1 to the variable s2. Now the
3             // owner of "hello" is s2)
4// cannot use s1 anymore, because it doesn't have any data.
5
6// SOLUTION
7let s2 = s1.clone(); // clone all the data (get a copy of each value and store
8                     // it on new memory -very expensive-)
 1fn main() {
 2  let s = String::from("hello"); // allocate on the heap
 3  foo(s); // -> some_string = s // move the ownership to some_string
 4  // cannot use s anymore, because it doesn't have any data
 5}
 6fn foo(s1: String) {
 7  // some operations
 8} // some_string gets dropped ("hello" is removed)
 9
10// SOLUTION (not recommended)
11fn main() {
12  let mut s = String::from("hello"); // allocate on the heap
13  s = foo(s);
14  // now we can use s here
15}
16fn foo(s1: String) -> String { // some_string = s // takes ownership
17  // some operations
18  some_string // s = some_string // gives back ownership
19}
20
21// SOLUTION (borrowing)
22fn main() {
23  let s = String::from("hello"); // allocate on the heap
24  foo(&s); // create a pointer to s
25  // now we can use s here
26}
27fn foo(s1: &String) { // gets ownership of the pointer, but not actual memory
28  // some operations
29} // the pointer gets dropped, but not the memory

Esto no ocurre con valores del stack, simplemente se hace una copia.

Borrowing

Este es un diagrama de la solución del último ejemplo.

Como ya vimos, podemos crear una referencia añadiendo & delante.

Nota: Lo contrario a crear una referencia (al usar &) es desreferenciar, que se consigue usando *.

Hay que tener en cuenta, que la sintaxis puede ser un poco diferente a otros lenguajes: para indicar que el valor es una referencia, añadimos & delante del tipo de dato; y para crear la referencia, añadimos el & delante del valor.

El uso de estas referencias es muy útil en casos donde no queramos tomar ownership de una variable, pero igualmente necesitamos su valor.

En casos donde es necesario modificar ese valor, debemos crear una referencia mutable: &mut.

Las reglas de las referencias son las siguientes:

video

Lifetimes

Algunos links de ayuda:

Todas las referencias en Rust tienen un lifetime, que es el scope por la cual la referencia es válida. Pero la mayoría del tiempo son implícitas por el compilador.

Las referencias deben de ser siempre válidas, si tenemos una referencia a un valor borrado el compilador lo sabrá y dará un error. Esto se comprueba con el borrow checker.

Las normas que usa el compilador para asignar las lifetimes:

1let reference;
2{
3  let value = 10;
4  reference = &value;
5}
6// reference no es válida aquí (dangeling reference)

dangling references

 1let s1 = String::from("a");
 2let s2 = String::from("abcdef");
 3
 4let result = longest(s1.as_str(), s2.as_str());
 5println!(result);
 6
 7// &i32: referencia
 8// &'a i32: referencia con lifetime
 9// &'a mut i32: mut ref con lifetime
10fn longest<'a>(a: &'a str, b: &'a str) -> &'a str{ // sin la generic da error
11  if a.len() > b.len() {
12    a
13  } else {
14    b
15  }
16}
1struct Name<'a>{
2  someting: &'a str;
3}

Smart Pointer

una mejor explicación

Se trata de un puntero, una variable que guarda una dirección de memoria, por lo que es una variable que contiene otra variable (y sus datos). Lo más común es la referencia.

Smart Pointers hacen un seguimiento de los punteros a una memoria en concreto, y cuando no hay más punteros, los datos se borran.

A diferencia de las referencias, los Smart Pointers son dueños de los datos de alguna forma. Los vectores y los strings son Smart Pointers: tienen metadatos, son dueños de los datos.

Smart Pointers implementan los traits Deref y Drop.

Deref

Este trait nos permite implementar el operador para desreferenciar un valor (*) en una estructura de datos creada por nosotros. Sin ella, el compilador solo sabrá como desreferenciar una referencia, no un objecto creado por nosotros.

Para las referencia mutables, en cambio, debemos usar el trait DerefMut

 1struct MyBox<T>(T); // Tuple Struct
 2
 3impl<T> MyBox<T> {
 4  fn new(x: T) -> MyBox {
 5    MyBox(x) // Para este ejemplo no es necesario guardar el valor en heap
 6  }
 7}
 8
 9impl<T> Deref for MyBox<T> {
10  type Target = T;
11
12  fn deref(&self) -> &T {
13    &self.0 // Devuelve una referencia al valor guardado.
14            // Se debe devolver una referencia, por el sistema de ownership que
15            // ya vimos. Y esta referencia podrá ser desreferenciada por el
16            // compilador como siempre.
17  }
18}
19
20fn main() {
21  let x = 5;
22  let y = MyBox::new(x);
23
24  assert_eq!(5, x);
25  assert_eq!(x, *y); // Equivalente a: *(y.deref())
26}

Dered Coercion: En algunos casos, la desreferencia ya se llama automáticamente, ya que ayuda a crear un código más legible:

 1fn main() {
 2  let m = MyBox::new(String::from("Rust"));
 3  hello(&m); // La función toma un `&str`, pero le estamos pasando un `MyBox`
 4             // Aquí se ha desreferenciado automaticametente.
 5  // &MyBox<String> -> &String -> &str (String también implementa Deref)
 6}
 7
 8fn hello(name: &str) {
 9  println!("Hello, {}!", name);
10}

Estas son las reglas donde se usa Deref Coercion:

El caso donde no hay Deref Coercion es desde &T a &mut U, ya que esto va en contra de las reglas de borrowing.

Drop

Este trait permite personalizar qué es lo que ocurre cuando un valor sale de scope y debe ser eliminado. Funciona algo así como un destructor. En el caso de Box, el comportamiento que queremos es que se eliminen los datos guardados en la heap.

Este método también se llama automáticamente, pero podemos llamarlo para eliminar el valor prematuramente: drop(T) (no se permite llamar directamente .drop())

 1struct Entity(i32);
 2
 3impl Drop for Entity {
 4  fn drop(&mut self) {
 5    // Aquí manejamos qué ocurre cuando se va a eliminar este objeto
 6    println!("Borrando Entity con valor {}...", self.0);
 7  }
 8}
 9
10fn main() {
11  let my_entity = Entity(10);
12  // ...
13} // Aquí se debe eliminar `my_entity`, por lo que se llama a `drop` y imprimirá
14  // "Borrando Entity con valor 10...".

Box

Box<T> es un Smart Pointer que guarda datos en la heap, mientras que el propio Box<> se guarda en la stack. Esta es una dirección de memoria a los datos que en tiene guardados en la heap.

1// Guarda un i32 con valor 5 en heap
2let b: Box<i32> = Box::new(5);
1enum List<T> {
2  // Rust necesita saber cuanto espacio ocupa List, debemos hacer Box<List<T>>
3  Cons(T, Box<List<T>>),
4  Nul
5}
6
7use List::{Cons, Nul};
8let list = Cons(1, Box::new(Cons(2, Box::new(Cons(3, Box::new(Nul))))));

Contando referencias - Shared Smart Pointer

En la mayoría de los casos, los valores tienen siempre un solo dueño, pero en otros, es necesario que sea un valor compartido.

Piensa en este ejemplo: una persona enciende una televisión y se pone a verla. A medida que más personas vienen a ver la televisión, esta se mantiene encendida. Cuando las personas se van marchando, la última es la encargada de apagar la tele. Si se hubiese apagado mientras todavía hay personas viendo, el resultado será panic.

Puedes intercambiar a las personas por variables y la televisión por los datos: si la primera persona al irse (fuera del scope), apagase la tele (borrase los datos); el resto no podría usarla.

Nota: Esto solo se puede usar en programas de un solo thread.

 1use std::rc::Rc;
 2
 3fn main() {
 4  let value = "ejemplo".to_string();
 5
 6  {
 7    // Se crea `a`. Hay 1 referencia a `value`.
 8    let a = Rc::new(value);
 9
10    {
11      // Se clona `a` a `b`. Ahora hay dos referencias a `value`
12      let b = Rc::clone(&a);
13
14      // Los dos son iguales
15      assert_eq!(a, b);
16
17      // Podemos usar los valores normalmente
18      println!("{}", a);
19    } // Se borra `b`. Hay una referencia a `value`
20  } // Se borra `a`. No hay ninguna referencia a `value`, también se borra.
21
22  // No se puede usar `value`, porque se ha movido dentro del `Rc` y porque se
23  // ha eliminado al salir este de scope.
24}

El método Rc::clone(Rc<T>) no creará una copia tal cual del valor (ya que es no es eficiente ni necesario), si no que simplemente aumentará el número de referencias al valor. Cuando se eliminen todas las referencias, el valor se eliminará.

Podemos saber cuantas referencias tiene usando el método Rc::strong_count(&Rc<T>).

Interior Mutability

Interior Mutability nos permite mutar datos incluso cuando tenemos referencias inmutables a esos datos. Esto se consigue usando código no seguro en una estructura de datos, llamada RefCell.

Se trata de código que no está comprobado por el compilador siguiendo las reglas convencionales de ownership, borrowing, etc. Esto se hace para incrementar la flexibilidad. Y, en lugar de comprobar las reglas en el momento de compilación, se hace en el momento de ejecución. En caso de error, panic!.

Por norma general, es mejor comprobar estas reglas al compilar, además de que no afectan al rendimiento. Sin embargo, en algunos casos, comprobar las reglas en el momento de ejecución pueden permitir acciones que el compilador no. El ejemplo más famoso es el Problema de la Parada, que no se puede determinar haciendo un análisis estático.

Nota: Esto solo se puede usar en programas de un solo thread.

Cell

std::cell::Cell nos permite tener varias referencias mutables al mismo valor, siempre y cuando este sea un valor de la Stack (que implemente Copy), entonces no habrá problema el tener dos referencias mutables.

1let x = Cell::new(0);
2let y = &x;
3let z = &x;
4
5x.set(1); // x = 1, y = 1, z = 1
6x.set(1); // x = 2, y = 2, z = 2
7x.set(1); // x = 3, y = 3, z = 3

Esto es equivalente a (pero no compila)

1let x = 0;
2let y = &mut x;
3let z = &mut x;
4
5x = 1   // x = 1, y = 1, z = 1
6*y = 2; // x = 2, y = 2, z = 2
7*z = 3; // x = 3, y = 3, z = 3

Técnicamente, no hay coste en tiempo de ejecución, pero deberíamos considerar, a la hora de copiar un gran struct solo por un solo campo, hacer ese campo un Cell en lugar de todo el struct.

RefCell

Funciona de forma similar a Cell, pero también se puede usar con datos en la Heap, sin embargo, sí que tiene coste en tiempo de ejecución.

1let a = 5;
2let b = &mut a; // Error: `a` no es mutable
3
4let mut c = 5;
5let d = &c;
6*d = 2; // Error: `d` no es mutable

Guardar en Heap

Recapitulación

MemoriaVarios dueñosInmutableMutableComprobación
BoxHeapNoCompilar
RcHeapNoCompilar
CellStackNoCompilar
RefCell?NoEjecución

tipos de memoria diagrama de ownership

Anterior: Documentación, Cargo y Crates Volver a Rust Siguiente: Errores en Rust