Posts

Rust frente a Swift: dos modelos para gestionar memoria sin recolector de basura

Arturo Rivas Arias

Rust y Swift comparten una promesa poco habitual: ofrecer seguridad de memoria sin depender de un recolector de basura. Ambos dos liberan recursos de forma determinista, permiten construir abstracciones de alto nivel y evitan gran parte de los errores que sufriríamos al gestionar punteros manualmente. Sin embargo, llegan a ese resultado desde puntos de partida muy distintos.

Rust hace que la propiedad de cada valor forme parte central del lenguaje. El compilador comprueba quién posee un recurso, durante cuánto tiempo puede prestarse y en qué momento deja de ser utilizable. Swift, en cambio, combina semántica de valor para struct y enum, conteo automático de referencias para las clases y un sistema de ownership que normalmente permanece oculto tras copias implícitas.

Por eso reducir la comparación a «Rust usa ownership y Swift usa ARC» queda incompleto. Swift también tiene ownership, préstamo y consumo de valores; Rust también dispone de punteros con conteo de referencias. La diferencia decisiva está en qué comportamiento adopta cada lenguaje por defecto y cuánta información exige expresar en el código.

La memoria no se reduce a pila frente a heap

Antes de comparar ambos lenguajes conviene separar tres cuestiones que suelen mezclarse:

  • Dónde se almacena un dato: en la pila, en el heap, dentro de otro valor o incluso ni siquiera se guarda debido a la optimización del compilador.
  • Quién posee el dato: qué variable u objeto es responsable de mantenerlo vivo.
  • Cómo termina su ciclo de vida: al abandonar un ámbito, al desaparecer la última referencia o mediante una operación explícita.

Ni Rust garantiza que cada struct esté en la pila ni Swift coloca automáticamente todas las clases en el heap bajo cualquier circunstancia. Esas son decisiones de representación que puede tomar el compilador siempre que preserve la semántica observable. El modelo de memoria describe principalmente la validez, la propiedad y el acceso a los valores, no un mapa fijo de direcciones físicas.

Rust: un propietario y préstamos temporales

Las reglas de ownership de Rust parten de una idea sencilla: cada valor tiene un propietario y, cuando ese propietario abandona su ámbito, el valor se destruye. Asignar o pasar a una función un tipo que no implementa Copy transfiere normalmente la propiedad. Esta operación se denomina move y no necesita copiar el contenido ni actualizar un contador de referencias.

struct Catalog {
    products: Vec<String>,
}

fn count_products(catalog: &Catalog) -> usize {
    catalog.products.len()
}

fn main() {
    let catalog = Catalog {
        products: vec!["Cámara".into(), "Trípode".into()],
    };

    let count = count_products(&catalog); // Préstamo inmutable
    let archived = catalog;               // Transferencia de propiedad

    println!("{count}");
    println!("{}", archived.products.len());
    // catalog ya no puede utilizarse aquí.
}

count_products no necesita quedarse con el catálogo, así que recibe &Catalog: una referencia prestada. El borrow checker comprueba que esa referencia no sobreviva al valor y que los accesos simultáneos sean compatibles. Puede haber varios préstamos inmutables activos o un préstamo mutable exclusivo, pero no ambas cosas a la vez.

Esa regla evita en código seguro que las referencias queden colgando, el uso posterior a la liberación y las invalidaciones provocadas por una mutación mientras existe una referencia al interior de una colección. Los lifetimes permiten describir relaciones entre el ciclo de vida de varias referencias cuando el compilador no puede inferirlas por sí solo. Son una prueba estática: no constituyen objetos ni realizan trabajo adicional durante la ejecución.

Cuando el propietario sale de su ámbito, Rust ejecuta el destructor del valor. Un tipo puede implementar Drop para cerrar un archivo, liberar una reserva o finalizar cualquier otro recurso, y después se destruyen también sus campos. Gracias a la técnica de RAII, tanto la memoria como otros recursos quedan ligados al ciclo de vida del valor que los posee y se liberan automáticamente cuando este se destruye..

Los tipos que implementan Copy, como muchos escalares, son la excepción de uso cotidiano. Una asignación produce una copia implícita y la variable original continúa siendo válida. Rust no prohíbe copiar: obliga a que los tipos con recursos o destrucción relevante no se dupliquen accidentalmente.

Swift: valores independientes y referencias compartidas

Swift presenta dos semánticas principales. Las estructuras y enumeraciones son tipos de valor: asignarlas, pasarlas o devolverlas produce conceptualmente un valor independiente. Las clases son tipos de referencia: varias variables pueden apuntar a la misma instancia.

struct Catalog {
    var products: [String]
}

var current = Catalog(products: ["Cámara", "Trípode"])
var archived = current

archived.products.append("Objetivo")

print(current.products.count)  // 2
print(archived.products.count) // 3

La asignación garantiza que current y archived puedan evolucionar de manera independiente, pero no obliga a duplicar inmediatamente cada byte. Colecciones como Array, Dictionary y String utilizan internamente copy-on-write: las dos copias pueden compartir un búfer mientras nadie lo modifique y crear almacenamiento independiente cuando una mutación lo requiera. El compilador también puede eliminar copias que no cambien el resultado observable.

En una clase no se copia el objeto al asignar otra variable. Se copia la referencia y ARC mantiene un recuento de referencias fuertes. Cuando ese recuento llega a cero, Swift destruye la instancia y ejecuta su deinit.

final class ImageStore {
    let name: String

    init(name: String) {
        self.name = name
    }

    deinit {
        print("Liberando \(name)")
    }
}

var primary: ImageStore? = ImageStore(name: "Catálogo")
var secondary = primary

primary = nil    // La instancia continúa viva
secondary = nil  // El contador llega a cero y se ejecuta deinit

ARC no es un recolector que recorra periódicamente un grafo de objetos. El compilador inserta las operaciones necesarias para conservar y liberar referencias, y el runtime actualiza sus contadores. Esto ofrece destrucción determinista, pero cada copia de una referencia fuerte puede implicar trabajo adicional y dos objetos que se retienen mutuamente pueden formar un ciclo que ARC no puede romper por sí solo.

Swift resuelve esas relaciones con referencias weak o unowned. Una referencia débil no conserva la instancia y pasa a nil cuando esta desaparece. Una referencia unowned tampoco la conserva, pero expresa que debería seguir existiendo cuando se accede a ella; incumplir esa expectativa provoca un fallo del programa. Elegir correctamente la referencia fuerte y la no propietaria sigue siendo responsabilidad de quien diseña el grafo de objetos.

Compartir una referencia es explícito en Rust

El ownership único de Rust no sirve directamente cuando varias partes deben poseer el mismo valor. Para eso existe Rc<T>, que introduce conteo de referencias de forma explícita para un solo hilo. Arc<T> ofrece la variante atómica adecuada para compartir propiedad entre hilos.

La comparación correcta no es una clase Swift frente a cualquier valor Rust. Una clase Swift se parece más a un Rc<T> o Arc<T> en cuanto a propiedad compartida, aunque Swift incremente y reduzca referencias fuertes de forma implícita. Un Box<T> de Rust, por el contrario, reserva en el heap pero mantiene un único propietario.

Rc<T> también puede formar ciclos y filtrar memoria. Rust proporciona Weak<T> para representar relaciones que no poseen el objeto, de modo parecido a weak en Swift. El lenguaje evita muchos errores de memoria, pero escoger una estructura de propiedad cíclica sigue teniendo consecuencias en ambos modelos.

Exclusividad no significa lo mismo que ARC

ARC responde a «¿cuándo puede destruirse esta instancia?», pero no a «¿quién puede mutarla ahora?». Dos referencias fuertes Swift pueden apuntar a la misma clase mutable, y el conteo de referencias no impide que dos tareas intenten modificarla simultáneamente.

Swift sí aplica acceso exclusivo a memoria para operaciones como inout y métodos mutating. Algunas infracciones se detectan durante la compilación y otras necesitan una comprobación dinámica. Este mecanismo protege accesos solapados concretos, pero no convierte automáticamente cualquier objeto compartido en seguro frente a concurrencia.

Rust lleva la exclusividad mucho más lejos en su modelo ordinario de referencias: un &mut T exige acceso exclusivo durante el préstamo. Si se necesita mutabilidad compartida, hay que introducir deliberadamente una abstracción como Mutex, RwLock, Cell o RefCell, cada una con sus reglas estáticas o dinámicas.

En Swift, Sendable, los actores y el aislamiento global completan esta parte del modelo para el código concurrente. Son garantías distintas de ARC: describen qué valores pueden cruzar dominios de concurrencia y dónde puede accederse al estado mutable.

Los tipos no copiables acercan Swift a Rust

El Swift moderno permite declarar estructuras y enumeraciones no copiables mediante ~Copyable. En estos tipos, borrowing concede acceso temporal, consuming transfiere la propiedad e inout permite una mutación exclusiva. Ya no se puede insertar una copia implícita para ocultar una transferencia.

struct ExportLease: ~Copyable {
    let identifier: Int

    borrowing func inspect() {
        print("Exportación \(identifier)")
    }

    consuming func commit() {
        print("Confirmando \(identifier)")
    }
}

func prepare(_ lease: borrowing ExportLease) {
    lease.inspect()
}

let lease = ExportLease(identifier: 42)
prepare(lease)
lease.commit()
// lease.inspect() produciría un error: lease ya se ha consumido.

Esta familia de características, explicada en la propuesta de tipos no copiables, permite modelar descriptores de archivo, bloqueos, tokens y otros recursos que deben tener un propietario único. También mejora la previsibilidad cuando una copia sería demasiado costosa.

El parecido con Rust es real, pero no convierte ambos lenguajes en equivalentes. En Rust, mover un tipo que no implementa Copy es el comportamiento normal. En Swift, la mayor parte de los tipos continúa siendo Copyable; el compilador puede introducir copias para conservar su semántica de valor y las APIs recurren a ~Copyable cuando necesitan restringir expresamente esa capacidad.

El mapa mental quedaría aproximadamente de la siguiente manera:

IntenciónRustSwift
Propiedad única de un valorComportamiento predeterminado para tipos no CopyTipo de valor ~Copyable
Lectura temporal&Tborrowing
Mutación temporal exclusiva&mut Tinout o método mutating
Transferencia definitivaMoveconsuming o consume
Propiedad compartida con contadorRc<T> / Arc<T>Instancia de clase gestionada por ARC
Referencia no propietariaWeak<T>weak / unowned
Limpieza deterministaDropdeinit

Qué coste paga cada modelo

Rust desplaza buena parte del coste al diseño y a la compilación. El programa debe demostrar que cada préstamo y transferencia es válido. A cambio, el ownership básico no necesita un contador de referencias en ejecución y hace muy visibles decisiones como compartir, bloquear o reservar en el heap. El precio es una curva de aprendizaje mayor y la necesidad ocasional de reorganizar el código para expresar una relación que el compilador pueda probar.

Swift prioriza que el código habitual sea flexible y fácil de comprender, especialmente al trabajar con los frameworks de Apple y sus grafos de objetos. Las copias de valores, ARC y copy-on-write eliminan mucha anotación, pero pueden ocultar operaciones de conservar, liberar, comprobar unicidad o copiar un buffer. Las herramientas de ownership permiten recuperar el control cuando un recurso o una ruta crítica lo necesita sin imponer ese nivel de detalle a toda la aplicación.

Ninguno de los dos lenguajes elimina por completo las fugas de memoria, las carreras de datos ni el acceso inseguro. Rust puede filtrar ciclos de Rc, bloquearse por un uso incorrecto de sincronización o perder garantías dentro de unsafe. Swift puede mantener ciclos fuertes, compartir clases mutables sin aislamiento o cometer errores al trabajar con punteros inseguros. La seguridad depende tanto de las garantías del lenguaje como de permanecer dentro de las abstracciones que pueden verificarlas.

Dos valores predeterminados distintos

Rust pregunta primero quién posee este valor y quién puede acceder a él ahora. Swift pregunta primero si este tipo representa un valor independiente o una identidad compartida y gestiona después las copias y referencias necesarias. Ambos modelos pueden expresar propiedad única, préstamos, acceso exclusivo y conteo de referencias, pero colocan esas herramientas en lugares diferentes de la experiencia cotidiana.

Para una aplicación Swift convencional, los tipos de valor y ARC ofrecen un equilibrio muy productivo. Para recursos únicos, interoperabilidad de bajo nivel o rendimiento especialmente sensible, ~Copyable, borrowing y consuming permiten hacer explícito lo que antes dependía más del optimizador. Rust comienza precisamente en ese extremo: exige razonar sobre la propiedad desde el principio y solo añade propiedad compartida cuando el diseño la solicita.

La lección más útil no es decidir qué lenguaje «gestiona mejor» la memoria, sino reconocer sus valores predeterminados. En Rust, compartir es una decisión visible. En Swift, copiar valores y compartir identidades es deliberadamente sencillo. Entender esa diferencia permite anticipar tanto el rendimiento como los errores que cada compilador puede evitar antes de ejecutar el programa.