Aprende Rust

Lección 23 de 26 Rust funcional

Smart pointers: Box, Rc y RefCell

  • 5 min de lectura
  • 3 ejercicios
  1. Box::new(5) guarda el valor en el heap. En el stack queda solo un puntero, que es su único dueño.
  2. Rc permite varios dueños. Rc::clone(&a) no copia el dato: solo suma uno al contador de dueños.
  3. Cada vez que un dueño desaparece, el contador baja. Al llegar a 0, el valor se libera solo.
  4. RefCell comprueba los préstamos al ejecutar: pedir borrow_mut() con otro préstamo activo provoca un panic.
1/4

Un puntero es una variable que guarda una dirección de memoria: «el dato está allí». Las referencias & son el puntero más sencillo de Rust: solo prestan un valor y no cuestan nada.

Los smart pointers (punteros inteligentes) son structs que se comportan como punteros pero tienen capacidades extra y, normalmente, son dueños de los datos a los que apuntan. Ya conoces dos sin saberlo: String y Vec<T>. En esta lección verás los tres más importantes: Box, Rc y RefCell.

Box: datos en el heap

Box<T> guarda un valor en el heap. En el stack solo queda el puntero, de tamaño fijo. Cuando la Box sale de su ámbito, se libera el puntero y el valor:

fn main() {
    let caja = Box::new(5);
    println!("caja = {caja}");

    let suma = *caja + 1;
    println!("suma = {suma}");
}

Guardar un número en el heap no tiene mucho sentido. Box se usa sobre todo en tres casos:

  • Tipos recursivos, cuyo tamaño no se puede conocer al compilar.
  • Datos grandes que quieres mover sin copiarlos: solo se mueve el puntero.
  • Trait objects: cuando te importa qué trait implementa un valor, no su tipo concreto.

Tipos recursivos

Una lista enlazada es un nodo con un valor y… otra lista. Escrito tal cual, no compila:

enum Lista {
    Nodo(i32, Lista),
    Vacia,
}

fn main() {
    let _lista = Lista::Vacia;
}

El compilador necesita saber cuánto ocupa una Lista, pero una Lista contiene una Lista, que contiene una Lista… El error lo dice claro: recursive type has infinite size. La solución es meter el resto de la lista en una Box, que siempre ocupa lo mismo:

#[derive(Debug)]
enum Lista {
    Nodo(i32, Box<Lista>),
    Vacia,
}

use Lista::{Nodo, Vacia};

fn sumar(lista: &Lista) -> i32 {
    match lista {
        Nodo(valor, resto) => valor + sumar(resto),
        Vacia => 0,
    }
}

fn main() {
    let lista = Nodo(1, Box::new(Nodo(2, Box::new(Nodo(3, Box::new(Vacia))))));
    println!("{lista:?}");
    println!("Suma: {}", sumar(&lista));
}

Trait objects

Un Vec solo guarda elementos de un mismo tipo. Si quieres una lista de «cosas que implementan Forma», guárdalas como Box<dyn Forma>:

trait Forma {
    fn area(&self) -> u32;
}

struct Cuadrado {
    lado: u32,
}

struct Rectangulo {
    ancho: u32,
    alto: u32,
}

impl Forma for Cuadrado {
    fn area(&self) -> u32 {
        self.lado * self.lado
    }
}

impl Forma for Rectangulo {
    fn area(&self) -> u32 {
        self.ancho * self.alto
    }
}

fn main() {
    let formas: Vec<Box<dyn Forma>> = vec![
        Box::new(Cuadrado { lado: 3 }),
        Box::new(Rectangulo { ancho: 2, alto: 5 }),
    ];

    for forma in &formas {
        println!("Área: {}", forma.area());
    }
}

dyn Forma significa «algún tipo que implementa Forma». Como cada tipo ocupa distinto, hace falta la Box para darles a todos el mismo tamaño. Así también puedes devolver closures distintas: Box<dyn Fn(i32) -> i32>.

Deref y Drop

Lo que convierte a un struct en smart pointer son dos traits:

  • Deref permite usar * para llegar al valor, como con una referencia. Además, Rust convierte automáticamente &Box<String> en &String y luego en &str cuando hace falta. Es la deref coercion, y por eso sumar(resto) funcionaba arriba aunque resto era una &Box<Lista>.
  • Drop define el código que se ejecuta cuando el valor se libera. Así una Box libera su memoria en el heap.

Puedes implementar Drop en tus tipos para ver cuándo ocurre:

struct Recurso {
    nombre: String,
}

impl Drop for Recurso {
    fn drop(&mut self) {
        println!("Liberando {}", self.nombre);
    }
}

fn main() {
    let _a = Recurso { nombre: String::from("A") };
    {
        let _b = Recurso { nombre: String::from("B") };
        println!("Fin del bloque interior");
    }
    let c = Recurso { nombre: String::from("C") };
    drop(c);
    println!("Fin de main");
}

No puedes llamar a c.drop() directamente. Para liberar algo antes de tiempo usa la función drop(c), que se lo queda y lo suelta.

Rc: varios dueños

Las reglas de ownership dicen que cada valor tiene un único dueño. A veces, sin embargo, un dato pertenece de verdad a varias partes del programa y no está claro cuál terminará la última. Para eso existe Rc<T> (reference counted): cuenta cuántos dueños tiene el valor y lo libera cuando el contador llega a cero.

use std::rc::Rc;

fn main() {
    let a = Rc::new(String::from("datos compartidos"));
    println!("Dueños: {}", Rc::strong_count(&a));

    let b = Rc::clone(&a);
    {
        let c = Rc::clone(&a);
        println!("Dueños: {}", Rc::strong_count(&a));
        println!("c = {c}");
    }
    println!("Dueños: {}", Rc::strong_count(&a));
    println!("a = {a}, b = {b}");
}

Rc::clone no copia el String: solo suma uno al contador y devuelve otro puntero al mismo dato. Se escribe Rc::clone(&a) en lugar de a.clone() por convención, para que al leer el código se vea que es barato.

Dos limitaciones importantes: Rc solo da acceso de lectura y solo funciona en un hilo. Para lo primero tienes RefCell; para lo segundo, Arc, que verás en la lección de concurrencia.

RefCell: mutabilidad interior

El borrow checker comprueba las reglas de préstamo al compilar. RefCell<T> aplica las mismas reglas, pero las comprueba al ejecutar. Eso permite modificar un valor aunque solo tengas una referencia inmutable al RefCell: es la mutabilidad interior.

use std::cell::RefCell;

fn main() {
    let notas = RefCell::new(vec![7, 8]);

    notas.borrow_mut().push(10);

    println!("Notas: {:?}", notas.borrow());
    println!("Cantidad: {}", notas.borrow().len());
}
  • borrow() devuelve un préstamo de lectura. Puede haber varios a la vez.
  • borrow_mut() devuelve un préstamo de escritura. Solo puede haber uno, y ninguno de lectura.

Si incumples las reglas, ya no es un error de compilación: el programa hace panic al ejecutarse.

use std::cell::RefCell;

fn main() {
    let valor = RefCell::new(5);
    let lectura = valor.borrow();
    let mut escritura = valor.borrow_mut(); // panic: ya hay un préstamo activo
    *escritura += 1;
    println!("{lectura}");
}
thread 'main' panicked at src/main.rs:6:31:
RefCell already borrowed

Consejo: Usa RefCell solo cuando el borrow checker no pueda demostrar algo que tú sabes que es correcto. Las comprobaciones al compilar son gratis y más seguras.

Rc y RefCell juntos

La combinación Rc<RefCell<T>> es muy habitual: Rc permite varios dueños y RefCell permite que cualquiera de ellos modifique el valor.

use std::cell::RefCell;
use std::rc::Rc;

fn main() {
    let saldo = Rc::new(RefCell::new(100));

    let cuenta_ana = Rc::clone(&saldo);
    let cuenta_luis = Rc::clone(&saldo);

    *cuenta_ana.borrow_mut() -= 30;
    *cuenta_luis.borrow_mut() += 50;

    println!("Saldo compartido: {}", saldo.borrow());
}

Como Rc<RefCell<Vec<String>>> es largo de escribir, puedes darle un nombre más corto con un alias de tipo: type Tareas = Rc<RefCell<Vec<String>>>;.

TipoDueños¿Se puede modificar?Reglas comprobadas
Box<T>UnoSí, si la variable es mutAl compilar
Rc<T>VariosNoAl compilar
RefCell<T>UnoSí, con borrow_mut()Al ejecutar
Rc<RefCell<T>>VariosSí, con borrow_mut()Al ejecutar

Cuidado: Si dos Rc se apuntan mutuamente, su contador nunca llega a cero y la memoria no se libera. Para esos casos existe Weak<T>, una referencia que no cuenta como dueña.

Hora de practicar

Ejercicios

0/3 resueltos

Reto 1 de 3

El enum Lista no compila porque es un tipo recursivo de tamaño infinito. Arréglalo con Box e implementa producto, que multiplica todos los valores de la lista. El producto de una lista Vacia es 1.
Salida esperada
Producto: 24

Al ejecutar, unos tests ocultos comprueban tus funciones.

src/main.rs
$ cargo run Listo

Pulsa Ejecutar para compilar y ver aquí la salida de tu programa.

¿Algo no cuadra en este reto? Avísanos