Tipos no copiables: el valor con un solo dueño
Qué significa suprimir la conformidad implícita con `Copyable`, por qué es una supresión y no una conformidad nueva, qué operaciones desaparecen al hacerlo y qué invariantes de recurso se vuelven imposibles de violar.
Cada tipo que has escrito en Swift lleva una conformidad que nunca declaraste. Antes de la versión 5.9 esa conformidad no tenía nombre visible porque no había forma de renunciar a ella; hoy se llama Copyable, la tiene todo tipo por omisión, y la sintaxis con virgulilla sirve para quitarla. Ese gesto mínimo, escribir ~Copyable detrás del nombre de una estructura, cambia la naturaleza de sus valores: dejan de ser información duplicable y pasan a ser objetos únicos que se mueven de un sitio a otro sin dejar copia atrás. Con esa única supresión se vuelven expresables el descriptor que no se puede cerrar dos veces, el cerrojo que no se puede duplicar y el testigo que solo sirve una vez.
- Explicar por qué
~Copyablees una supresión de restricción y no una conformidad añadida. - Enumerar las operaciones que un valor no copiable pierde y la razón de cada pérdida.
- Leer y escribir código que mueve, presta y consume valores únicos sin violar el verificador.
- Reconocer los límites vigentes del modelo y las vías previstas para superarlos.
Una restricción que se quita, no una capacidad que se añade
El detalle de diseño que hay que interiorizar primero es la polaridad. Copyable no es un protocolo al que uno se apunta, sino una restricción implícita que el compilador aplica a todo tipo y a todo parámetro genérico. La virgulilla no significa que el tipo conforme a algo llamado no copiable: significa que la restricción por omisión queda levantada. Es la misma gramática que emplean las restricciones implícitas de escapabilidad y de disponibilidad de otras capacidades, y la razón de esa forma es la compatibilidad: cualquier otro reparto habría roto todo el código escrito hasta 2023.
struct Descriptor: ~Copyable {
private let fd: Int32
init(fd: Int32) { self.fd = fd }
func leer() -> Int { 0 }
}
let a = Descriptor(fd: 3)
let b = a // esto no copia: mueve. a queda invalido
// _ = a.leer() // ERROR: uso de un valor ya consumido
_ = b.leer()
La asignación no ha cambiado de comportamiento: sigue tomando el valor de la derecha e instalándolo a la izquierda. Lo que ha cambiado es que ahora eso agota el origen, porque no hay operación de duplicado disponible para dejarlo intacto. Toda la aritmética del modelo se sigue de ahí: pasar un argumento por omisión presta, asignar mueve, devolver mueve, y guardar en una propiedad exige haber recibido la propiedad antes.
La misma virgulilla vale para las enumeraciones, y ahí produce una construcción particularmente expresiva: una máquina de estados donde cada transición consume el estado anterior y devuelve el siguiente, de modo que el estado obsoleto deja de existir para el compilador en lugar de quedar disponible por descuido.
enum Peticion: ~Copyable {
case preparada(String)
case enviada(Int)
consuming func enviar() -> Peticion {
switch consume self {
case .preparada: return .enviada(200)
case .enviada(let c): return .enviada(c)
}
}
}
Es la codificación en el sistema de tipos de un protocolo de uso: enviar dos veces la misma petición no es un fallo que haya que detectar en ejecución, es un programa que no compila. Los lenguajes con tipos afines llaman a esto tipado por estados, y hasta 2023 en Swift solo podía imitarse con envoltorios de un solo uso vigilados por una bandera booleana.
Qué pierde un valor único
Las restricciones no son arbitrarias: cada una se deduce de la ausencia de copia.
Un valor no copiable no puede almacenarse en la mayoría de contenedores genéricos, porque un contenedor escrito sin la anotación de supresión exige Copyable en su parámetro. Las versiones recientes de la biblioteca estándar han ido levantando esa exigencia en las piezas fundamentales, empezando por Optional, Result y los punteros no seguros; las colecciones dinámicas de propósito general siguen siendo el caso pendiente más visible.
No puede envolverse en un existencial. Un valor de tipo any se guarda en una caja que puede copiarse, y esa caja es incompatible con la unicidad. Del mismo modo, un tipo no copiable solo puede conformar protocolos cuyos requisitos hayan sido declarados compatibles con la supresión.
No puede capturarse por un cierre que escapa ni cruzar a un contexto concurrente arbitrario, porque escapar significa duplicar la vía de acceso. Y no puede ser el tipo de una propiedad de clase mutable sin restricciones, porque las clases se comparten y el compartir reintroduce el problema por la puerta de atrás.
Para operar con estos valores hay una sintaxis dedicada de inspección que evita consumirlos al examinarlos: el switch sobre un valor prestado permite mirar dentro sin agotarlo, y existe la forma que consume, en la que cada rama recibe la propiedad de lo que había dentro.
enum Recurso: ~Copyable {
case abierto(Descriptor)
case cerrado
}
func describir(_ r: borrowing Recurso) -> String {
switch r { // inspeccion prestada: r sobrevive
case .abierto: return "abierto"
case .cerrado: return "cerrado"
}
}
Supresión, no conformidad
Todo tipo es Copyable salvo que lo niegues. La virgulilla levanta una restricción por omisión; en posición genérica, amplía el conjunto de tipos admitidos en lugar de reducirlo.
Mover es lo normal
Sin copia disponible, la asignación agota el origen. Prestar con borrowing es la forma de mirar sin gastar.
Fronteras vigentes
Nada de existenciales, nada de cierres que escapan y, por ahora, nada de colecciones dinámicas de propósito general.
Genéricos y el sentido inverso de la virgulilla
El punto que más confusión genera aparece al escribir código genérico. Cuando declaras un parámetro de tipo con la supresión, no estás exigiendo que el argumento sea no copiable: estás dejando de exigir que sea copiable. El conjunto de tipos aceptados crece.
// Solo acepta tipos copiables: la restriccion implicita sigue ahi
func guardar<T>(_ valor: T) { }
// Acepta ambos mundos: copiables y no copiables
func consumir<T: ~Copyable>(_ valor: consuming T) { }
Dentro del cuerpo de la segunda función el compilador debe ser conservador y tratar a T como si no fuera copiable, porque podría no serlo. Ese es el precio del ensanchamiento, y es también la explicación de por qué la biblioteca estándar tardó una versión entera en adoptar la anotación: cada firma revisada exige demostrar que su implementación jamás dependía de la copia. Es un trabajo de auditoría, no de escritura.
flowchart TB t[Todo tipo declarado en Swift] --> c[Restriccion implicita Copyable] c --> op1[Asignar duplica] c --> op2[Cabe en any y en colecciones] t --> s[Escribes la supresion con virgulilla] s --> m[Asignar mueve y agota el origen] s --> d[Puede declarar deinit propio] s --> l[Sin existenciales ni cierres que escapan] m --> u[Invariante de dueno unico verificado en compilacion]
Si un tipo tiene un método llamado cerrar, liberar, finalizar o cancelar cuya documentación advierte de llamarlo una sola vez y no usar el valor después, tienes delante un tipo no copiable disfrazado. Esa advertencia en prosa es exactamente lo que la supresión convierte en un error de compilación.
Dónde ya vive esto en la práctica
Los ejemplos más instructivos no están en los tutoriales sino en la biblioteca estándar. Las primitivas de sincronización modernas son no copiables por construcción, y no por rendimiento: un cerrojo duplicado no protege nada, y un valor atómico copiado deja de ser el punto de sincronización que dos hilos creen compartir. El error que antes exigía documentación y revisión de código ahora lo rechaza el compilador.
La segunda familia son las vistas sobre memoria ajena: tipos que ofrecen acceso seguro a un búfer sin poseerlo, cuya validez está atada a la vida del propietario. Requieren además la otra supresión del modelo, la de escapabilidad, porque el problema no es solo que no deban copiarse sino que no deben sobrevivir a lo que describen. La combinación de ambas supresiones es lo que permite ofrecer acceso a memoria en bruto con verificación estática y sin coste en ejecución.
La tercera es el propio código de sistemas: envoltorios de descriptores, manejadores de dispositivos, arenas de memoria y regiones asignadas a mano. Swift en contextos empotrados, sin montículo disponible ni recuento de referencias, encuentra aquí la pieza que le faltaba para modelar recursos sin recurrir a una clase.
Y hay una cuarta familia, menos citada y muy instructiva: los testigos de capacidad. Un valor que existe únicamente para demostrar que cierta operación ya ocurrió y que solo puede canjearse una vez, como una reserva confirmada o el permiso para reanudar una operación suspendida. Ahí la unicidad no protege memoria ni recursos del sistema operativo, protege la lógica del dominio, y el compilador la hace cumplir con el mismo rigor.
Merece la pena entender por qué la ausencia de copia habilita tanto, porque no es evidente que quitar una capacidad amplíe lo que se puede decir. La clave es que la copia destruye información. Mientras un valor sea duplicable, el compilador no puede saber cuántas vías de acceso a un recurso existen en un punto del programa: cualquier función pudo guardarse una, cualquier asignación pudo dejar otra, y ese desconocimiento obliga a razonar de forma conservadora sobre todo lo demás. La liberación no puede ser determinista porque no se sabe quién queda. La mutación no puede ser libre porque no se sabe quién observa. La transferencia entre hilos no puede ser segura porque no se sabe quién retiene. Al suprimir la copia se restaura una propiedad que los sistemas de tipos lineales conocen desde los años ochenta: en cada instante hay exactamente una vía de acceso a ese valor, y esa afirmación es de una potencia desproporcionada respecto a su modestia. De ella se deducen la liberación exactamente una vez, la mutación sin comprobar unicidad, la transferencia sin sincronización y la eliminación de todo recuento. Por eso ~Copyable no es una restricción incómoda que se acepta a cambio de velocidad, sino un aumento genuino del poder expresivo del sistema de tipos: renuncias a duplicar y recibes a cambio la capacidad de afirmar cosas sobre el futuro del programa que antes solo cabían en un comentario y que nadie podía verificar.
~Copyable suprime una restricción implícita que llevan todos los tipos. Sin copia, la asignación mueve y agota el origen, prestar es la forma de inspeccionar, y consumir es la de transferir. A cambio se pierden existenciales, cierres que escapan y, por ahora, las colecciones dinámicas generales. En posición genérica la virgulilla amplía el conjunto de tipos aceptados. Su valor no es evitar copias caras, sino hacer imposible violar un invariante de dueño único.
- Escribe una estructura no copiable que envuelva un descriptor y comprueba qué diagnóstico produce intentar asignarla a dos variables.
- Convierte una máquina de estados basada en un
enumcopiable en una versión no copiable donde cada transición consuma el estado anterior. - Intenta guardar un valor no copiable en un array y clasifica el error obtenido; después consíguelo dentro de un
Optional. - Declara una función genérica con la supresión y otra sin ella, y determina experimentalmente cuál acepta más tipos.
- Localiza en la biblioteca de sincronización un tipo no copiable y explica qué error de concurrencia deja de ser posible gracias a esa declaración.