Lección 23 de 26 Rust funcional
Smart pointers: Box, Rc y RefCell
- Box::new(5) guarda el valor en el heap. En el stack queda solo un puntero, que es su único dueño.
- Rc permite varios dueños. Rc::clone(&a) no copia el dato: solo suma uno al contador de dueños.
- Cada vez que un dueño desaparece, el contador baja. Al llegar a 0, el valor se libera solo.
- RefCell comprueba los préstamos al ejecutar: pedir borrow_mut() con otro préstamo activo provoca un panic.
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:
Derefpermite usar*para llegar al valor, como con una referencia. Además, Rust convierte automáticamente&Box<String>en&Stringy luego en&strcuando hace falta. Es la deref coercion, y por esosumar(resto)funcionaba arriba aunquerestoera una&Box<Lista>.Dropdefine el código que se ejecuta cuando el valor se libera. Así unaBoxlibera 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 borrowedConsejo: Usa
RefCellsolo 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>>>;.
| Tipo | Dueños | ¿Se puede modificar? | Reglas comprobadas |
|---|---|---|---|
Box<T> | Uno | Sí, si la variable es mut | Al compilar |
Rc<T> | Varios | No | Al compilar |
RefCell<T> | Uno | Sí, con borrow_mut() | Al ejecutar |
Rc<RefCell<T>> | Varios | Sí, con borrow_mut() | Al ejecutar |
Cuidado: Si dos
Rcse apuntan mutuamente, su contador nunca llega a cero y la memoria no se libera. Para esos casos existeWeak<T>, una referencia que no cuenta como dueña.
Hora de practicar
Ejercicios
0/3 resueltos
Reto 1 de 3 Resuelto
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.Producto: 24
Al ejecutar, unos tests ocultos comprueban tus funciones.
Pulsa Ejecutar para compilar y ver aquí la salida de tu programa.