La Orden Jedi tenía un problema de dogma. Sus reglas fueron diseñadas para prevenir el lado oscuro, y durante siglos funcionaron. Pero las reglas se calcificaron en absolutos. “El apego lleva al sufrimiento” se convirtió en “nunca formes vínculos.” “La disciplina previene el lado oscuro” se convirtió en “suprime toda emoción.” Cuando la galaxia necesitó pragmatismo, los Jedi solo pudieron ofrecer doctrina.
La comunidad de Rust tiene su propia versión de esto: la culpa del clone.
Los posts anteriores cubrieron la mecánica: qué hace clone, cuánto cuesta y las seis estrategias de ownership para compartir estado. Este post cierra la serie con la pregunta que nadie benchmarkea: ¿el miedo al clone está haciendo más daño que el clone en sí?
La anatomía de la culpa del clone #
Funciona así:
- Estás luchando con el borrow checker.
- Los lifetimes se propagan a través de tres capas de structs.
- Recurres a
.clone(). - Compila.
- Te sientes culpable. “Debería haber resuelto los lifetimes.” “Un Rustacean de verdad no necesitaría esto.” “Se supone que el borrow checker previene copias, y yo acabo de saltármelo.”
- Pasas 30 minutos refactorizando para eliminar el clone.
- El código es más difícil de leer, los structs tienen parámetros de lifetime, y cada API que los toca es más restrictiva.
- Ahorraste 50 nanosegundos por llamada.
Esto no es una exageración. Ocurre en code reviews, en charlas de conferencias, en hilos de Reddit. Alguien publica código funcional con un .clone(). El primer comentario: “puedes evitar ese clone reestructurando para usar referencias.” La reestructuración se propone sin medir si el clone importa.
La culpa viene de un lugar real. La cultura de abstracciones de coste cero de Rust te enseña que cada asignación de memoria es un fallo. El borrow checker existe para evitar copias innecesarias. El compilador prácticamente grita cuando intentas usar un valor movido. Clone se siente como evadir el sistema de seguridad.
Pero hay una diferencia entre respetar un principio y convertirlo en dogma.
Los números que nadie benchmarkea #
Operación Coste aproximado
────────────────────────────────────────────────────
Clone de un i32 < 1 ns
Clone de un PathBuf (50 bytes) ~50 ns
Clone de un struct Config pequeño ~100 ns
Clone de un Vec<u8> (1 KB) ~100 ns
Arc::clone() ~5-10 ns
────────────────────────────────────────────────────
Una syscall (read/write) ~1-10 μs
Un cambio de contexto ~1-10 μs
Clone de un Vec<u8> (1 MB) ~100-500 μs
────────────────────────────────────────────────────
Una petición HTTP (local) ~1-10 ms
Una consulta a base de datos ~1-50 ms
Lectura de 1 MB desde disco ~1-10 ms
Una petición HTTP (remota) ~10-100 ms
────────────────────────────────────────────────────Un clone de PathBuf cuesta ~50 nanosegundos. Una consulta a base de datos cuesta ~1-50 milisegundos. La proporción es de 20.000x a 1.000.000x. Pasar 30 minutos eliminando un clone de 50 ns en código que hace una consulta a base de datos es optimizar ruido. Obtendrías mejor rendimiento optimizando la propia consulta, agrupando operaciones de I/O o cacheando resultados.
Cuando alguien te diga “evita ese clone” sin datos de profiling, pregunta: “¿cuál es el hot path aquí, y cómo se compara este clone con el I/O?”
Si el clone está dentro de un bucle cerrado procesando millones de elementos sin I/O, importa. Mídelo, optimízalo, elimínalo.
Si el clone está en un camino de inicialización, un handler de peticiones que llama a una base de datos, o un bucle de eventos de TUI que redibuja a 60 fps, es invisible en cualquier perfil. La culpa está desperdiciada.
El lint de clippy: herramienta correcta, doctrina equivocada #
Clippy tiene un lint llamado redundant_clone:
let s = String::from("hello");
let t = s.clone(); // warning: redundant clone — `s` is not used after this
drop(t);Este lint es correcto y útil. Si el valor original no se usa después del clone, un move es estrictamente mejor: coste cero, mismo resultado. El lint detecta un desperdicio genuino.
El problema es lo que el lint refuerza culturalmente. Entrena a los desarrolladores de Rust a asociar “clone” con “advertencia.” Cada subrayado amarillo bajo .clone() refuerza el instinto de que clonar está mal. El lint dice “este clone específico es redundante.” El desarrollador escucha “clonar es malo.”
Clippy no tiene un lint para “pasaste 30 minutos añadiendo parámetros de lifetime para evitar un clone de 50 ns.” No advierte sobre struct Processor<'a, 'b> cuando un simple struct Processor { config: Config } habría sido más claro. La herramienta es asimétrica: señala clones innecesarios pero no complejidad innecesaria.
Usa el lint. Elimina clones redundantes. Pero no dejes que se convierta en tu teoría general del ownership.
Cuándo clone supera a los lifetimes #
Hay una clase de código donde evitar el clone empeora todo:
// Without clone: three layers of lifetime parameters
struct Processor<'a> {
config: &'a Config,
name: &'a str,
}
struct Server<'a> {
processor: Processor<'a>,
}
struct Application<'a> {
server: Server<'a>,
}
// Every function that creates an Application needs the Config to outlive it:
fn run<'a>(config: &'a Config) -> Application<'a> { /* ... */ }Ahora intenta lanzar Application en un hilo o una tarea async:
tokio::spawn(async move {
application.run().await; // ERROR: Application borrows Config, requires 'static
});El lifetime te bloquea. Las opciones para arreglarlo:
- Reestructurar el ownership para que
ApplicationposeaConfigpor valor. - Usar
Arc<Config>y pasarlo a través de cada capa. - Clonar la config y dejar que
Applicationposea su copia.
La opción 1 es ideal si estás diseñando la API desde cero. La opción 2 tiene sentido si Config es costoso. La opción 3 es correcta cuando Config tiene 200 bytes y el coste de ingeniería de las opciones 1 o 2 supera el valor que aportan.
La Orden Jedi diría “nunca clones.” Qui-Gon Jinn diría “clona la config, entrega la funcionalidad, y revisa si alguna vez aparece en un perfil.”
La teoría formal: por qué Rust hace visible la duplicación #
Hay una razón matemática por la que Rust trata clone como lo hace, y entenderla transforma clone de una concesión en una herramienta de diseño deliberada.
Tipos afines: usar como máximo una vez #
El modelo de ownership de Rust es un sistema de tipos afines. En teoría de tipos, los tipos afines imponen una regla simple: cada valor se puede usar como máximo una vez. Puedes consumirlo (move) o descartarlo (drop), pero no puedes usarlo dos veces de forma silenciosa.
Esto es distinto de los tipos lineales (usar exactamente una vez; Rust permite descartar valores no usados, por lo que es afín, no lineal) y de los tipos sin restricciones (usar tantas veces como quieras; esto es todo lenguaje con recolector de basura).
Las tres reglas estructurales #
En la lógica lineal (el marco matemático subyacente a los tipos lineales), el uso libre de valores de la lógica clásica se rige por tres reglas estructurales:
| Regla | Qué permite | Estado en Rust |
|---|---|---|
| Debilitamiento (weakening) | Descartar un recurso sin usarlo | Permitido (los valores se pueden descartar) |
| Contracción (contraction) | Duplicar un recurso | Prohibido por defecto |
| Intercambio (exchange) | Reordenar recursos | Permitido |
La regla crítica es la contracción (contraction): la capacidad de usar un recurso más de una vez. En Java, Python, Go, cada asignación de valor es una contracción implícita: el runtime comparte o copia referencias silenciosamente, y nunca piensas en ello.
Rust prohíbe la contracción por defecto. Cuando escribes let y = x, el valor se mueve. x desaparece. Esta es la disciplina afín: como máximo una vez.
Clone como la reintroducción explícita de la contracción #
Clone no es un parche. Es la reintroducción controlada de la contracción (contraction) en un sistema de tipos afines.
Cuando un tipo implementa Clone, el autor del tipo declara: “este valor se puede duplicar de forma significativa.” Cuando escribes .clone(), estás ejerciendo esa declaración en un punto específico del código.
La palabra clave es explícito. En Java:
Config config2 = config; // Shares a reference. Silent. Invisible.En Go:
config2 := config // Copies the struct. Silent. Invisible.En Rust:
let config2 = config.clone(); // Duplicates the value. Visible. Intentional.
La versión de Rust te dice tres cosas que las versiones de Java y Go no:
- La duplicación está ocurriendo. No es compartir, no es mover. Existe una copia nueva e independiente.
- Tiene un coste. El tipo implementa
Clone, lo que significa que hace algo: asignación de memoria, copia, incremento de refcount. - Es deliberado. El programador eligió este punto para duplicar. Se puede encontrar, revisar, perfilar y cuestionar.
Esa visibilidad es la característica. No la carga.
Copy: cuando la contracción es tan barata que puede ser implícita #
Los tipos Copy son la excepción que confirma la regla. Para tipos donde la duplicación es un memcpy bit a bit, enteros, floats, booleanos, tuplas pequeñas de tipos Copy, Rust permite la contracción implícita:
let x: i32 = 42;
let y = x; // Copy: implicit contraction. x is still valid.
Copy es el lenguaje diciendo: “el coste de hacer esto explícito superaría el valor de la visibilidad.” Cuatro bytes copiados en el stack no merecen escribir .clone(). Pero el tipo debe optar explícitamente, y las restricciones son estrictas: sin asignaciones en el heap, sin implementación de Drop. La contracción implícita solo se permite cuando se garantiza que es trivial.
El equilibrio #
El error de la Orden Jedi no fue tener reglas. Fue tratar las reglas como fines en lugar de medios. La Rule of Two no era inherentemente incorrecta; era incorrecta cuando se aplicaba rígidamente a Anakin, cuya situación requería matices.
La culpa del clone sigue el mismo patrón. El principio, “evita copias innecesarias”, es sólido. El dogma, “cada .clone() es un code smell”, no lo es. Los principios te sirven a ti. Tú no sirves a los principios.
Cuándo sentir la culpa #
- Clonar un
Vec<u8>con un millón de elementos dentro de un bucle cerrado. - Clonar un
HashMapcon 10.000 entradas en cada petición HTTP. - Clonar cuando un move funcionaría (deja que Clippy lo detecte).
- Clonar porque “no puedo resolver los lifetimes” cuando los lifetimes codifican un invariante real que deberías preservar.
Cuándo soltar la culpa #
- Clonar un struct de configuración para evitar tres capas de parámetros de lifetime.
- Clonar un wrapper de
PathBufpara satisfacer una API que toma ownership. - Clonar un valor pequeño para pasarlo a un bloque
async move. - Clonar cuando la alternativa es
Rc<RefCell<>>para un struct que ocupa 50 bytes.
La conclusión #
Clone no es un modo de fallo. Es la reintroducción explícita de la contracción (contraction) en un sistema de tipos afines. Todos los lenguajes permiten duplicación; Rust es el que te hace reconocerla.
La pregunta nunca es “¿debería clonar?” en abstracto. Siempre es:
- ¿Qué contiene el struct? (¿50 bytes o 50 megabytes?)
- ¿Dónde ocurre este clone? (¿bucle cerrado o inicialización?)
- ¿Cuál es la alternativa? (¿lifetimes simples o tres capas de
Arc<Mutex<>>para un PathBuf?)
Conoce tus tipos. Mide antes de optimizar. Y deja de tratar una operación de 50 nanosegundos como una perturbación en la Fuerza.
Referencia: