wandres.dev
COPY-ON-WRITE · el coste de los valores

Implementar copy-on-write en tu propio tipo

El patrón completo: un `struct` público sobre un almacenamiento de clase privado. El punto único de mutación, los accesores `_read` y `_modify`, y la lista de invariantes que no puedes romper sin perder la semántica de valor.

⏱ 19 min

Llega el momento de escribir el patrón entero. No es difícil —cabe en cuarenta líneas— pero es implacable: hay media docena de invariantes y romper cualquiera de ellas no produce un error de compilación, sino un tipo que parece funcionar y que, en el caso equivocado, deja que una copia observe la mutación de otra. Ese es el peligro específico de copy-on-write manual: los fallos no se manifiestan como caídas sino como aliasing silencioso, y aparecen tarde, en el código de otro, mucho después de que hayas dejado de mirar. Escribirlo bien exige entender que el struct público es una fachada sin estado propio salvo un puntero, que la clase privada es el dato real, y que el único punto del programa autorizado a escribir en ella es una función que primero comprueba la unicidad.

🎯 Al terminar esta lección sabrás
  • Construir el esqueleto canónico: struct público sobre una clase de almacenamiento final y privada.
  • Escribir el punto único de mutación y justificar por qué debe ser mutating y usar inout.
  • Exponer lectura y escritura con _read y _modify para evitar copias intermedias en los subíndices.
  • Auditar un tipo propio contra la lista de invariantes que sostienen la semántica de valor.

El esqueleto canónico

Supongamos un tipo que envuelve un búfer de bytes de tamaño arbitrario. La estrategia es partirlo en dos: una clase que posee el dato y un struct que posee una referencia a esa clase.

private final class Almacen {
    var bytes: [UInt8]
    init(bytes: [UInt8]) { self.bytes = bytes }
    init(copiando otro: Almacen) { self.bytes = otro.bytes }
}

struct Bufer {
    private var almacen: Almacen

    init(_ bytes: [UInt8] = []) {
        almacen = Almacen(bytes: bytes)
    }

    var count: Int { almacen.bytes.count }
}

Cuatro decisiones ya están tomadas en estas líneas. La clase es final, porque una subclase podría añadir estado que el inicializador de copia no duplicaría y porque el despacho dinámico no aporta nada aquí. Es private, porque el patrón entero depende de que ninguna referencia al almacenamiento escape del tipo. Tiene un inicializador de copia explícito, que será el encargado de materializar la duplicación y el único sitio donde tendrás que acordarte de añadir los campos nuevos. Y el struct guarda la referencia en un var, no en un let, porque en un momento tendrá que pasarla como inout.

El punto único de mutación

Todo lo demás gira alrededor de una función privada que garantiza la exclusividad antes de cualquier escritura.

extension Bufer {
    private mutating func asegurarUnico() {
        if !isKnownUniquelyReferenced(&almacen) {
            almacen = Almacen(copiando: almacen)
        }
    }

    mutating func append(_ byte: UInt8) {
        asegurarUnico()
        almacen.bytes.append(byte)
    }

    mutating func removeAll() {
        asegurarUnico()
        almacen.bytes.removeAll()
    }
}

La función tiene que ser mutating por dos motivos encadenados: reasigna self.almacen en la rama de copia, y necesita pasar almacen como inout, lo cual exige acceso de escritura a self. Esto tiene una consecuencia de diseño que conviene interiorizar: cualquier método que pueda escribir en el almacenamiento debe ser mutating, aunque no parezca modificar nada visible. Si escribes una función no mutating que toca almacen.bytes, el compilador te dejará —la referencia es una constante, pero el objeto al que apunta no lo es— y habrás abierto un agujero por el que una copia verá los cambios de otra.

flowchart TB
api[Metodo mutating publico] --> unico[asegurarUnico]
unico --> chk[isKnownUniquelyReferenced sobre almacen]
chk --> si[Exclusivo luego seguir]
chk --> no[Compartido luego construir Almacen copiando]
no --> reasig[Reasignar self almacen al nuevo]
reasig --> escribir[Escribir en el almacenamiento]
si --> escribir
lect[Metodo de lectura] --> directo[Leer almacen sin comprobar nada]
style unico fill:#cba6f7,color:#11111b
style no fill:#f9e2af,color:#11111b
style escribir fill:#a6e3a1,color:#11111b
style directo fill:#89b4fa,color:#11111b

Los accesores: el problema del subíndice

Exponer un subíndice ingenuo funciona pero desperdicia trabajo. Con get y set clásicos, una expresión como bufer[3] += 1 obliga al compilador a leer el elemento, operar y escribirlo de vuelta, y en tipos con carga útil grande esa lectura copia. La solución que usa la biblioteca estándar son los accesores de corrutina: _read cede una lectura prestada sin copiar y _modify cede una referencia mutable directa al almacenamiento.

extension Bufer {
    subscript(i: Int) -> UInt8 {
        _read {
            yield almacen.bytes[i]
        }
        _modify {
            asegurarUnico()
            yield &almacen.bytes[i]
        }
    }
}

La forma es peculiar: el cuerpo se ejecuta hasta el yield, el llamante hace su trabajo con el valor cedido, y luego el cuerpo continúa tras el yield si hay algo más que hacer. Para el caso mutable, esto significa que asegurarUnico corre una sola vez, el llamante escribe directamente sobre el elemento en el búfer, y no hay ningún valor intermedio que copiar. Es la diferencia entre una operación proporcional al tamaño del elemento y una operación en el sitio, y es también lo que hace que matriz[i][j] = x sea eficiente en Swift, como veremos en la lección siguiente.

Conviene ser honesto sobre el estatus de estos accesores: _read y _modify llevan guion bajo porque siguen siendo oficialmente internos y su sintaxis puede cambiar. La propuesta que los estabiliza bajo los nombres read y modify lleva tiempo en el horno. Úsalos con conocimiento de causa, sabiendo que son la herramienta correcta y que la biblioteca estándar entera depende de ellos.

🔒

El almacenamiento no sale nunca

La clase es private y final, y ningún método la devuelve, la captura ni la expone. Una sola fuga y la comprobación de unicidad deja de significar lo que crees.

🎯

Un solo punto de escritura

Toda mutación pasa por asegurarUnico. Si aparece un segundo camino hacia el almacenamiento, aparecerá también el primer aliasing.

_modify en lugar de get y set

El accesor de corrutina cede el almacenamiento directo, evita el valor intermedio y ejecuta la comprobación de unicidad una sola vez por operación.

La lista de invariantes

Un tipo con copy-on-write manual es correcto si y solo si sostiene todo lo siguiente, y merece la pena auditarlo punto por punto cada vez que lo tocas.

El almacenamiento es una clase Swift nativa, final y private, y ninguna referencia a él escapa: no se devuelve, no se pasa a un closure que sobreviva a la llamada, no se guarda en otro objeto. Toda escritura pasa por la comprobación de unicidad, y por tanto todo método que escriba es mutating. El inicializador de copia duplica todos los campos, y si alguno es a su vez un tipo con copy-on-write, basta asignarlo porque él se encargará de lo suyo. Ninguna propiedad del struct fuera del almacenamiento guarda estado observable; si guardas, por ejemplo, un count cacheado en el struct, tendrás dos fuentes de verdad que pueden divergir tras una copia.

Y hay un caso que merece mención aparte: si el almacenamiento contiene referencias a otros objetos mutables, copiarlo superficialmente no basta. La copia debe ser profunda hasta el punto donde la mutabilidad se detiene. Un almacenamiento que guarda instancias de una clase final con todas sus propiedades let puede compartirlas sin peligro, porque nadie puede observar un cambio que no puede ocurrir; uno que guarda objetos mutables debe duplicarlos también, o habrás construido un híbrido con semántica de valor solo en el primer nivel.

extension Bufer: Equatable, CustomStringConvertible {
    static func == (a: Bufer, b: Bufer) -> Bool {
        a.almacen === b.almacen || a.almacen.bytes == b.almacen.bytes
    }
    var description: String { "Bufer(\(count) bytes)" }
}

Ese atajo de la igualdad es una cortesía habitual y una buena señal de que has entendido el patrón: si dos valores comparten almacenamiento, son iguales sin necesidad de mirar el contenido, y esa comparación de identidad es O(1) frente a la comparación estructural, que es O(n).

Encapsulación como condición de corrección

Casi todo lo que aprendiste sobre encapsulación se presentó como higiene: oculta los detalles para poder cambiarlos, reduce la superficie para reducir el acoplamiento. Son argumentos de mantenibilidad, y su incumplimiento produce código feo pero correcto. Copy-on-write manual pertenece a otra categoría: aquí la encapsulación es una condición de corrección. Si el almacenamiento se filtra, el tipo deja de tener semántica de valor; no se vuelve más difícil de mantener, se vuelve incorrecto, y de la peor manera posible, porque el fallo depende del contador de referencias en tiempo de ejecución y por tanto es no determinista, sensible al optimizador y capaz de no reproducirse jamás en tu máquina. Esta es una instancia de un fenómeno más general y bastante incómodo: hay abstracciones cuya corrección no puede verificarse localmente. El compilador comprueba tipos, exclusividad, aislamiento de actores y nulidad, pero no puede comprobar que una clase privada no se filtre por un camino de datos indirecto, porque eso exigiría un análisis de escape interprocedural que ningún compilador de producción hace en general. Vives, por tanto, sostenido por una convención que solo tú custodias. Es exactamente el mismo tipo de deuda que motivó los tipos no copiables del nivel 21: ~Copyable y consuming convierten justamente esta clase de invariante —quién posee esto y quién puede escribirlo— en algo que el compilador sí verifica. Cuando escribas tu tercer o cuarto tipo con copy-on-write manual y tengas que volver a comprobar a mano las seis invariantes, entenderás sin necesidad de que nadie te lo argumente por qué el lenguaje decidió subir esa garantía al sistema de tipos.

📝
Lo esencial de esta lección

Un struct público que guarda en un var la referencia a una clase final y private. Un método mutating privado que comprueba la unicidad con isKnownUniquelyReferenced y, si falla, reconstruye el almacenamiento con un inicializador de copia. Todas las escrituras pasan por ahí y por tanto son mutating. Los subíndices usan _read y _modify para evitar valores intermedios. El almacenamiento no escapa nunca.

⚔️ Escribe el patrón entero
  1. Implementa Bufer completo con append, removeAll, subíndice con _read y _modify, Equatable y Collection.
  2. Añade un contador estático que se incremente en el inicializador de copia y verifica que solo crece cuando una copia viva es mutada.
  3. Rompe el patrón a propósito: expón un método que devuelva el Almacen y demuestra con un caso concreto que dos valores dejan de ser independientes.
  4. Escribe una versión con get y set clásicos en lugar de _modify y compara ambas con una operación del tipo bufer[i] += 1 sobre elementos grandes.
  5. Añade al almacenamiento una propiedad que sea una referencia a otra clase mutable y decide si el inicializador de copia debe duplicarla. Justifica la respuesta en términos de qué puede observar una copia.