En el Templo Jedi, los jóvenes aprendices aprenden siete formas de combate con sable láser. Cada forma fue desarrollada para contrarrestar una amenaza concreta: la Forma III (Soresu) es defensa pura, la Forma V (Djem So) vuelve la fuerza del enemigo en su contra, la Forma VII (Juyo) canaliza la agresión en poder. Un Jedi que domina una sola forma terminará enfrentando a un oponente que esa forma no puede manejar.
El modelo de ownership de Rust funciona de la misma manera. No existe una única forma “correcta” de compartir datos. Hay seis estrategias, cada una diseñada para un tipo específico de problema. Usar la equivocada no solo produce código subóptimo — produce código más difícil de mantener, más difícil de razonar, y en algunos casos, código que entra en pánico en tiempo de ejecución o genera deadlocks en producción.
El post anterior cubrió qué hace .clone() realmente. Este post cubre cuándo usarlo — y cuándo recurrir a algo completamente diferente.
Forma I: Move — transferir el ownership #
fn process(data: Vec<u8>) {
// data tiene ownership aquí. Se destruye al final del scope.
}
let buffer = vec![0u8; 1024];
process(buffer);
// buffer es inválido. El ownership fue transferido.
La forma: un propietario, un lifetime. El valor entra, se consume, y deja de existir para el llamante.
Cuándo brilla: flujos de trabajo lineales donde el productor crea datos y el consumidor los consume. Sin compartir, sin prestar, sin complejidad. Este es el comportamiento por defecto en Rust, y es el modelo más simple.
Cuándo falla: necesitas el valor después de cederlo. O dos consumidores lo necesitan. En el momento en que escribes process(buffer) e intentas usar buffer de nuevo, el compilador te frena. Move es exclusivo por naturaleza.
Forma II: Borrow — prestar sin dar #
fn analyze(data: &[u8]) -> usize { data.len() }
fn modify(data: &mut Vec<u8>) { data.push(42); }
let mut buffer = vec![0u8; 1024];
let len = analyze(&buffer); // préstamo inmutable: muchos lectores simultáneos
modify(&mut buffer); // préstamo mutable: escritor exclusivo
La forma: acceso sin coste. Sin allocación, sin duplicación, sin overhead. El borrow checker verifica en tiempo de compilación que los préstamos no se solapen peligrosamente.
Cuándo brilla: los datos sobreviven a todos los prestatarios, y el scope es contenido. Una función que lee datos y devuelve un resultado. Un método que muta un campo y retorna. Local, acotado, predecible.
Cuándo falla: los lifetimes se propagan.
struct Processor<'a> {
config: &'a Config,
}
struct Server<'a> {
processor: Processor<'a>,
}
struct Application<'a> {
server: Server<'a>,
}Tres capas de parámetros de lifetime para una referencia a un struct de configuración. Ahora intenta mover Application a un thread spawneado. Se requiere 'static. Los lifetimes te bloquean. O refactorizas toda la cadena de ownership o clonas la config. La config pesa 200 bytes. El refactor toma dos horas y empeora cada firma de tipo.
Borrow es la solución óptima cuando los lifetimes son locales. Cuando no lo son, la cura se vuelve peor que la enfermedad.
Forma III: Clone — dar a cada consumidor su propia copia #
let config = Config { name: "api".into(), port: 8080, tags: vec![] };
let config_for_server = config.clone();
let config_for_logger = config.clone();
start_server(config_for_server);
start_logger(config_for_logger);La forma: copias independientes. Cada consumidor es dueño de sus datos. Sin parámetros de lifetime. Sin estado compartido. Sin coordinación.
Cuándo brilla: los datos son baratos de clonar y los consumidores no necesitan ver las mutaciones de los demás. Structs de configuración, wrappers de paths, DTOs pequeños, payloads de mensajes.
// PathBuf wrapper: ~50 bytes para clonar
#[derive(Clone)]
struct JsonFileTaskRepository { file_path: PathBuf }
// Template builder: clona y personaliza
let template = RequestBuilder::new("https://api.example.com")
.header("Authorization", "Bearer token");
let get_users = template.clone().path("/users");
let get_posts = template.clone().path("/posts");Cuándo falla catastróficamente:
let image: Vec<u8> = load_image(); // 10 MB
// Esto copia 10 megabytes. Cada. Vez.
let copy = image.clone();
process(copy);Clonar 10 MB toma ~100-500 microsegundos. Un Arc::clone toma ~5 nanosegundos. Eso es una diferencia de 100.000x. Si esto está dentro de un handler de requests procesando 1.000 imágenes por segundo, el clone por sí solo consume la mayor parte de tu presupuesto de CPU.
La regla: si el struct contiene campos escalares pequeños y strings cortos, clona. Si contiene algo proporcional al input del usuario (buffers, colecciones, caches), mide antes de clonar.
Forma IV: Rc<T> / Arc<T> — ownership compartido #
use std::sync::Arc;
let data = Arc::new(vec![1, 2, 3, 4, 5]);
let handle1 = Arc::clone(&data); // refcount: 2
let handle2 = Arc::clone(&data); // refcount: 3
// Tres handles, una allocación, cero copias de datos.
La forma: múltiples propietarios, una sola allocación. Los datos viven hasta que el último propietario los suelta. Sin duplicación, sin lifetimes.
| Variante | ¿Thread-safe? | Coste por clone |
|---|---|---|
Rc<T> |
No (!Send) |
~2-5 ns (incremento no atómico) |
Arc<T> |
Sí | ~5-10 ns (incremento atómico) |
Cuándo brilla: los datos son caros de clonar y múltiples consumidores necesitan acceso de lectura. Imágenes, tablas precalculadas, árboles de configuración parseados, datasets compartidos.
Cuándo falla: los consumidores necesitan mutar los datos. Arc<T> te da &T, no &mut T. Si necesitas mutación, necesitas la Forma V.
Convención: escribe Arc::clone(&x) en vez de x.clone(). Ambos compilan idénticamente. La convención señala a los lectores humanos: “esto es un bump de refcount, no una copia profunda.” Un detalle menor, pero en un codebase con tanto Arc como structs pesados, esta distinción previene confusión.
Forma V: Mutabilidad interior — Rc<RefCell<T>> / Arc<Mutex<T>> #
// Single-threaded: Rc<RefCell<T>>
let cache = Rc::new(RefCell::new(HashMap::new()));
let cache2 = Rc::clone(&cache);
cache2.borrow_mut().insert("key", "value");
assert_eq!(cache.borrow().get("key"), Some(&"value"));
// Ambos handles ven la mutación.
// Multi-threaded: Arc<Mutex<T>>
let counter = Arc::new(Mutex::new(0u64));
let counter2 = Arc::clone(&counter);
std::thread::spawn(move || {
*counter2.lock().unwrap() += 1;
});La forma: ownership compartido más mutación. El análisis estático del borrow checker se reemplaza por verificaciones en tiempo de ejecución: RefCell entra en pánico ante un doble préstamo mutable, Mutex bloquea ante contención.
Cuándo brilla: estado mutable compartido que no puede reestructurarse hacia paso de mensajes. Caches, contadores, registros globales.
Cuándo falla: el uso excesivo. Si estás envolviendo cada campo en Rc<RefCell<>>, no has resuelto un problema de ownership — has convertido Rust en un lenguaje con recolector de basura con pasos extra. Las garantías en tiempo de compilación desaparecen. El RefCell puede entrar en pánico en tiempo de ejecución. El Mutex puede generar deadlocks.
Este es el lado oscuro de la Fuerza: poderoso, pero erosiona las garantías que hacen a Rust valioso. Úsalo cuando debas. Cuestiónate cuando lo alcances.
Forma VI: Cow<T> — clone on write #
use std::borrow::Cow;
fn normalize_path(input: &str) -> Cow<str> {
if input.contains("//") {
Cow::Owned(input.replace("//", "/")) // alloca solo cuando es necesario
} else {
Cow::Borrowed(input) // préstamo sin coste
}
}
let a = normalize_path("/home/user"); // Borrowed. Sin allocación.
let b = normalize_path("/home//user"); // Owned. Se allocó.
La forma: diferir el clone hasta que la mutación sea realmente necesaria. En el caso común, no hay allocación. En el caso raro, se hace un clone.
Cuándo brilla: funciones que normalmente devuelven el input sin modificar pero que ocasionalmente lo transforman. Parsers, normalizadores, motores de templates, procesadores de strings. Cualquier pipeline donde la mayoría de valores pasan sin modificación.
Cuándo falla: la mutación siempre es necesaria. Si cada llamada pasa por el camino Owned, Cow añade una rama y un wrapper de enum sin beneficio. Simplemente devuelve un valor owned directamente.
El árbol de decisión #
¿Múltiples consumidores necesitan los datos?
├─ No → Move (Forma I)
└─ Sí
├─ ¿Todos los consumidores sobreviven a la fuente de datos?
│ ├─ Sí, y los lifetimes se mantienen locales → Borrow (Forma II)
│ └─ No, o los lifetimes se propagan viralmente
│ ├─ ¿Los datos son baratos de clonar? (<1 KB, sin campos pesados)
│ │ ├─ Sí → Clone (Forma III)
│ │ └─ No
│ │ ├─ ¿Los consumidores necesitan mutar?
│ │ │ ├─ No → Arc<T> / Rc<T> (Forma IV)
│ │ │ └─ Sí
│ │ │ ├─ ¿Single-threaded? → Rc<RefCell<T>> (Forma V)
│ │ │ └─ ¿Multi-threaded? → Arc<Mutex<T>> (Forma V)
│ │ └─ ¿La mutación es rara? → Cow<T> (Forma VI)
│ └─ ¿Cruzando frontera de thread/async?
│ └─ Debe ser 'static → Arc<T> o CloneLa pregunta clave en cada nodo: ¿qué hay dentro del struct? La respuesta determina qué forma aplica. Un wrapper de PathBuf se clona en 50 nanosegundos; usa la Forma III. Un buffer de imagen de 10 MB no puede permitirse la Forma III; usa la Forma IV. Un cache mutable compartido requiere la Forma V. Un normalizador de texto que rara vez modifica encaja en la Forma VI.
Cuándo clone es correcto: cuatro patrones #
Structs de configuración #
#[derive(Clone)]
struct AppConfig {
db_url: String, // ~50 bytes
port: u16, // 2 bytes
log_level: String, // ~10 bytes
}
let config = load_config();
let server = Server::new(config.clone()); // ~60 bytes clonados
let worker = Worker::new(config); // movido, no clonado
Arc<AppConfig> añadiría conteo de referencias atómico y forzaría a cada API a aceptar Arc<AppConfig> en vez de AppConfig. Para 60 bytes, eso es comprar un Star Destroyer para cruzar un río.
Paso de mensajes #
Cada mensaje es un valor independiente. Clone es el modelo semántico:
tx.send(event.clone()).await?;Patrones builder/template #
Clona una base, personaliza la copia:
let base = Request::builder().timeout(30).auth("token");
let req_a = base.clone().path("/users");
let req_b = base.clone().path("/posts");Wrappers de PathBuf #
El caso de nuestro proyecto TUI: un JsonFileTaskRepository que envuelve un único PathBuf. Clonarlo cuesta ~50 bytes. La I/O que sigue cuesta milisegundos. El clone es invisible en el perfil.
Cuándo clone es catastrófico: cuatro anti-patrones #
Buffers de datos grandes #
// Buffer de 10 MB clonado por request = ~500 μs por clone
// A 1.000 req/s = 500 ms/s gastados solo clonando. La mitad de tu presupuesto de CPU.
let frame = image_buffer.clone(); // NO
let frame = Arc::clone(&image_buffer); // SÍ: 5 ns, no 500 μs
Pools de conexión a base de datos #
Los pools contienen recursos del SO: sockets TCP, sesiones TLS. O no implementan Clone (correcto) o su Clone es un Arc::clone disfrazado (también correcto, pero entiende qué estás clonando).
Estado mutable compartido #
// MAL: cada clone es un snapshot independiente
let cache_a = cache.clone(); // Task A inserta aquí
let cache_b = cache.clone(); // Task B nunca ve las inserciones de A
// BIEN: ownership compartido
let cache = Arc::new(RwLock::new(HashMap::new()));Si los consumidores deben ver las mutaciones de los demás, clone es estructuralmente incorrecto. Crea copias, no vistas.
Tipos que rechazan Clone #
File, TcpStream, MutexGuard. Estos tipos representan recursos únicos del sistema. La ausencia de Clone es el autor del tipo diciéndote: este valor no puede duplicarse de forma significativa. Reestructura tu ownership en vez de luchar contra el sistema de tipos.
Dónde nos deja esto #
Seis formas. Cada una existe porque las demás no pueden manejar todas las situaciones. El desafío no es aprender las formas — es reconocer cuál exige la situación actual.
El próximo post cierra la serie con el ángulo cultural y teórico: por qué la comunidad Rust desarrolló un miedo instintivo a clone, si ese miedo está justificado, y qué tienen que decir la teoría de tipos afines y la lógica lineal sobre la duplicación explícita que .clone() representa.
Referencia: