wandres.dev
DOMAR EL BORROW CHECKER · pensar en ownership

Reestructurar en vez de pelear: split borrows, índices, funciones y scopes

Cuando el borrow checker te frena, la salida rara vez es clonar: es reestructurar. Cuatro técnicas que convierten un préstamo imposible en uno trivial —prestar campos disjuntos, sustituir referencias por índices, extraer funciones que declaren préstamos separados, y secuenciar con el último uso.

⏱ 18 min

La reacción instintiva ante un error del borrow checker es forzarlo: clonar, envolver en RefCell, esparcir unsafe. Casi siempre es la respuesta equivocada. La respuesta idiomática es reestructurar: reorganizar los datos y el flujo para que el préstamo que quieres sea, para el compilador, obviamente seguro. Cuatro técnicas cubren la inmensa mayoría de los casos, y dominarlas transforma el borrow checker de adversario en una guía de diseño que te empuja hacia estructuras más limpias.

🎯 Al terminar esta lección sabrás
  • Aplicar split borrows: prestar campos disjuntos de un struct por separado.
  • Sustituir referencias por índices usize para romper préstamos que se solapan.
  • Extraer funciones cuya firma declare préstamos disjuntos que el checker respeta.
  • Secuenciar accesos con scopes y el último uso para que los préstamos no coincidan.

Split borrows: el compilador presta campos, no structs

El borrow checker es más listo de lo que parece con los structs: entiende que dos campos distintos son ubicaciones de memoria distintas, y permite prestarlos por separado —uno mutable, otro también— a la vez. Esto es un split borrow, y resuelve de golpe muchos falsos “cannot borrow as mutable more than once”:

struct Jugador {
    vida: i32,
    mana: i32,
}

fn main() {
    let mut j = Jugador { vida: 100, mana: 50 };
    let v = &mut j.vida;   // préstamo mutable del campo `vida`
    let m = &mut j.mana;   // préstamo mutable del campo `mana`: OK, son disjuntos
    *v -= 10;
    *m += 5;
    println!("{} {}", j.vida, j.mana);
}

El límite crucial: este razonamiento funciona con campos conocidos en compilación, no a través de una llamada a método ni de una indexación. En cuanto pides &mut self.algo() o &mut v[i], el compilador ya no ve campos concretos sino “algo dentro de self” o “algo dentro de v”, y vuelve a exigir exclusividad total. Por eso dos &mut v[i] y &mut v[j] sobre un mismo Vec no compilan aunque i distinto de j: el checker no puede probar que los índices difieren. Para ese caso, la biblioteca estándar ofrece la herramienta exacta, split_at_mut, que parte un slice en dos mitades disjuntas y devuelve un &mut a cada una:

fn main() {
    let mut datos = [1, 2, 3, 4, 5, 6];
    let (izq, der) = datos.split_at_mut(3);  // dos slices mutables disjuntos
    izq[0] += 100;
    der[0] += 200;
    println!("{datos:?}");  // [101, 2, 3, 204, 5, 6]
}

Índices en vez de referencias

Cuando una referencia se empeña en solaparse con una mutación, a menudo la cura es no sostener la referencia en absoluto: sostén un índice. Un usize es Copy, no presta nada, y no ata la vida de ninguna estructura. Convertir “una referencia a un elemento” en “la posición de un elemento” rompe el préstamo de raíz:

fn indice_del_maximo(v: &[i32]) -> usize {
    let mut mejor = 0;
    for i in 1..v.len() {
        if v[i] > v[mejor] {
            mejor = i;
        }
    }
    mejor   // devolvemos una POSICIÓN, no una referencia
}

fn main() {
    let mut v = vec![3, 7, 2, 9, 4];
    let i = indice_del_maximo(&v); // el préstamo compartido termina aquí
    v[i] += 100;                   // OK: no hay ninguna referencia viva que estorbe
    println!("{v:?}");             // [3, 7, 2, 109, 4]
}

Esta es, además, la base del patrón arena: en lugar de un grafo de nodos que se referencian con &Nodo —una pesadilla de lifetimes—, guardas los nodos en un Vec y los enlazas con índices. Los índices no cuelgan, se copian sin coste y desacoplan la topología de la estructura de la disciplina de préstamos. Es la forma idiomática de construir grafos, árboles y listas enlazadas en Rust seguro.

💡
El índice cambia una garantía por flexibilidad, sé consciente del canje

Un índice no está atado por el borrow checker, y eso es justo su poder y su peligro. Ganas flexibilidad —lo copias, lo guardas, lo devuelves— pero pierdes la garantía de que sigue apuntando a algo válido: si el Vec encoge, el índice puede quedar fuera de rango o señalar otro elemento. Es un préstamo cuya validez ahora vigilas tú, no el compilador. Úsalo cuando la referencia directa es inviable, y trata cada índice como lo que es: una referencia lógica sin red de seguridad estática.

Extraer funciones: la firma declara los préstamos

A veces el checker se pierde en un bloque largo donde suceden demasiadas cosas con demasiados préstamos entremezclados. Extraer parte de la lógica a una función suele desatascarlo, porque una firma es una declaración explícita de qué se presta y cómo, y el checker confía en ella sin reanalizar el cuerpo del llamador. Si dos operaciones necesitan préstamos disjuntos, pasarlos como parámetros separados se lo dice al compilador de forma inequívoca:

struct Banco {
    origen: Vec<i32>,
    destino: Vec<i32>,
}

// La firma declara dos préstamos mutables DISJUNTOS; el checker los acepta.
fn transferir(origen: &mut Vec<i32>, destino: &mut Vec<i32>) {
    if let Some(x) = origen.pop() {
        destino.push(x);
    }
}

fn main() {
    let mut b = Banco { origen: vec![1, 2, 3], destino: vec![] };
    transferir(&mut b.origen, &mut b.destino); // split borrow en el sitio de la llamada
    println!("{:?} {:?}", b.origen, b.destino); // [1, 2] [3]
}

Fíjate en la sinergia: la llamada usa un split borrow de dos campos, y la función los recibe como parámetros separados. El compilador verifica la disjunción una vez, en el sitio de la llamada, y dentro de transferir razona con dos préstamos limpios e independientes. Extraer una función no es solo higiene: reorganiza el problema de préstamos en piezas que el checker resuelve por separado.

Secuenciar con scopes y el último uso

La última técnica es la más simple y, gracias a NLL, la que menos ceremonia necesita: ordena los accesos para que no se solapen en el tiempo. Como un préstamo muere en su último uso, a menudo basta con mover ese último uso antes de la operación que conflictúa, o —en los pocos casos donde NLL no basta— acotar el préstamo en un bloque explícito para forzar su muerte:

fn main() {
    let mut config = vec![10, 20, 30];

    // Extrae lo que necesites del préstamo compartido y termina con él...
    let total: i32 = config.iter().sum(); // préstamo efímero: nace y muere en esta línea

    // ...antes de pedir el préstamo mutable.
    config.push(total);   // OK: ningún préstamo compartido sigue vivo
    println!("{config:?}"); // [10, 20, 30, 60]
}

En los raros casos donde el último uso no basta —porque el préstamo y la mutación quedan inevitablemente entrelazados—, un bloque explícito sigue siendo una herramienta legítima: extrae por copia lo que necesites dentro del scope y deja que la llave mate el préstamo antes de mutar.

fn main() {
    let mut datos = vec![10, 20, 30];
    let primero;
    {
        let r = &datos[0];   // préstamo acotado a este bloque
        primero = *r;        // extrae por copia: `i32` es Copy
    }                        // la llave mata el préstamo aquí
    datos.push(primero);     // OK: fuera del scope no queda ningún préstamo vivo
    println!("{datos:?}");   // [10, 20, 30, 10]
}

No abuses de este recurso —con NLL rara vez hace falta—, pero tenlo a mano para el préstamo terco que se niega a morir a tiempo.

flowchart TB
c[Conflicto de prestamos] --> q1[Son campos disjuntos de un mismo struct]
q1 -->|si| s1[Split borrow prestalos por separado o split_at_mut]
q1 -->|no| q2[Necesitas la referencia o solo el acceso]
q2 -->|solo el acceso| s2[Usa un indice usize en vez de una referencia]
q2 -->|logica enredada| s3[Extrae una funcion con parametros disjuntos]
s3 --> s4[O secuencia con el ultimo uso y scopes]
Pelear con el borrow checker casi siempre delata un modelo de datos confuso

La expresión “pelear con el borrow checker” está tan extendida que suena inevitable, como si Rust impusiera una fricción arbitraria que hay que sufrir. Pero examina los casos reales y descubrirás un patrón incómodo: la enorme mayoría de esas peleas no nacen de que el compilador sea pedante, sino de que tu modelo de datos no tiene claro quién posee qué, quién lo modifica y durante cuánto tiempo. El error del borrow checker es el síntoma; la enfermedad es una estructura donde esas respuestas están enredadas. Y por eso las cuatro técnicas de esta lección no son trucos para “escapar” del compilador: son cuatro formas de aclarar el modelo. El split borrow te obliga a reconocer que dos campos son cosas independientes. El índice te obliga a decidir si necesitas la identidad de un dato o solo tocarlo. Extraer una función te obliga a nombrar exactamente qué presta cada operación. Secuenciar te obliga a admitir que dos accesos no tenían por qué ser simultáneos. En cada caso, cuando terminas de reestructurar, el código no solo compila: es mejor —más explícito sobre su gestión de datos, más fácil de razonar, más difícil de romper—. Esta es la lección más profunda del nivel: el borrow checker no te pide que luches contra él, te pide que pienses con la precisión que un programa de sistemas siempre necesitó y que otros lenguajes te dejaban posponer hasta el crash en producción. Reestructurar en vez de clonar es elegir entender tu programa en vez de acallar al mensajero.

📝
Lo esencial de reestructurar

Ante un préstamo imposible, reestructura antes de clonar. Cuatro herramientas: split borrows para prestar campos disjuntos de un struct (y split_at_mut para slices); índices usize para sostener una posición en vez de una referencia que se solapa; extraer funciones cuya firma declare préstamos disjuntos que el checker acepta sin reanalizar; y secuenciar con el último uso o scopes para que los accesos no coincidan en el tiempo. Cada técnica no esquiva al compilador: aclara quién posee y muta qué, y deja el código mejor de como estaba.

⚔️ Convierte el conflicto en estructura
  1. Provoca un doble &mut sobre dos campos de un struct y confirma que el split borrow lo permite; luego intenta lo mismo a través de un método y observa que deja de compilar.
  2. Toma dos &mut v[i] y &mut v[j] sobre un mismo Vec, mira el error, y resuélvelo con split_at_mut.
  3. Reescribe una función que devolvía &i32 para que devuelva un usize, y explica qué préstamo desaparece con el cambio.
  4. Extrae una operación con préstamos entremezclados a una función con dos parámetros &mut disjuntos y razona por qué el checker ahora la acepta.
  5. Encuentra en código propio un clone puesto para callar al compilador y sustitúyelo por una de las cuatro técnicas; compara legibilidad y coste.