borrowing y consuming: la convención hecha explícita
Qué promete cada modificador de parámetro, quién queda a cargo de destruir el valor, por qué en tipos copiables son una pista y en no copiables un contrato, y cómo el operador `consume` cierra una vida a mano.
Toda llamada a función plantea una pregunta que el código fuente de Swift nunca hacía visible: cuando el argumento cruza la frontera, ¿quién queda a cargo de destruirlo? Hay solo tres respuestas posibles y el compilador lleva desde 2014 eligiendo entre ellas por su cuenta. El llamante conserva la propiedad y presta el valor mientras dura la llamada. El llamante cede la propiedad y se desentiende. O el llamante presta acceso exclusivo de escritura y recupera el valor mutado al terminar. borrowing, consuming e inout son exactamente esas tres respuestas, escritas por fin donde se pueden leer.
- Enunciar la promesa que cada modificador establece entre llamante y llamado.
- Recordar las convenciones por omisión y saber cuándo escribirlas cambia algo.
- Distinguir el papel de pista en tipos copiables del papel de contrato en tipos no copiables.
- Usar los operadores
consumeycopypara dirigir la vida de un valor a mano.
Las tres promesas
Un parámetro borrowing establece que el llamado obtiene acceso de solo lectura durante la llamada y ni un instante más. El llamante garantiza que el valor sigue vivo mientras dure; el llamado no incrementa ningún recuento, no puede mutar el parámetro y no puede quedárselo: guardarlo en una propiedad, capturarlo en un cierre que escapa o devolverlo requiere una copia, y en un tipo no copiable eso es sencillamente ilegal.
Un parámetro consuming establece lo contrario: la propiedad se transfiere. El llamado hereda la obligación de destruir el valor, puede almacenarlo sin copiarlo y puede consumirlo dentro. A cambio, el llamante pierde el derecho a usarlo después de la llamada.
inout es la tercera vía y no es ninguna de las anteriores: un préstamo mutable exclusivo. La ley de exclusividad garantiza que mientras dura ese acceso nadie más, ni siquiera el propio llamante, puede leer o escribir esa posición de memoria. No hay transferencia de propiedad en ningún momento: el valor sale y vuelve, y el responsable de destruirlo sigue siendo quien lo era antes de la llamada.
struct Registro: ~Copyable {
let bytes: UnsafeMutableRawBufferPointer
}
func inspeccionar(_ r: borrowing Registro) -> Int { r.bytes.count }
func archivar(_ r: consuming Registro) { /* se queda con el */ }
func uso(_ r: consuming Registro) {
_ = inspeccionar(r) // prestamo: r sigue siendo mio
archivar(r) // transferencia: aqui termina mi derecho
// inspeccionar(r) // ERROR: r ya fue consumido
}
El diagnóstico de la última línea es la esencia del modelo. No dice que la operación sea cara: dice que el valor ya no te pertenece.
Las convenciones por omisión
Escribir estos modificadores rara vez cambia la convención elegida, porque las omisiones ya son las razonables. Un parámetro normal de una función o de un método es borrowing por omisión. Los parámetros de un inicializador y el valor entrante de un asignador de propiedad son consuming, porque su trabajo consiste precisamente en quedarse con lo que reciben. Un método mutating toma su receptor como inout, y un método puede declararse borrowing func o consuming func para fijar la convención de self.
De ahí se sigue una regla práctica: en tipos copiables, anotar suele ser redundante, y su efecto real se limita a los casos donde la convención por omisión no coincide con lo que el cuerpo hace con el valor. Dicho de otro modo, escribir borrowing en un parámetro corriente no cambia nada salvo la documentación; escribir consuming sí cambia el código generado, porque contradice la omisión.
struct Lote {
private(set) var elementos: [Int] = []
// Estilo constructor: al consumir self no hay dos duenos del bufer,
// de modo que la mutacion no dispara copia al escribir.
consuming func agregando(_ x: Int) -> Lote {
var copia = self
copia.elementos.append(x)
return copia
}
}
El patrón encadenado clásico paga una copia del búfer en cada eslabón porque el receptor prestado sigue vivo en el llamante mientras se muta la copia. Al declarar el método consuming, el receptor deja de estar vivo fuera y la comprobación de unicidad tiene éxito.
Es el ejemplo canónico porque muestra que la anotación no elimina trabajo: reubica una decisión. El coste de la copia no desapareció por escribir una palabra, desapareció porque la palabra le comunicó al compilador un hecho que antes no podía deducir, a saber, que nadie iba a usar el receptor después.
Un consuming en un parámetro que solo se lee obliga al llamante a ceder la propiedad. Si el llamante necesita el valor después, el compilador insertará una copia o un retain en el sitio de la llamada para poder satisfacer ambas exigencias, y habrás movido el coste en lugar de eliminarlo. La pregunta correcta no es cuál suena más rápido, sino si el cuerpo se queda con el valor.
Pista en lo copiable, contrato en lo no copiable
La misma palabra clave tiene dos pesos muy distintos según el tipo al que se aplica, y confundirlos es la fuente principal de malentendidos.
En un tipo copiable, la semántica del programa no cambia. Si una función pide consuming y el llamante todavía necesita el valor, el compilador copia y todos quedan satisfechos; si pide borrowing y el cuerpo necesita almacenarlo, el compilador copia también. La copia es siempre una salida disponible, así que el modificador funciona como una directiva de rendimiento: ajusta dónde ocurre el trabajo, no qué significa el programa.
En un tipo no copiable esa salida no existe. Sin copia posible, el modificador se convierte en la única forma de decir si el valor sobrevive a la llamada, y el verificador de préstamos lo hace cumplir. Aquí borrowing y consuming no son adorno: son parte de la firma tanto como el tipo de retorno.
De esa asimetría se sigue una consecuencia de compatibilidad que afecta al diseño de bibliotecas. Cambiar un parámetro copiable de una convención a otra no rompe a ningún llamante, porque el compilador ajusta las copias en cada sitio de llamada; hacer lo mismo en un tipo no copiable rompe a todo el que usara el valor después de la llamada. En el primer caso estás retocando el rendimiento, en el segundo estás modificando el contrato público.
flowchart TB q1[El cuerpo necesita mutar el valor del llamante] q1 -->|si| io[inout prestamo mutable exclusivo] q1 -->|no| q2[El cuerpo se queda con el valor tras la llamada] q2 -->|si| co[consuming transferencia de propiedad] q2 -->|no| bo[borrowing prestamo de solo lectura] co --> n1[En no copiables el llamante pierde el acceso] bo --> n2[En no copiables no se puede almacenar ni devolver] style bo fill:#a6e3a1,color:#11111b style co fill:#cba6f7,color:#11111b style io fill:#89b4fa,color:#11111b
borrowing
Acceso de solo lectura acotado a la llamada. Sin recuento, sin mutación, sin quedárselo. Es la convención por omisión de casi todo.
consuming
Transferencia de propiedad y de la obligación de destruir. Es lo que ya hacen los inicializadores y los asignadores.
inout
Préstamo mutable con exclusividad garantizada. No es propiedad transferida: el valor vuelve al llamante al final del acceso.
Dirigir la vida a mano
Los modificadores gobiernan las fronteras de función; dentro de una función, dos operadores permiten intervenir directamente. El operador consume termina la vida de una variable en ese punto exacto y entrega su valor: lo que quedaba de esa variable deja de existir para el compilador, y usarla después es un error.
func enviar(_ carga: consuming [UInt8]) { /* ... */ }
func rutina() {
var carga = generar()
ajustar(&carga)
enviar(consume carga) // fin explicito de la vida de la variable
// print(carga) // ERROR: uso despues de consumir
}
Sin el operador, el compilador debe suponer que la variable puede usarse más adelante y mantenerla viva hasta el final de su ámbito. Con él, la propiedad se reenvía a la llamada y desaparece cualquier necesidad de conservar el valor. Su simétrico es el operador copy, que fuerza una copia explícita cuando el análisis habría reenviado la propiedad y todavía necesitas el original.
Hay dos matices que conviene tener presentes. El primero es que consume sobre un tipo copiable es una petición de fin de vida, no una prohibición absoluta: sirve para ayudar al optimizador y para documentar la intención, y sobre variables globales o propiedades de clase el compilador puede necesitar copiar de todos modos. El segundo es que el modelo trabaja sobre variables locales y parámetros; una propiedad almacenada de una clase no se puede consumir, porque el objeto sigue existiendo y su campo debe quedar en un estado válido.
El análisis que hay detrás merece un nombre, porque explica los diagnósticos que verás: el compilador construye el grafo de flujo de la función y comprueba, para cada uso de una variable, que no exista ningún camino de ejecución en el que ese uso venga después de un consumo. Por eso el error habla de una línea concreta y señala además dónde ocurrió el consumo, y por eso una transferencia dentro de una rama condicional invalida la variable en todo lo que siga a la unión de las ramas, aunque la otra rama no consumiera nada. El verificador razona sobre caminos, no sobre líneas.
Cuando el verificador se queja de un uso después de consumir, la primera reacción tentadora es añadir una copia explícita. Casi siempre hay una solución mejor: mover la línea que consume al final, o extraer la parte que solo lee a una función borrowing que se llame antes. Un consumo colocado en el último uso real no necesita ninguna copia, y el código queda además más claro sobre dónde termina la vida del valor.
Debajo de la sintaxis hay una sola pregunta, y merece formularse con precisión porque explica todo lo demás: en cualquier punto del programa, cada valor con destructor tiene exactamente un responsable de ejecutarlo, y una convención de paso de argumentos no es más que la regla que decide si ese responsable cambia al cruzar la frontera de la llamada. borrowing significa que no cambia. consuming significa que sí. inout significa que el responsable sigue siendo el llamante, pero cede temporalmente el derecho exclusivo de escritura. Vista así, la novedad de Swift 5.9 no es haber inventado nada, sino haber convertido en verificable una decisión que antes se tomaba por heurística. Y esto tiene una consecuencia de diseño que trasciende a Swift: cuando el sistema de tipos puede nombrar al responsable de la destrucción, la liberación de recursos deja de ser un problema de disciplina y se convierte en un problema de tipos, con toda la maquinaria de verificación que eso arrastra. Es la misma transición que separó a los lenguajes con comprobación estática de tipos de los que solo fallaban en ejecución, aplicada esta vez no a la forma de los datos sino a su duración. Por eso el modelo se siente incómodo al principio: no estás aprendiendo palabras clave nuevas, estás aceptando que la vida de un valor es información de tipo, y que como toda información de tipo, alguien tendrá que escribirla cuando el compilador no pueda deducirla.
borrowing presta para leer, consuming transfiere la propiedad junto con la obligación de destruir, inout presta acceso exclusivo de escritura. Las omisiones ya son las sensatas: borrowing en parámetros normales, consuming en inicializadores y asignadores. En tipos copiables el modificador es una directiva de rendimiento porque siempre queda la salida de copiar; en no copiables es un contrato verificado. El operador consume cierra una vida a mano y copy fuerza el camino contrario.
- Declara una estructura con un array grande y compara el número de asignaciones de un método encadenado escrito como
mutating, comoborrowingy comoconsuming. - Escribe una función
borrowingque intente guardar su parámetro en una propiedad y clasifica el error que produce en un tipo copiable y en uno no copiable. - Aplica
consumea una variable local antes de una llamada y comprueba con el ensamblador si desaparece algún release. - Provoca de forma deliberada una violación de exclusividad pasando la misma variable dos veces como
inouty lee el diagnóstico completo. - Toma tres funciones de tu proyecto que reciben tipos grandes y decide, justificando cada caso, cuál de las tres convenciones les corresponde.