Cuándo el copy-on-write te traiciona
Copias inesperadas por referencias temporales, capturas en closures que retienen el búfer, y el comportamiento real de las colecciones anidadas. Los patrones que convierten una operación en el sitio en una duplicación completa.
Copy-on-write funciona hasta que deja de funcionar, y cuando deja de funcionar no avisa. No hay error, ni advertencia, ni cambio de resultado: solo un programa que de pronto asigna memoria en un bucle interno y corre veinte veces más lento de lo que su complejidad asintótica sugiere. La causa es siempre la misma y siempre pequeña: alguien sostiene una segunda referencia fuerte al búfer en el instante en que se comprueba la unicidad. Puede ser una variable local que creíste inocente, un closure que capturó lo que no debía, una propiedad que se leyó en el orden equivocado. Esta lección es un catálogo de esas traiciones, y aprender a reconocerlas de un vistazo es lo que separa a quien escribe Swift correcto de quien escribe Swift rápido.
- Reconocer las construcciones que crean una referencia fuerte temporal al almacenamiento.
- Explicar por qué una captura en un closure convierte cada mutación posterior en una copia completa.
- Predecir cuándo una colección anidada muta en el sitio y cuándo duplica un nivel entero.
- Aplicar un procedimiento sistemático para localizar copias inesperadas en código propio.
La referencia temporal que no ves
El caso arquetípico es una variable local creada para leer y olvidada mientras se escribe.
var datos = Array(0..<1_000_000)
let vista = datos // refcount 2
for i in datos.indices {
datos[i] += 1 // la primera escritura duplica un millon de enteros
}
print(vista.count) // vista sigue viva hasta aqui
La duplicación ocurre una vez, no un millón, porque tras ella datos es dueño exclusivo. Pero el patrón se vuelve venenoso cuando la referencia extra se recrea dentro del bucle, y ahí sí se paga en cada iteración. La variante más común aparece cuando una propiedad de una clase sirve a la vez de fuente y de destino, y cada iteración construye una lectura intermedia.
final class Modelo { var items: [Int] = Array(0..<100_000) }
let m = Modelo()
for i in 0..<m.items.count {
let actual = m.items // copia el struct, refcount 2
m.items[i] = actual[i] * 2 // escribe con el buffer compartido: duplica
}
Cien mil duplicaciones de cien mil elementos. El código es correcto y su complejidad aparente es lineal; la real es cuadrática. La corrección es trivial —sacar la lectura del bucle, o no crear la variable— y el diagnóstico es siempre el mismo: buscar qué otra cosa apunta al búfer en el momento de escribir.
Closures: el retain que sobrevive
Un closure que captura una colección la retiene mientras el closure viva. Si el closure es @escaping y se guarda en algún sitio, esa retención es indefinida y todas las mutaciones posteriores de la variable original pagan copia, no solo la primera.
var registro: [String] = []
var cierres: [() -> Void] = []
cierres.append { print(registro.count) } // captura registro por referencia al box
for i in 0..<1_000 {
registro.append("linea \(i)") // cada append comprueba unicidad
}
Aquí conviene matizar el mecanismo, porque la captura de una var no copia el array: promueve la variable a un box del heap que el closure y el contexto comparten. La consecuencia práctica sobre el búfer depende de si el closure llega a leer el array, de cuándo lo hace y de qué decide el optimizador; pero la forma peligrosa es inequívoca cuando la captura es por valor.
var datos = Array(0..<100_000)
let instantanea = datos // captura explicita por valor
let tarea = { instantanea.count } // el buffer queda retenido por el closure
datos.append(1) // duplica: hay dos duenos
La regla operativa: si necesitas una instantánea, cíñela al ámbito más pequeño posible y libérala cuanto antes; si no la necesitas, captura solo lo que vas a usar —un count, un identificador— en lugar de la colección entera. Y sé consciente de que [weak self] no ayuda aquí: el problema no es un ciclo de retención sino una referencia fuerte legítima que dura de más.
flowchart TB causa[Copia inesperada] --> t1[Variable local intermedia viva durante la escritura] causa --> t2[Closure que capturo la coleccion por valor] causa --> t3[Propiedad leida y escrita en la misma expresion de bucle] causa --> t4[Elemento extraido de una coleccion anidada a una variable] t1 --> fix1[Reducir el ambito de la lectura] t2 --> fix2[Capturar solo lo necesario y no la coleccion entera] t3 --> fix3[Sacar la lectura fuera del bucle] t4 --> fix4[Mutar en el sitio con subindice o con default] style causa fill:#f38ba8,color:#11111b style fix1 fill:#a6e3a1,color:#11111b style fix2 fill:#a6e3a1,color:#11111b style fix3 fill:#a6e3a1,color:#11111b style fix4 fill:#a6e3a1,color:#11111b
Colecciones anidadas
Un array de arrays es el caso donde la intuición falla en ambas direcciones, y donde los accesores de corrutina de la lección anterior explican todo. La buena noticia primero: mutar por subíndice encadenado es eficiente.
var matriz = Array(repeating: Array(repeating: 0, count: 1_000), count: 1_000)
matriz[i][j] = 7 // en el sitio: el _modify del nivel exterior cede la fila
El subíndice exterior usa _modify, que cede una referencia mutable a la fila en lugar de una copia; la fila resulta ser única porque el array exterior la posee en exclusiva; y la escritura ocurre donde está. Ninguna fila se duplica. Ahora la mala noticia, que es la misma operación escrita de otro modo.
var fila = matriz[i] // copia el struct de la fila: refcount 2
fila[j] = 7 // duplica los mil elementos
matriz[i] = fila // y descarta el original
Idéntico resultado, coste completamente distinto. La lección general: extraer un elemento a una variable rompe la cadena de mutación en el sitio. Lo mismo vale para diccionarios, con un giro adicional que es la trampa clásica de Swift.
var grupos: [String: [Int]] = [:]
grupos["a", default: []].append(1) // en el sitio, via _modify del subindice
grupos["a"]?.append(2) // tambien en el sitio en Swift moderno
if var arr = grupos["a"] { // esto no: extrae, duplica y reinserta
arr.append(3)
grupos["a"] = arr
}
La forma con default es la canónica y la que debes memorizar: el subíndice con valor por defecto cede el almacenamiento del diccionario directamente, y el append opera sobre él. La forma con if var hace tres trabajos donde bastaba uno, y es el patrón que más veces he visto convertir un histograma lineal en uno cuadrático.
Toda variable intermedia es sospechosa
Extraer un valor a una variable local crea una referencia fuerte. Si esa variable sigue viva cuando escribes, pagas la copia.
Los closures retienen indefinidamente
Una captura por valor mantiene el búfer compartido mientras el closure exista, y convierte cada mutación posterior en una duplicación.
Encadena, no extraigas
En colecciones anidadas, m[i][j] = x y el subíndice con default mutan en el sitio. Sacar el elemento a una variable duplica un nivel entero.
Cómo se caza
El procedimiento que funciona tiene tres pasos y no requiere herramientas exóticas. Primero, haz observable la copia: en un tipo propio, un contador estático en el inicializador de copia del almacenamiento; en un Array de la biblioteca, imprime withUnsafeBufferPointer y compara direcciones base antes y después de la operación sospechosa. Un cambio de dirección base es una duplicación, sin ambigüedad.
Segundo, acota el ámbito de toda lectura. La mayoría de las copias inesperadas desaparecen encerrando la lectura en un bloque, usando defer para liberar antes de escribir, o simplemente reordenando dos líneas. Cuando no puedas evitar una referencia extra, considera anularla explícitamente: si la variable es Optional, asignarle nil antes de la escritura devuelve el contador a uno.
Tercero, prefiere las formas que ceden almacenamiento. Entre dos maneras de expresar la misma mutación, la que encadena subíndices, la que usa default:, la que llama a un método mutating in situ y la que usa withUnsafeMutableBufferPointer para bucles calientes son todas preferibles a la que extrae, muta y reinserta. Y no olvides el suelo de todo: mide con optimizaciones activadas, porque sin ellas el ruido del tráfico de ARC hará indistinguibles las copias reales de las aparentes.
Copy-on-write es un ejemplo de manual de lo que Joel Spolsky bautizó como ley de las abstracciones con fugas, pero con un matiz que hace el caso más interesante de lo habitual. La fuga aquí no es un accidente de implementación que algún día se arreglará: es estructural e irreparable dentro del modelo. El patrón promete que la copia es barata, y esa promesa solo puede cumplirse si el programa mantiene una propiedad —que no haya referencias extra vivas en el momento de escribir— que el propio modelo declara invisible. Te dan una abstracción cuyo coste depende de un estado que la abstracción se compromete a ocultarte. Ninguna cantidad de ingeniería en la biblioteca estándar arregla eso, porque la información que necesitas para razonar sobre rendimiento es exactamente la que la semántica de valor existe para borrar. La consecuencia es una asimetría cognitiva que define la experiencia de optimizar Swift: la corrección se razona localmente —cada valor es independiente, punto— pero el rendimiento se razona globalmente, contando dueños de un búfer a lo largo de funciones, closures y ámbitos que ni siquiera están en el mismo archivo. Es la misma asimetría, por cierto, que hace que los perfiles de rendimiento sean tan poco intuitivos aquí: una línea que solo lee puede ser la responsable de que otra línea, treinta más abajo, cueste un millón de operaciones. Aprender a leer un programa Swift buscando dueños en lugar de valores es una habilidad distinta de aprender a escribirlo, y es la que este nivel te está pidiendo que desarrolles.
Toda copia inesperada tiene la misma causa: una segunda referencia fuerte viva en el instante de la escritura. Las fuentes habituales son variables locales intermedias, capturas por valor en closures, lecturas y escrituras de la misma propiedad dentro de un bucle, y elementos extraídos de colecciones anidadas. La regla operativa es encadenar en lugar de extraer, acotar el ámbito de las lecturas y hacer observable la copia comparando direcciones base.
- Reproduce el bucle cuadrático del
Modelo, mide su tiempo con cien mil elementos, corrígelo y compara. Explica el cambio de complejidad. - Captura un array grande por valor en un closure
@escaping, guárdalo, y verifica con direcciones base que cada mutación posterior duplica el búfer. - Escribe las dos versiones de la mutación de una matriz —encadenada y con extracción a variable— y compara el número de asignaciones con un contador.
- Construye un histograma sobre un millón de elementos con
if vary con el subíndicedefault:, y mide ambos. Explica la diferencia con los accesores de corrutina. - Toma una función real de un proyecto tuyo que mute una colección dentro de un bucle y audítala buscando dueños del búfer en cada línea, no valores.