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

isKnownUniquelyReferenced: la función que hace posible el COW

La única primitiva que necesita copy-on-write y por qué debe ser dinámica. Su firma, el motivo del `inout`, las tres condiciones que la hacen mentir a la baja, y qué hace el compilador alrededor de ella.

⏱ 18 min

Todo el edificio de copy-on-write descansa sobre una sola pregunta: en este instante, ¿soy el único dueño de este búfer? Si la respuesta es sí, puedo escribir sin miedo; si es no, debo duplicar primero. Lo notable es que esa pregunta no admite respuesta estática. El compilador no puede saber cuántas variables vivas apuntan a un objeto concreto en un punto arbitrario de la ejecución, porque eso depende de qué ramas se tomaron, qué funciones se llamaron y qué guardó alguien en una propiedad. La pregunta es irreductiblemente dinámica, y Swift la responde consultando el mismo contador de referencias que ARC ya mantiene por otros motivos. La función que expone esa consulta se llama isKnownUniquelyReferenced, y su nombre —con ese known delante— confiesa por adelantado que su respuesta es conservadora.

🎯 Al terminar esta lección sabrás
  • Justificar por qué la comprobación de unicidad no puede resolverse en tiempo de compilación.
  • Leer la firma de isKnownUniquelyReferenced y explicar por qué el parámetro es inout.
  • Enumerar los tres casos en que la función responde false sobre un objeto que crees exclusivo.
  • Describir qué transformaciones aplica el optimizador alrededor de la comprobación en código real.

Una pregunta que solo el tiempo de ejecución responde

ARC mantiene, en la cabecera de todo objeto de clase, un contador de referencias fuertes. Ese contador existe para decidir cuándo destruir el objeto, pero contiene exactamente la información que copy-on-write necesita: si vale uno, la única referencia fuerte viva es la que tengo en la mano, y por tanto ninguna otra variable del programa puede observar lo que yo escriba.

El razonamiento merece enunciarse con precisión porque de él depende la corrección del patrón entero. Una escritura sobre un objeto es indetectable si y solo si nadie más puede leerlo; nadie más puede leerlo si no tiene una referencia; y no puede tener una referencia fuerte si el contador vale uno y esa unidad es la mía. La implicación va en un solo sentido —el contador uno basta, pero no es necesario, porque dos referencias en el mismo hilo que jamás lean concurrentemente también serían inofensivas— y esa unilateralidad es la fuente de todas las copias de más que veremos.

final class Caja { var n = 0 }

var caja = Caja()
print(isKnownUniquelyReferenced(&caja))  // true

let otra = caja
print(isKnownUniquelyReferenced(&caja))  // false

Nadie declaró nada. La diferencia entre las dos llamadas es puro estado de ejecución, y de ahí se sigue el rasgo más interesante del diseño: copy-on-write reutiliza infraestructura que ya estabas pagando. El contador atómico existiría igual sin el patrón; el patrón solo lo lee. Esa es la razón por la que Swift pudo ofrecer semántica de valor barata sin añadir maquinaria nueva al runtime, y también la razón por la que el patrón es inseparable de ARC: un lenguaje con recolector de basura por trazado no tiene contador que consultar y no podría implementarlo así.

Vale la pena insistir en por qué la vía estática está cerrada. Determinar si un objeto tiene exactamente una referencia fuerte viva en un punto del programa es un análisis de alias interprocedural, y en presencia de bibliotecas compiladas por separado, de despacho dinámico y de closures almacenados, es indecidible en el caso general. Un compilador puede resolver los casos fáciles —y de hecho el optimizador de Swift los resuelve, eliminando comprobaciones cuando puede probar la unicidad localmente— pero no puede resolverlos todos, y una garantía que solo se cumple a veces no sirve para fundamentar la semántica de un tipo. La comprobación dinámica es el suelo sobre el que descansa el resto.

La firma y sus tres exigencias

La declaración es escueta y cada pieza está ahí por un motivo.

func isKnownUniquelyReferenced<T: AnyObject>(_ object: inout T) -> Bool
func isKnownUniquelyReferenced<T: AnyObject>(_ object: inout T?) -> Bool

La sobrecarga sobre Optional existe porque un almacenamiento diferido —creado en la primera escritura, no en el inicializador— es un patrón legítimo, y sin ella tendrías que desenvolver el opcional a una variable local, lo cual crearía justo la referencia extra que arruina la respuesta. Es un buen ejemplo de una API cuya forma está dictada enteramente por lo que quiere evitar.

El parámetro es inout, y no por capricho. Un parámetro de entrada normal en Swift se pasa como préstamo garantizado y, en principio, tampoco retendría; pero inout aporta algo que el préstamo no da: acceso exclusivo verificado. Desde SE-0176, pasar una variable como inout prohíbe cualquier otro acceso a esa misma variable durante la llamada, y el compilador o el runtime lo comprueban. Eso convierte la respuesta en algo defendible: sin exclusividad, otra parte del programa podría estar copiando la referencia justo mientras se lee el contador, y el true sería una mentira en cuanto la función retornara. La segunda razón es aún más prosaica: inout obliga a que el argumento sea una variable almacenada real y direccionable. No puedes pasar una constante, ni una propiedad calculada, ni el resultado de una expresión —y eso descarta de raíz los usos en los que la unicidad no significaría nada.

Ese requisito propaga una consecuencia hacia arriba que hay que aceptar en el diseño del tipo: como el almacenamiento debe ser una var y pasarse por inout, la función que lo comprueba necesita acceso de escritura a self, y por tanto ha de ser mutating. De ahí que, en un tipo con copy-on-write, todo método que pueda escribir sea mutating aunque en apariencia no modifique ninguna propiedad del struct. No es una elección estilística sino una obligación heredada de la firma.

flowchart TB
pregunta[Quiero mutar el almacenamiento compartido] --> comp[Leer el contador de referencias fuertes]
comp --> uno[Vale uno luego soy dueno exclusivo]
comp --> mas[Vale mas de uno luego hay copias vivas]
uno --> mutar[Escribir en el sitio sin asignar nada]
mas --> dup[Asignar buffer nuevo y copiar elementos]
dup --> mutar
nota[Referencias weak y unowned no cuentan y las clases de Objective C dan siempre falso] --> comp
style comp fill:#89b4fa,color:#11111b
style mutar fill:#a6e3a1,color:#11111b
style dup fill:#f9e2af,color:#11111b
style nota fill:#f38ba8,color:#11111b

Los tres casos en que responde false de más

El prefijo known es una advertencia contractual: la función puede devolver false cuando el objeto es en realidad exclusivo, pero nunca devolverá true cuando no lo sea. La asimetría es deliberada y es lo que la hace segura: un false de más provoca una copia innecesaria —un problema de rendimiento—, mientras que un true de más provocaría mutación compartida, es decir, corrupción silenciosa. Tres situaciones producen ese false conservador.

La primera son las referencias débiles. Una referencia weak o unowned no incrementa el contador de fuertes, de modo que un observador débil vivo no impide un true. Esto no es un fallo sino la definición: quien tiene una referencia débil no puede impedir la destrucción del objeto, y su lectura ya está sujeta a que el objeto desaparezca. Pero implica que un weak que sí observe el almacenamiento verá mutaciones que creías privadas, y por eso el almacenamiento de un tipo con copy-on-write jamás debe filtrarse hacia fuera.

final class Almacen { var datos = [1, 2, 3] }

var caja = Almacen()
weak var observador = caja
print(isKnownUniquelyReferenced(&caja))  // true: el weak no cuenta
print(observador?.datos ?? [])           // y sin embargo esta mirando

La segunda son las clases de Objective-C. La función devuelve siempre false para cualquier objeto cuya clase no sea Swift nativa, porque el runtime de Objective-C admite retain y release sobreescritos, contadores externos en tablas laterales y objetos etiquetados, y ninguna lectura del contador sería fiable. Si tu almacenamiento hereda de NSObject, el patrón se degrada a copiar siempre.

La tercera, y la que de verdad muerde en la práctica, son las referencias temporales. Cualquier construcción que retenga el objeto durante la llamada —una variable local intermedia, una captura en un closure, un valor todavía vivo en un registro por decisión del optimizador— eleva el contador y fuerza una copia. Es un false correcto según el contrato y desastroso para el rendimiento, y es el tema entero de la cuarta lección.

final class Almacen { var datos = [Int]() }

var a = Almacen()
let temporal = a                          // referencia extra viva
print(isKnownUniquelyReferenced(&a))      // false: correcto y caro

La asimetría tiene además una lectura de diseño de API que conviene notar. La función se llama is known uniquely referenced y no is uniquely referenced precisamente para que el nombre no prometa lo que no puede cumplir. Es la misma disciplina que gobierna los nombres de los tipos con prefijo Unsafe: la firma debe hacer visible la parte del contrato que suele olvidarse. Quien lea la llamada y se pregunte qué añade ese known habrá dado ya el paso más importante para no fiarse de un false.

⚖️

Conservadora por diseño

Puede decir false sobre un objeto exclusivo; nunca dirá true sobre uno compartido. El error posible cuesta rendimiento, nunca corrección.

🔐

El inout aporta exclusividad

No es una formalidad. Garantiza que nadie más accede a esa variable durante la llamada y obliga a que el argumento sea almacenamiento direccionable real.

🚫

Objective-C queda fuera

Para clases no nativas devuelve siempre false. Un almacenamiento heredado de NSObject convierte el copy-on-write en copy-always.

Qué hace el compilador alrededor

Sería un error imaginar que cada mutación de un Array ejecuta una lectura atómica del contador. El optimizador de Swift trata la comprobación de unicidad como un objeto de primera clase en su representación intermedia y la manipula agresivamente. La biblioteca estándar marca las operaciones relevantes con atributos semánticos internos, de modo que el optimizador reconoce el patrón sin necesidad de analizarlo: sabe que cierta llamada es una comprobación de unicidad y qué invariantes preserva.

En la representación intermedia del compilador la comprobación ni siquiera aparece como una llamada a función: existe una instrucción específica que marca el inicio de una mutación con copy-on-write y cuyo resultado es el booleano de unicidad, y otra que marca su final. Tenerlas como instrucciones y no como llamadas es lo que permite al optimizador razonar sobre ellas —moverlas, fusionarlas, eliminarlas— con la misma libertad con que razona sobre una suma, en lugar de tratarlas como una caja negra con efectos desconocidos.

Con esa información aplica al menos tres transformaciones. Iza la comprobación fuera de los bucles, de modo que un bucle que escribe un millón de posiciones la ejecuta una vez y no un millón. Elimina comprobaciones redundantes cuando puede probar que entre dos mutaciones consecutivas nadie pudo copiar la referencia. Y elimina pares retain/release que se anulan, reduciendo la probabilidad de que una referencia temporal envenene el resultado. En compilaciones de depuración casi nada de esto ocurre, lo que explica una observación frecuente: un algoritmo con arrays puede ser un orden de magnitud más lento sin optimizaciones, y no porque el código sea distinto, sino porque las comprobaciones y el tráfico de ARC quedan en su forma ingenua. Es también el motivo por el que ningún dato de rendimiento medido sin optimizaciones dice nada útil sobre este patrón.

var v = Array(repeating: 0, count: 1_000_000)
for i in v.indices {
    v[i] = i          // con -O: una comprobacion de unicidad, no un millon
}

Que el izado sea posible depende de una condición que el optimizador debe probar: que dentro del bucle nadie pueda crear una referencia nueva al búfer. Una llamada a una función opaca —de otro módulo, sin inlining, sin información de efectos— rompe esa prueba y devuelve la comprobación a su sitio, dentro del bucle. Es una de las razones por las que la optimización entre módulos importa tanto en Swift, y por las que un bucle idéntico puede rendir de forma distinta según esté en el mismo archivo que la función que llama o en otro paquete.

Existe además una función hermana, isKnownUniquelyReferenced aplicada al almacenamiento gestionado de ManagedBuffer, que es la vía canónica cuando quieres asignar cabecera y elementos en un solo bloque en lugar de encadenar una clase con un Array dentro. ManagedBuffer y ManagedBufferPointer son públicos precisamente para que puedas construir tipos con la misma forma que los de la biblioteca estándar: una sola asignación, cabecera y carga útil contiguas, y ninguna indirección de más. Es más trabajo y más código con punteros, y solo compensa cuando el número de asignaciones es lo que estás midiendo.

Hay un matiz que conviene retener para las lecciones siguientes: estas transformaciones funcionan sobre Array porque la biblioteca estándar coopera con el optimizador mediante atributos internos que no están disponibles para tu código. Tu tipo con copy-on-write manual sí ejecutará la comprobación en cada mutación, aunque el inlining y la eliminación de código redundante ayuden. Es una asimetría real y una de las razones para medir antes de asumir que replicar el patrón te sale gratis.

El contador como canal de información

Merece la pena detenerse en lo que ocurre aquí desde el punto de vista del diseño de sistemas. El contador de referencias es, en su origen, un mecanismo de gestión de memoria: existe para saber cuándo llamar a deinit. Copy-on-write lo reinterpreta como un canal de información sobre la topología del programa: la misma palabra que dice cuánto le queda de vida al objeto dice también cuántos observadores puede tener una escritura. Es una reutilización elegante y también una fuente inagotable de confusión, porque une dos preocupaciones que conceptualmente son independientes. Un objeto con contador dos puede tener sus dos referencias en el mismo hilo, en la misma función y a un microsegundo de distancia, y aun así fuerza una copia; un objeto con contador uno puede estar siendo observado por un weak y aun así permite mutar. El contador no mide compartición semántica, mide propiedad fuerte, y el patrón funciona porque en la práctica ambas cosas coinciden casi siempre. Ese casi es donde vive todo el dolor del rendimiento en Swift. Un lenguaje con tipos lineales —o con ~Copyable y consuming, que es adonde Swift ha ido a partir del nivel 21— no necesita preguntar nada en tiempo de ejecución, porque el sistema de tipos ya garantiza la unicidad de forma estática. La comprobación dinámica es el precio de no haber pedido esa garantía por adelantado, y la evolución reciente del lenguaje puede leerse entera como el intento de ofrecerte la alternativa estática sin retirar la dinámica.

📝
Lo esencial de esta lección

isKnownUniquelyReferenced lee el contador de referencias fuertes de un objeto de clase nativo pasado como inout. El inout garantiza acceso exclusivo y almacenamiento direccionable. La función es conservadora: nunca afirma unicidad falsa, pero la niega ante referencias weak inexistentes en el conteo, clases de Objective-C y referencias temporales. El optimizador iza y elimina comprobaciones en Array, pero tu tipo manual no recibe ese trato privilegiado.

⚔️ Interroga al contador
  1. Escribe una clase final y comprueba el valor de isKnownUniquelyReferenced antes y después de crear una segunda referencia fuerte, y de nuevo tras anularla.
  2. Repite el experimento con una referencia weak en lugar de fuerte y explica por qué el resultado no cambia.
  3. Haz que tu clase herede de NSObject en una plataforma Apple y observa que la respuesta pasa a ser siempre false. Razona qué consecuencia tiene eso para un tipo con copy-on-write.
  4. Introduce una variable local intermedia que retenga el objeto justo antes de la llamada y comprueba que fuerza un false. Elimínala y verifica que vuelve a true.
  5. Compila un bucle que escribe un millón de posiciones de un Array con y sin optimizaciones, mide ambos tiempos y atribuye la diferencia a la comprobación izada y al tráfico de ARC.