wandres.dev
OWNERSHIP · move semantics

Qué es ownership: las tres reglas

El mecanismo con el que Rust garantiza la seguridad de memoria sin recolector de basura ni free manual: cada valor tiene un dueño, uno solo a la vez, y al salir de scope el valor se libera.

⏱ 15 min

Ownership es la idea de la que cuelga todo lo demás en Rust. No es una biblioteca ni una convención de estilo: es una disciplina que el compilador impone y demuestra, y que resuelve —en tiempo de compilación, con coste cero en ejecución— el problema que ha atormentado a los lenguajes de sistemas durante medio siglo: quién libera cada porción de memoria, y cuándo exactamente.

🎯 Al terminar esta lección sabrás
  • Enunciar las tres reglas de ownership y qué garantiza cada una.
  • Entender cómo el ownership sustituye al recolector de basura y al free manual.
  • Ver el ownership como un sistema de tipos afín verificado en compilación.
  • Leer en el código el momento exacto en que un valor nace y muere.

El problema que ownership resuelve

Durante décadas hubo solo dos respuestas a la pregunta “¿quién libera la memoria?”. La primera, la gestión manual de C: tú pides con malloc y tú devuelves con free. Máximo control, pero un campo minado de fugas, dobles liberaciones y use-after-free. La segunda, el recolector de basura de Java, Go o Python: una rutina en ejecución rastrea qué sigue vivo y libera el resto. Cómodo, pero con coste de CPU, pausas y un instante de liberación que tú no eliges.

🧨

Manual (C)

Tú escribes cada free. Coste cero en ejecución, pero un solo olvido es una fuga, un crash o una vulnerabilidad.

🗑️

GC (Java, Go)

Una máquina en ejecución libera por ti. Seguro, pero con pausas y sin control del cuándo.

🦀

Ownership (Rust)

El compilador decide y verifica quién libera y cuándo. Sin GC, sin free a mano, sin riesgo.

En Rust la pregunta “¿quién es el dueño de este dato?” se responde en el código y en los tipos, y el compilador la verifica antes de emitir el binario. El resultado: la liberación se inserta automáticamente en el punto correcto.

fn main() {
    let s = String::from("dueño"); // `s` posee un buffer en el heap
    saluda(&s);                    // se lo prestamos, sin cederlo
} // fin de scope: el buffer de `s` se libera aquí, exactamente una vez

fn saluda(nombre: &String) {
    println!("Hola, {nombre}");
}

Fíjate en el detalle clave: en ningún punto del código aparece un free, ni una anotación especial, ni una llamada a un recolector. La liberación está implícita en la estructura del programa y, aun así, es exacta y única.

ℹ️
Coste cero no es un eslogan de marketing

Que el análisis de ownership ocurra en compilación tiene una consecuencia medible: el binario de Rust no contiene ni un byte de recolector de basura, ni contadores de referencia ocultos, ni pausas de “stop the world”. Donde un lenguaje con GC gasta ciclos en ejecución rastreando qué sigue vivo, Rust ya lo dedujo y grabó los drop exactos en el código máquina. Pagas el precio una sola vez, al compilar, y nunca más en cada ejecución.

Las tres reglas

Toda la teoría del ownership cabe en tres frases que el compilador nunca deja de aplicar:

  1. Cada valor tiene un dueño: la variable que lo posee.
  2. Solo puede haber un dueño a la vez.
  3. Cuando el dueño sale de scope, el valor se libera (se ejecuta su drop).
// Regla 1: cada valor tiene un dueño.
let nombre = String::from("Rust"); // `nombre` es el dueño de este String

// Regla 2: solo un dueño a la vez.
let alias = nombre;                // la PROPIEDAD se mueve a `alias`
// a partir de aquí `nombre` ya no es válido: hay un único dueño

// Regla 3: al salir de scope, se libera.
{
    let temporal = String::from("efímero");
    println!("{temporal}");
} // `temporal` sale de scope: su memoria se libera aquí, automáticamente

Cada regla tapa un agujero clásico. La regla 1 impide memoria sin dueño (la fuga). La regla 2 impide dos dueños del mismo buffer (el doble free). La regla 3 impide el use-after-free y el olvido de liberar: el fin de scope es la liberación, y ocurre siempre.

⚠️
clone no es la respuesta por defecto

Cuando el compilador se queje de un valor movido, la tentación del principiante es sembrar .clone() por todas partes para acallarlo. A veces es lo correcto; muchas veces es tapar con una copia cara del heap un problema que se resuelve mejor prestando el valor (el borrowing del nivel 9). Un .clone() reflejo es deuda de rendimiento disfrazada de arreglo. Antes de clonar, pregúntate si de verdad necesitas dos dueños o solo querías mirar el dato.

El scope como frontera de vida

Un String vive en dos sitios a la vez. En el stack guarda tres palabras: un puntero al buffer, la longitud y la capacidad. En el heap vive el buffer real de bytes. El dueño es la variable del stack; la regla 3 dice que cuando esa variable muere, Rust libera el buffer del heap que cuelga de ella.

flowchart LR
s[Variable s en el stack] -->|apunta a| buf[Buffer de bytes en el heap]
s -->|sale de scope| free[Se libera el buffer]
fn main() {
    let externo = String::from("vivo todo main");
    {
        let interno = String::from("vivo solo el bloque");
        println!("{interno}");
    } // `interno` muere aquí: su buffer se libera
    println!("{externo}");
} // `externo` muere aquí, después: orden inverso a la creación

Observa el anidamiento: interno nace después que externo y muere antes, respetando el orden de una pila. Ese comportamiento LIFO no es casual, y reaparecerá con nombre propio cuando hablemos de Drop.

El punto de liberación es léxico y predecible: la llave de cierre del bloque donde vive la variable. No hay que adivinarlo ni esperar a un recolector. Esa determinación es lo que convierte al ownership en un sustituto real del GC: sabes el instante exacto de cada liberación con solo leer las llaves.

El árbol de propiedad

De la regla del dueño único sale una estructura elegante: la propiedad forma un árbol. Cada valor tiene exactamente un padre-dueño, y los valores que posee cuelgan de él. Un struct posee sus campos; un Vec<T> posee sus elementos; un Box<T> posee lo que envuelve. No hay ciclos por defecto —para eso hará falta Rc<T>, mucho más adelante—, así que el grafo de propiedad es un bosque de árboles limpios.

struct Nodo {
    nombre: String,
    hijo: Option<Box<Nodo>>,
}

fn main() {
    let raiz = Nodo {
        nombre: String::from("raíz"),
        hijo: Some(Box::new(Nodo {
            nombre: String::from("hoja"),
            hijo: None,
        })),
    };
    println!("{}", raiz.nombre);
} // al morir `raiz`: se libera su String, luego su hijo, y el String del hijo

Liberar raiz es entonces un recorrido en postorden de ese árbol: primero se destruye la raíz, lo que dispara la destrucción de sus campos, que a su vez destruyen los suyos, hasta las hojas. Todo automático, todo determinista, todo demostrado en compilación. La gestión de memoria manual reaparece aquí convertida en una travesía de árbol que tú nunca tienes que programar.

Ownership es un sistema de tipos afín

Bajo la superficie, el ownership es un sistema de tipos afín: cada valor puede usarse como mucho una vez por su dueño. “Como mucho” —y no “exactamente una”— porque un valor puede no usarse nunca y aun así liberarse limpiamente al final del scope. Cuando mueves un valor (regla 2), el compilador marca el origen como no inicializado mediante un análisis de flujo de datos, y prohíbe volver a leerlo. No hay contador de referencias ni rastreo en ejecución: es álgebra sobre el grafo del programa, resuelta antes de compilar.

Que el sistema sea afín, y no lineal, tiene una consecuencia práctica: el origen invalidado no queda muerto para siempre. Puedes reasignarle un valor nuevo y vuelve a la vida como dueño de algo distinto. La invalidación recae sobre el valor movido, no sobre el nombre de la variable.

let mut s = String::from("primero");
let t = s;                    // `s` queda sin valor (movido a `t`)
s = String::from("segundo");  // `s` vuelve a ser dueña, de otro buffer
println!("{s} y {t}");        // ambos válidos: dueños de buffers distintos

De ahí el eslogan de “coste cero”: las llamadas a drop que liberan la memoria las escribe el compilador por ti, en los puntos que su análisis demuestra correctos. El binario resultante no lleva runtime de GC; lleva exactamente los free que tú habrías escrito a mano si fueras infalible.

Esta es también la razón por la que el ownership no tiene coste conceptual oculto: no hay un “recolector” mental que debas modelar en ejecución. El programa se razona igual que se lee una demostración —cada valor entra en scope, se usa a lo sumo una vez como dueño, y sale—. Si sabes leer llaves, sabes leer vidas.

El compilador no aplica una regla: construye una demostración

La diferencia entre ownership y un linter es abismal. Un linter avisa de patrones sospechosos; el borrow checker demuestra un teorema sobre tu programa: que ninguna memoria se usa tras liberarse, que nada se libera dos veces, que nada queda sin liberar por la vía normal. Las tres reglas no son heurísticas: son los axiomas de esa demostración. Cuando el compilador acepta tu código, no dice “parece bien”; dice “he probado que es seguro respecto a memoria”. Por eso Rust puede quitar el recolector de basura sin volver a la ruleta rusa de C: no confía en tu disciplina, la sustituye por una prueba mecánica. Interioriza que cada let declara un dueño y cada } es una liberación demostrada, y el resto de Rust —move, borrowing, lifetimes— dejará de parecer arbitrario: son las consecuencias lógicas de estos tres axiomas.

⚔️ Lee la vida de cada valor
  1. Escribe una función con tres String en scopes anidados y predice, antes de compilar, en qué orden se liberan.
  2. Añade let alias = nombre; y luego intenta usar nombre: lee el error E0382 y localiza qué regla violaste.
  3. Explica en una frase por qué la regla 2 hace imposible el doble free.
  4. Sustituye un String por un i32 y observa que ahí la regla 2 no invalida el original: anota la pregunta, la resolverá la lección de Copy vs Move.
  5. Dibuja el árbol de propiedad de un struct con dos campos String y marca en qué orden se liberan sus nodos.