Stack vs Heap y punteros inteligentes en Rust — Capítulo 15

Gestión de Memoria Dinámica en Rust


En el contexto de un libro que explora los fundamentos y las características avanzadas del lenguaje Rust, este capítulo aborda la distinción entre el stack y el heap, así como los punteros inteligentes básicos que facilitan la gestión de memoria dinámica. Esta comprensión resulta esencial para escribir código eficiente y seguro, ya que Rust impone reglas estrictas de ownership y borrowing que interactúan directamente con estas estructuras de memoria. Al dominar estos conceptos, se evitan errores comunes en la asignación y liberación de recursos, promoviendo programas más robustos y predecibles.

El Stack y el Heap en Rust

La memoria en Rust se organiza principalmente en dos regiones: el stack y el heap. El stack opera como una pila de marcos (frames) que se asignan y liberan de manera estricta y predecible, siguiendo un orden LIFO (Last In, First Out). Cada función en ejecución ocupa un marco en el stack, donde se almacenan variables locales de tamaño conocido en tiempo de compilación, como enteros, flotantes o estructuras fijas. Esta región es rápida y eficiente porque las operaciones de asignación y liberación son meras manipulaciones del puntero de stack, sin necesidad de fragmentación o recolección de basura.

Por el contrario, el heap permite asignaciones dinámicas para datos cuyo tamaño no se conoce en tiempo de compilación, como vectores de longitud variable o estructuras recursivas. La asignación en el heap implica solicitar memoria al sistema operativo, lo que introduce overhead y potencial fragmentación. Rust no cuenta con un recolector de basura automático como en lenguajes como Java o Go; en su lugar, el compilador enforces la liberación de memoria a través del sistema de ownership, asegurando que cada valor en el heap sea liberado exactamente cuando su owner sale del scope.

Una distinción clave radica en la predictibilidad: el stack es ideal para datos de corta duración y tamaño fijo, mientras que el heap soporta estructuras más complejas pero requiere manejo explícito para evitar leaks o accesos inválidos. Por ejemplo, un tipo como Vec<T> usa el heap internamente para almacenar sus elementos, pero su owner en el stack gestiona la liberación automática al final del scope. En casos borde, como recursión profunda, el stack puede desbordarse (stack overflow), lo que Rust previene en parte mediante chequeos en tiempo de compilación para tipos de tamaño ilimitado, aunque no siempre es posible detectarlo estáticamente.

Comparado con C++, donde el programador debe manejar manualmente new y delete en el heap, Rust integra estas operaciones en su modelo de ownership, eliminando la necesidad de liberación explícita y reduciendo errores como dangling pointers. Sin embargo, para tipos que requieren heap sin la complejidad de colecciones como Vec, Rust proporciona punteros inteligentes que encapsulan esta lógica.

Box: Asignación en el Heap

Box<T> representa el puntero inteligente más simple en Rust para asignar datos en el heap. Se trata de un tipo que envuelve un valor de tipo T en el heap, proporcionando ownership exclusivo sobre él. La sintaxis para crear una Box es directa: Box::new(valor), lo que asigna memoria en el heap y mueve el valor allí. Al igual que cualquier owner, cuando la Box sale del scope, libera automáticamente la memoria asociada, invocando el destructor de T si aplica.

Este puntero es particularmente útil para tipos recursivos, donde el tamaño no puede determinarse en tiempo de compilación. Considérese una estructura como un árbol binario:

enum BinaryTree<T> {
    Empty,
    Node(Box<BinaryTree<T>>, T, Box<BinaryTree<T>>),
}

Aquí, las Box permiten que cada nodo apunte a subárboles en el heap, evitando tamaños infinitos en el stack. Regla formalBox<T> implementa Deref y DerefMut, permitiendo dereferenciarla como un puntero con *box_value, lo que facilita el acceso al valor subyacente sin copias innecesarias.

En términos de borrowing, Box<T> se comporta como un owner estándar: se puede prestar de manera inmutable o mutable, pero no permite múltiples owners. Caso borde: Si T implementa Drop, la liberación ocurre en orden inverso al de creación, asegurando que dependencias se limpien correctamente. Comparado con std::unique_ptr en C++, Box ofrece garantías similares de ownership único, pero con chequeos en tiempo de compilación que previenen usos después de move.

No obstante, Box no soporta sharing; para escenarios donde múltiples referencias al mismo dato son necesarias, se requieren alternativas como las descritas en secciones posteriores.

Rc: Conteo de Referencias Compartidas

Rc<T> (Reference Counted) extiende la gestión de memoria al permitir múltiples owners de un mismo valor en el heap, mediante un contador de referencias compartido. Este tipo es adecuado para entornos single-threaded, donde se necesita sharing inmutable sin copiar datos costosos. La creación se realiza con Rc::new(valor), que asigna el valor en el heap y inicializa el contador en 1. Cada clonación con Rc::clone(&rc) incrementa el contador, y al salir del scope, cada Rc decrementa el contador; cuando llega a 0, se libera la memoria.

La semántica de Rc es estrictamente inmutable: no permite modificación del valor envuelto, alineándose con las reglas de borrowing de Rust para evitar data races. Para acceder al valor, se usa dereferenciación: *rc_value, gracias a la implementación de Deref. Un ejemplo ilustrativo:

use std::rc::Rc;

let data = Rc::new(5);
let data_clone = Rc::clone(&data);
assert_eq!(*data, *data_clone);

Regla formal: El contador es atómico en el sentido de que es thread-safe para conteo, pero Rc no es Sync ni Send, restringiéndolo a un solo hilo. Caso borde: Ciclos de referencias (como dos Rc apuntando mutuamente) previenen la liberación, causando memory leaks; Rust no detecta esto automáticamente, requiriendo diseño cuidadoso o uso de Weak para romper ciclos.

En comparación con std::shared_ptr en C++, Rc ofrece conteo similar pero con overhead reducido al no ser atómico, y sus garantías de inmutabilidad evitan errores sutiles. Es ideal para grafos o estructuras de datos donde el sharing es frecuente pero la mutabilidad no es requerida.

Arc: Conteo de Referencias Atómico

Arc<T> (Atomic Reference Counted) es la variante thread-safe de Rc<T>, diseñada para entornos multi-threaded donde múltiples hilos necesitan compartir ownership de un valor en el heap. Similar a Rc, usa un contador de referencias, pero este es atómico para garantizar operaciones seguras concurrentes. La creación sigue Arc::new(valor), y la clonación con Arc::clone(&arc) incrementa el contador atómicamente.

Al igual que RcArc enforces inmutabilidad, implementando Deref para acceso al valor. Es Send y Sync, permitiendo su paso entre hilos. Un fragmento de código mínimo:

use std::sync::Arc;
use std::thread;

let data = Arc::new(10);
let data_clone = Arc::clone(&data);
thread::spawn(move || {
    assert_eq!(*data_clone, 10);
}).join().unwrap();

Regla formal: Las operaciones de incremento y decremento usan atomicidad (como fetch_add y fetch_sub), asegurando que no haya races en el conteo. Caso borde: En escenarios de alta contención, el overhead atómico puede impactar el rendimiento; además, ciclos de referencias causan leaks permanentes, similar a Rc, y requieren Weak para mitigación.

Frente a std::shared_ptr en C++, que es atómico por defecto, Arc ofrece una distinción explícita con Rc para optimizar en contextos single-threaded. Su uso es crítico en programación concurrente, como en servidores o procesamiento paralelo, donde el sharing seguro es paramount.

Cuándo Usar Cada Uno

La elección entre Box<T>Rc<T> y Arc<T> depende del patrón de ownership y el contexto de threading. Box<T> se emplea cuando se requiere ownership único y asignación en heap, como en tipos recursivos o para trait objects donde el tamaño dinámico es necesario (por ejemplo, Box<dyn Trait>). Es la opción más ligera, con mínimo overhead, ideal para escenarios donde no se necesita sharing.

Rc<T> es apropiado para sharing inmutable en un solo hilo, como en estructuras de datos complejas donde múltiples partes del programa referencian el mismo dato sin copias. Se prefiere sobre Box cuando el ownership múltiple reduce duplicación, pero se debe evitar en código multi-threaded para prevenir errores de compilación.

Finalmente, Arc<T> se reserva para sharing inmutable concurrente, esencial en aplicaciones multi-hilo como web servers o computación paralela. Su overhead atómico lo hace más costoso que Rc, por lo que solo se justifica cuando el threading es inevitable. En todos los casos, se prioriza el principio de least privilege: usar el puntero más restrictivo que satisfaga los requisitos para maximizar la seguridad y eficiencia.

Estos punteros inteligentes forman la base para patrones más avanzados en la gestión de mutabilidad y concurrencia, que se explorarán en capítulos subsiguientes al introducir conceptos como la mutabilidad interior y el borrowing dinámico.

Dejar un comentario

Tu dirección de correo electrónico no será publicada. Los campos obligatorios están marcados con *

Scroll al inicio