wandres.dev
OWNERSHIP · borrowing y consuming

Por qué ownership en Swift: el precio de la copia implícita

El coste real de retain y release, la copia que el compilador inserta sin dejar rastro en el código fuente, y los invariantes de recurso que ningún tipo copiable puede expresar. La motivación completa del modelo de propiedad explícita.

⏱ 18 min

Swift tomó hace más de una década un compromiso que definió su carácter: los tipos de valor se copian, los de referencia se cuentan, y el compilador inserta ambas operaciones donde hagan falta sin pedir permiso ni dejar huella en el código fuente. El resultado es un lenguaje en el que casi nunca hay que pensar en la memoria y en el que, precisamente por eso, hay tres cosas que no se pueden decir. No se puede declarar que un valor tiene un dueño único. No se puede exigir que un recurso se libere exactamente una vez. No se puede prohibir una copia que costaría una asignación en el montículo dentro de un bucle caliente. Este nivel trata de las herramientas con las que Swift, desde la versión 5.9, permite decir esas tres cosas, y de por qué tardó nueve años en permitirlas.

🎯 Al terminar esta lección sabrás
  • Cuantificar el coste de retain y release separando el precio de la instrucción del coste de oportunidad.
  • Explicar por qué el optimizador no puede eliminar todos los pares de recuento aunque quiera.
  • Identificar los invariantes de recurso que un tipo copiable es incapaz de expresar.
  • Situar borrowing, consuming y ~Copyable como exposición de un modelo interno que ya existía.

La factura que ARC no te enseña

El recuento automático de referencias es determinista y no tiene pausas, y por eso se presenta a menudo como gratis frente a un recolector de basura. No lo es. Cada retain y cada release son, en el caso general, operaciones atómicas de lectura, modificación y escritura sobre una palabra compartida entre todos los núcleos que puedan ver el objeto. Un incremento atómico sin contención cuesta unas pocas decenas de ciclos; con dos hilos tocando la misma línea de caché, la factura se dispara por el protocolo de coherencia. Y esas operaciones no aparecen en tu código: las escribe el compilador.

Hay un coste mayor y menos visible: la barrera de optimización. Una llamada a la función de liberación puede, en principio, ejecutar el destructor de un objeto, y un destructor puede hacer cualquier cosa, incluida la mutación de estado global. El optimizador debe tratar esos puntos como efectos observables, de modo que un simple release en mitad de un bucle impide reordenar cargas, promover una propiedad a registro o vectorizar el cuerpo. El precio no es la instrucción: es todo lo que la instrucción prohíbe.

final class Nodo { var carga: [UInt8] = [] }

func procesar(_ lista: [Nodo]) -> Int {
    var total = 0
    for n in lista {          // leer un elemento produce un valor con propiedad
        total += n.carga.count
    }
    return total
}

Swift tiene pases de optimización dedicados a eliminar pares redundantes, y en un fragmento como este, compilado con optimización y con todo el código a la vista, suelen desaparecer. Suelen. El análisis se rinde en cuanto aparece una llamada opaca: un método de una clase no final, una función de otro módulo compilado por separado, un cierre que escapa, un protocolo con despacho dinámico. Ante lo opaco, el compilador debe ser conservador y conservar el recuento, porque la alternativa es liberar un objeto que alguien todavía usa.

La misma lógica alcanza a los tipos de valor. Las colecciones estándar usan copia al escribir, y toda mutación paga una comprobación de unicidad; si el búfer resulta compartido, la mutación asigna memoria y copia el contenido entero. Una copia accidental sostenida por una variable olvidada convierte una operación de coste constante en una lineal, y nada en el código fuente lo anuncia.

func acumular(_ base: [Int]) -> [Int] {
    var salida = base          // O(1): el bufer queda compartido
    for x in base {
        salida.append(x * 2)   // la primera iteracion duplica el bufer entero
    }
    return salida
}

El fragmento parece inocente y esconde una asignación en el montículo más una copia lineal, ambas invisibles en el sitio donde ocurren. Y lo peor no es el coste: es que ningún elemento del texto delata su existencia. Ni un signo, ni una palabra clave, ni una advertencia. La ergonomía que hace agradable escribir Swift es exactamente la que impide auditarlo.

Lo que un tipo copiable no puede prometer

El argumento de rendimiento es el fácil de explicar y el menos importante. El decisivo es de expresividad: la copia implícita hace imposible modelar un recurso con dueño único.

struct Descriptor {
    let fd: Int32
    func cerrar() { /* close(fd) */ }
}

let a = Descriptor(fd: 3)
let b = a            // copia silenciosa: dos duenos del mismo recurso
a.cerrar()
b.cerrar()           // doble cierre sobre un descriptor ya reasignado

El error no está en cerrar, sino en la línea que copia, y esa línea no contiene ninguna llamada, ningún operador sospechoso y ningún indicio de que algo relevante haya ocurrido.

La lista de invariantes que la copia implícita destruye es más larga de lo que parece a primera vista. Está la liberación exactamente una vez, que es el caso del descriptor. Está la exclusión mutua, porque un cerrojo duplicado protege dos regiones distintas y ninguna de las dos es la que el programador creía. Está el uso único, el de un testigo de autenticación o una continuación de una operación asíncrona que solo puede reanudarse una vez. Y está el protocolo con estados, donde cada operación debe suceder a la anterior y ninguna puede repetirse. Un descriptor de fichero, un bloqueo tomado, un búfer con propiedad, un manejador de la GPU, un testigo de un solo uso: todos son entidades cuya duplicación carece de sentido, y en un lenguaje donde todo es copiable la única defensa disponible es la documentación. La alternativa clásica, envolver el recurso en una clase para que el destructor lo libere, funciona a cambio de una asignación en el montículo, tráfico de ARC y una liberación cuyo momento exacto depende de quién retuvo la referencia y hasta cuándo.

⚙️

Coste medible

Operaciones atómicas, tráfico de coherencia entre núcleos, tamaño de código y comprobaciones de unicidad en cada mutación de una colección compartida.

🚧

Coste de oportunidad

Cada punto de liberación es una barrera para el optimizador. Lo que no se puede reordenar ni promover a registro no aparece en ningún perfil como una línea propia.

🔒

Coste semántico

Sin dueño único no hay liberación exactamente una vez, y sin eso el uso indebido de un recurso solo se detecta en ejecución, si se detecta.

El modelo ya vivía dentro del compilador

Aquí está el detalle que reordena la perspectiva: Swift no carecía de un modelo de propiedad. Lo tenía desde el principio, completo y riguroso, solo que enterrado en su representación intermedia. Cada argumento de función viaja en SIL con una convención declarada. La convención por omisión de un parámetro normal es garantizada: el llamante mantiene el valor vivo durante la llamada y el llamado no retiene nada. La de un parámetro de inicializador o del valor entrante de un asignador es propietaria: el llamado recibe la responsabilidad de destruirlo. Existen además formas explícitas de transferencia y de préstamo mutable, que es justamente lo que inout significa.

Esta observación tiene una consecuencia práctica inmediata: cuando escribes un modificador de propiedad no le estás enseñando al compilador un concepto nuevo, le estás quitando una decisión. Antes elegía por heurística y de forma conservadora, porque no tenía manera de saber si el llamante seguiría usando el valor; ahora se lo dices tú y él verifica que lo dicho se cumple. El cambio de régimen es de suposición a comprobación, y por eso la primera experiencia con estas herramientas casi siempre consiste en descubrir errores propios en código que llevaba años funcionando por casualidad.

Las etiquetas subrayadas que circularon durante años por el código de la biblioteca estándar eran la versión oficiosa de estas convenciones, y el manifiesto de propiedad publicado en 2017 ya describía el destino completo. Lo que llegó con Swift 5.9 y se ensanchó en Swift 6 no fue un modelo nuevo: fue el permiso para nombrar en el código fuente lo que SILGen llevaba una década decidiendo por su cuenta.

flowchart TB
fuente[Codigo fuente sin anotaciones] --> silgen[SILGen elige una convencion]
silgen --> g[Garantizada o prestamo]
silgen --> o[Propietaria o transferencia]
g --> opt[Pases de ARC intentan borrar pares]
o --> opt
opt --> op1[Si todo es visible el par desaparece]
opt --> op2[Si hay una llamada opaca el par se conserva]
fuente --> ann[Con borrowing y consuming la eleccion es tuya]
ann --> verif[El compilador verifica en lugar de suponer]
El producto no es la velocidad, es la garantía

Es tentador leer todo este nivel como una colección de trucos de rendimiento, y esa lectura desperdicia lo esencial. Un modelo de propiedad explícito no es un acelerador: es un sistema de tipos capaz de expresar afirmaciones que antes solo cabían en un comentario. Cuando escribes que un parámetro es consuming, no estás pidiendo una optimización, estás declarando que el llamante pierde el derecho a usar el valor después de la llamada, y esa declaración se convierte en un error de compilación para quien la incumpla. Cuando declaras un tipo ~Copyable, no estás evitando una copia costosa, estás afirmando que la duplicación de ese valor carece de sentido semántico. La diferencia importa porque decide cuándo usar estas herramientas: no cuando el perfilador señale un retain, sino cuando el dominio contenga un recurso con dueño único, un protocolo de uso con estados, un contrato que hoy solo vive en la documentación. El rendimiento llega de propina, y llega porque una vez que el compilador conoce la vida de un valor puede dejar de ser conservador. Ese es el orden correcto de causalidad: primero la verdad expresada en el tipo, después las optimizaciones que esa verdad habilita. Invertirlo produce código anotado hasta la ilegibilidad que gana un dos por ciento y pierde cualquier posibilidad de mantenimiento.

Dónde este nivel se aplica de verdad

La versión anotada del descriptor del apartado anterior muestra en cuatro líneas todo lo que este nivel desarrolla, y conviene tenerla delante como destino del recorrido:

struct Descriptor: ~Copyable {
    private let fd: Int32
    consuming func cerrar() { /* close(fd); */ discard self }
    deinit { /* close(fd) */ }
}

let a = Descriptor(fd: 3)
// let b = a   // ERROR: no hay copia posible
a.cerrar()     // consume el valor; una segunda llamada no compila

Los dominios que justifican bajar aquí son reconocibles. Recursos del sistema con liberación exactamente una vez. Rutas calientes donde una medición, no una intuición, muestra que el recuento domina el tiempo. Contextos sin asignación dinámica, incluido Swift para sistemas empotrados, donde una clase simplemente no es una opción. Primitivas de concurrencia como los cerrojos y los valores atómicos de la biblioteca de sincronización, que son no copiables por construcción porque copiar un cerrojo es un error categórico. Interoperabilidad con C y con Rust, donde la propiedad de un puntero cruza la frontera y conviene que el sistema de tipos la siga.

Y fuera de esos dominios, la recomendación es la contraria. El tipo copiable con semántica de valor y copia al escribir sigue siendo la elección correcta para la mayor parte del código, porque su coste es pequeño, su lectura es directa y no obliga a nadie a razonar sobre transferencias.

Conviene además ordenar el propio nivel antes de recorrerlo, porque las cuatro piezas encajan en una secuencia y no en una lista. Primero las convenciones de paso de argumentos, que responden a quién queda a cargo de destruir un valor al cruzar una llamada. Después la supresión de la copia, que convierte esa pregunta en obligatoria porque elimina la salida de duplicar. Luego el destructor de valor, que es lo que la unicidad hace posible y lo que da sentido práctico a todo lo anterior. Y al final la comparación con el lenguaje que llevó estas ideas más lejos, que sirve para medir con precisión hasta dónde llega Swift y dónde decidió detenerse.

ℹ️
Progresión, no sustitución

Ninguna de las herramientas de este nivel deroga lo aprendido antes. La semántica de valor, la copia al escribir y las clases con recuento siguen siendo el vocabulario principal del lenguaje, y el modelo de propiedad es un registro adicional que se activa donde hace falta. Un proyecto sano usa los dos, con una frontera consciente entre ellos, y esa frontera casi siempre coincide con el borde entre el dominio de la aplicación y la capa que toca recursos del sistema.

⚠️
La trampa del microbenchmark

Un banco de pruebas aislado casi siempre exagera la ganancia de estas anotaciones: al tener todo el código visible, el optimizador ya había eliminado los recuentos que el experimento pretendía ahorrar, o los elimina también en la versión anotada. Mide en el binario real, con optimización entre módulos activada o desactivada según se compile en producción, y compara perfiles completos.

📝
Lo esencial de la motivación

ARC cuesta operaciones atómicas y, sobre todo, oportunidades de optimización que se pierden en cada punto opaco. La copia implícita hace imposible modelar un recurso con dueño único, y la solución tradicional a base de clases paga montículo y liberación no determinista. Swift ya tenía convenciones de propiedad en SIL desde el primer día; borrowing, consuming y ~Copyable son la superficie de fuente de ese modelo, y sirven para expresar garantías antes que para ganar ciclos.

⚔️ Mide antes de creer
  1. Escribe una función que recorra un array de instancias de una clase no final y compara el ensamblador generado con y sin la palabra final.
  2. Repite la medición cruzando una frontera de módulo, primero sin optimización entre módulos y luego con ella activada.
  3. Construye un caso donde una variable olvidada mantenga compartido el búfer de un array y provoque una copia en cada mutación de un bucle.
  4. Enumera tres tipos de tu proyecto cuya copia carece de sentido semántico y describe qué invariante protege hoy la documentación.
  5. Localiza en la biblioteca estándar dos tipos declarados no copiables y argumenta en cada caso qué error de programación se vuelve imposible.