Ir al contenido
  1. Posts/

Ownership en Rust 1. Deja de temer a .clone() — lo que el borrow checker no te está diciendo

·7 mins
Rafael Fernandez
Autor
Rafael Fernandez
Matemáticas, programación y cosas de la vida
Ownership en Rust - Este artículo es parte de una serie.
Parte 1: Este artículo

La República no cayó porque tuviera clone troopers. Cayó porque no entendía para qué servían.

Lo mismo ocurre en los codebases de Rust. Los desarrolladores tratan .clone() como si cada llamada fuera la Orden 66, una catástrofe oculta esperando para dispararse. Pasan treinta minutos luchando con anotaciones de lifetime para evitar un clone que copia cincuenta bytes. Añaden Arc<Mutex<>> a un struct que contiene un solo PathBuf. Complican interfaces, propagan parámetros de lifetime a través de cinco capas de abstracción, y producen código más difícil de leer, más difícil de refactorizar y más difícil de razonar. Todo para evitar escribir cinco caracteres: .clone().

El borrow checker no te dice lo que cuesta un clone. Te dice cuándo necesitas uno. Son cosas muy diferentes.

Esta serie trata de cerrar esa brecha. No con ideología (“clonar está bien” o “clonar está mal”), sino con mecánica: qué hace .clone() a nivel de máquina para los tipos que realmente usas, y cómo tomar la decisión basándote en datos en lugar de en instinto.

Qué es realmente el trait Clone
#

El trait Clone tiene un único método requerido:

pub trait Clone {
    fn clone(&self) -> Self;
}

El contrato es simple: producir un nuevo valor independiente. Después de let b = a.clone(), modificar b no debe afectar a a. Esa es toda la promesa. El trait no dice nada sobre el coste, nada sobre la asignación de memoria, nada sobre qué ocurre internamente. Dice: “obtienes una copia, y es tuya.”

El mecanismo detrás de esa copia varía enormemente.

El espectro de costes: seis órdenes de magnitud
#

Esto es lo que .clone() realmente hace para los tipos que ves cada día:

Tipo Qué hace clone() internamente Coste aproximado
i32, f64, bool Copia bit a bit (igual que Copy) < 1 ns
String (32 bytes) Reserva un nuevo buffer en el heap, copia los bytes ~20-50 ns
PathBuf (ruta típica) Igual que String – envuelve un OsString ~20-50 ns
Vec<u8> (1 KB) Reserva un nuevo buffer, copia 1.024 bytes ~50-100 ns
Vec<u8> (1 MB) Reserva un nuevo buffer, copia 1.048.576 bytes ~100-500 us
HashMap<K,V> (100 entradas) Reserva una nueva tabla hash, clona cada clave y valor ~1-5 us
HashMap<K,V> (10.000 entradas) Lo mismo, pero diez mil pares ~50-500 us
Arc<T> Incrementa un contador de referencia atómico. No se copian datos. ~5-10 ns
Rc<T> Incrementa un contador de referencia no atómico. No se copian datos. ~2-5 ns
File, TcpStream, MutexGuard No compila. No hay implementación de Clone.

Lee esa tabla de nuevo. Los mismos cinco caracteres, .clone(), van desde “más rápido que una llamada a función” hasta “bloquea tu hilo durante medio milisegundo.” La sintaxis es idéntica. Las implicaciones no.

Una fábrica de clone troopers puede producir un soldado en minutos o en meses, dependiendo de la plantilla. Un ingeniero Kaminoan conoce la diferencia. Un programador de Rust también debería.

Clone vs Copy: dos conversaciones muy diferentes
#

Todo principiante en Rust se topa con esta pregunta pronto: si Clone duplica valores, ¿para qué sirve Copy?

Copy Clone
Cuándo se activa Implícitamente, en asignaciones y llamadas a funciones Explícitamente, solo cuando escribes .clone()
Qué hace el compilador memcpy bit a bit en el stack Llama al método clone(), que puede hacer cualquier cosa
Garantía de coste Siempre trivial. Siempre. Ninguna. Puede reservar memoria, puede hacer I/O, puede tardar milisegundos.
Quién puede implementarlo Solo tipos donde todos los campos son Copy y no hay Drop Cualquier tipo
Señal semántica “Duplicar esto es tan barato como copiar un registro” “Duplicar esto podría costar algo. Mira por dentro.”

Copy es una promesa de trivialidad. El compilador confía lo suficiente en ella como para hacerla a tus espaldas. Cuando escribes let y = x donde x: i32, el compilador copia cuatro bytes sin preguntar. No escribes .clone(). No piensas en ello. Simplemente funciona, porque el coste está garantizado como despreciable.

Clone es una capacidad sin promesa de coste. El tipo puede ser duplicado, pero el coste es tu responsabilidad evaluarlo.

Por qué String no puede ser Copy
#

Esta restricción no es arbitraria. Un String posee una asignación en el heap. Si String fuera Copy, cada let y = x duplicaría silenciosamente el uso de memoria:

let x = String::from("a]".repeat(1_000_000));  // 1 MB on the heap
let y = x;  // If this were Copy: silent 1 MB allocation. Every. Single. Time.

El lenguaje te obliga a escribir .clone() explícitamente para que la asignación de memoria sea visible. La ves en el código. Puedes buscarla con grep. Puedes perfilarla. Puedes preguntarte: “¿es necesario este clone, o puedo reestructurar para evitarlo?”

El mismo razonamiento aplica a Vec, PathBuf, HashMap y cualquier otro tipo que posea memoria en el heap. Si la duplicación tiene un coste, Rust te hace reconocerlo.

Por qué Arc no puede ser Copy
#

El clone de Arc<T> es barato (un incremento atómico). Entonces, ¿por qué no es Copy?

Porque Arc implementa Drop. Cuando un Arc sale del scope, decrementa el contador de referencia y potencialmente libera los datos compartidos. Los tipos Copy no pueden tener Drop; el compilador no puede garantizar una limpieza correcta si los valores se duplican silenciosamente por todas partes.

Así que Arc::clone(&x) es el compromiso: explícito en el código, trivial en coste. La convención de la comunidad es escribir Arc::clone(&x) en lugar de x.clone() específicamente para señalar: “esto es un incremento de refcount, no una copia profunda.” Ambos compilan de forma idéntica. La convención existe para los lectores humanos.

Qué genera derive(Clone)
#

Cuando escribes #[derive(Clone)], el compilador genera clonación campo por campo:

#[derive(Clone)]
struct Config {
    name: String,       // String::clone() → new heap allocation
    port: u16,          // u16 is Copy → bitwise copy, trivial
    tags: Vec<String>,  // Vec::clone() → new allocation + clone each String element
}

El clone() generado es conceptualmente:

impl Clone for Config {
    fn clone(&self) -> Self {
        Config {
            name: self.name.clone(),
            port: self.port,   // Copy, no .clone() needed
            tags: self.tags.clone(),
        }
    }
}

El coste de clonar Config es la suma de clonar sus campos. Si tags contiene 10.000 strings, el coste del clone está dominado por esas 10.000 asignaciones de strings. Si tags está vacío y name tiene 20 caracteres, el coste son dos pequeñas asignaciones de memoria.

El derive no te dice nada sobre el coste. Es un generador mecánico de código, no un analizador de costes. Tienes que mirar dentro del struct para saber lo que estás pagando.

Este es el insight crítico que el borrow checker no puede proporcionar. El compilador te dice “este valor ha sido movido, necesitas un clone si quieres volver a usarlo.” No te dice “este clone costará 50 nanosegundos” ni “este clone copiará 10 megabytes.” Ese conocimiento es tu responsabilidad.

Tipos que se niegan a clonarse
#

Algunos tipos intencionalmente no implementan Clone:

  • File: un handle de archivo abierto es un recurso del kernel. Duplicarlo requiere un dup() a nivel de sistema operativo, que no es una simple copia de memoria. Rust lo expone como file.try_clone(), devolviendo un Result porque el sistema operativo puede rechazarlo.
  • TcpStream: un socket de red. El mismo razonamiento que File.
  • MutexGuard: una prueba de que posees un lock. Duplicarlo significaría dos poseedores del mismo lock, violando la exclusión mutua.
  • &mut T: una referencia exclusiva. Duplicarla crearía dos referencias exclusivas a los mismos datos, violando la invariante fundamental de Rust.

Cuando un tipo no implementa Clone, el autor del tipo te está diciendo: este valor representa un recurso único que no puede ser duplicado de forma significativa. La ausencia de Clone es tan informativa como su presencia. Como un cristal de sable de luz: no se fabrican en masa cristales kyber. Cada uno se vincula a su portador.

Dónde nos deja esto
#

El borrow checker te dice cuándo necesitas duplicar un valor. El trait Clone te dice que la duplicación es posible. Ninguno te dice lo que cuesta.

Ese coste depende de una única pregunta: ¿qué hay dentro del struct?

  • ¿Un PathBuf? Cincuenta bytes. Clona y sigue adelante.
  • ¿Un Vec<u8> con un millón de elementos? Un megabyte. Piénsalo dos veces.
  • ¿Un Arc<HeavyData>? Ocho bytes de puntero más un incremento atómico. Barato, pero entiende por qué.
  • ¿Un File? No se puede clonar. Reestructura.

En el siguiente post, veremos las seis estrategias que Rust te ofrece para compartir estado cuando clonar no es la decisión correcta, o cuando lo es, junto con las alternativas contra las que compite. Cada estrategia es una forma de sable de luz: efectiva contra una amenaza específica, peligrosa cuando se aplica mal.

Referencia:

Ownership en Rust - Este artículo es parte de una serie.
Parte 1: Este artículo