El modelo mental completo: una sola imagen de todo Swift
Tipos y valores, abstracción genérica, memoria y concurrencia no son cuatro temas del lenguaje: son cuatro escalas de la misma pregunta. Quién puede observar una mutación, y cómo se demuestra la respuesta antes de ejecutar. Esta lección cose el track entero en un único diagrama mental.
Has recorrido treinta y ocho niveles de piezas: semántica de valor, opcionales, closures, enums, protocolos, genéricos, some y any, property wrappers, result builders, macros, errores, ARC, copy-on-write, ownership, punteros, interoperabilidad, Sendable, actores, secuencias asíncronas, tareas estructuradas, actores distribuidos. Presentadas así parecen un catálogo, y un catálogo se olvida. No lo son. Swift tiene una idea rectora, y una sola, que aparece disfrazada en cada uno de esos capítulos: toda mutación debe tener un conjunto de observadores conocido en tiempo de compilación. La semántica de valor es esa idea aplicada a un objeto; la ley de exclusividad, aplicada a un instante; el aislamiento de actores, aplicada al tiempo y a los hilos; los genéricos, aplicados a través de una frontera de abstracción. Cuando ves eso, el lenguaje deja de ser una lista de reglas y se convierte en un teorema con cuatro corolarios.
- Formular el principio único del que se derivan la semántica de valor, la exclusividad, el aislamiento y el ownership.
- Situar cada mecanismo del track en la escala que le corresponde y explicar qué escala protege.
- Leer un tipo real y nombrar, línea por línea, qué garantía sostiene cada decisión.
- Identificar las cuatro costuras donde el modelo se rompe y qué obligación te transfiere el compilador en cada una.
El principio y sus cuatro escalas
Un programa incorrecto casi siempre lo es por la misma razón: algo cambió y alguien lo vio cambiar sin esperarlo. El resto son variantes. Un array que se modifica mientras se itera, un objeto compartido que otro módulo mutó, un dato que dos hilos escriben a la vez, un puntero cuyo dueño ya lo liberó. Swift no ataca esos bugs uno a uno: ataca la condición que los hace posibles, y lo hace en cuatro escalas anidadas.
En la escala del instante, la ley de exclusividad prohíbe que dos accesos a la misma variable se solapen si al menos uno escribe. Por eso inout es copy-in copy-out y por eso no puedes pasar la misma propiedad dos veces al mismo swap. En la escala del objeto, la semántica de valor garantiza que el conjunto de observadores de una mutación tiene exactamente un elemento: tú. Copy-on-write hace que esa garantía sea barata en lugar de ruinosa. En la escala del tiempo, el aislamiento de actores extiende la exclusividad a los accesos que no se solapan en el código pero sí en la ejecución: Sendable marca qué puede cruzar y await marca dónde otro pudo colarse. Y en la escala de la abstracción, los genéricos y los protocolos transportan las tres garantías anteriores a través de una frontera donde el tipo concreto se desconoce.
flowchart TD P[Principio: toda mutacion tiene observadores conocidos] P --> I[Escala instante: ley de exclusividad e inout] P --> O[Escala objeto: semantica de valor y COW] P --> T[Escala tiempo: Sendable actores aislamiento] P --> A[Escala abstraccion: genericos protocolos ownership] I --> V[Verificado por el compilador o por el runtime] O --> V T --> V A --> V style P fill:#cba6f7,color:#11111b style V fill:#a6e3a1,color:#11111b
La palabra que une el diagrama es verificado. Swift no te pide disciplina: te pide que escribas lo suficiente para que la disciplina sea comprobable. Sendable no hace que un tipo sea seguro, declara que lo es y obliga al compilador a revisarlo. borrowing no acelera nada por sí solo, promete que no consumirás el valor y permite al optimizador eliminar el retain. Esa es la mecánica constante del lenguaje: una anotación que suena a promesa y un verificador que la cobra.
Que las cuatro escalas son la misma se ve mejor con dos programas casi idénticos que fallan por la misma razón a distinta velocidad.
// Escala instante: dos accesos exclusivos solapados. Error de compilacion.
var puntos = [1, 2, 3]
// swap(&puntos[0], &puntos[0])
// Escala tiempo: dos accesos exclusivos que no se solapan en el texto
// pero si en la ejecucion. En modo estricto, tambien error de compilacion.
final class Contador { var n = 0 }
let c = Contador()
// Task { c.n += 1 }
// Task { c.n += 1 }
El primer caso lo detecta la ley de exclusividad; el segundo, la comprobación de Sendable. Son verificadores distintos, con mensajes distintos y con años de diferencia en su llegada al lenguaje, pero comprueban exactamente la misma propiedad: que no haya dos escrituras cuyo orden nadie determinó. Cuando entiendes eso, los errores de concurrencia dejan de parecer una categoría aparte y se leen como lo que son, exclusividad extendida al eje del tiempo.
Un tipo que toca las cuatro escalas a la vez
La forma más rápida de comprobar que el modelo está integrado es escribir un tipo pequeño donde las cuatro escalas aparecen en veinte líneas. Una matriz densa sirve: necesita almacenamiento contiguo por rendimiento, semántica de valor por corrección, ARC para gestionar el buffer y Sendable para poder viajar.
struct Matriz: Sendable {
private final class Almacen {
let datos: UnsafeMutableBufferPointer<Double>
init(capacidad: Int) {
datos = .allocate(capacity: capacidad)
datos.initialize(repeating: 0)
}
init(copiando otro: Almacen) {
datos = .allocate(capacity: otro.datos.count)
_ = datos.initialize(from: otro.datos)
}
deinit { datos.deinitialize(); datos.deallocate() }
}
private var almacen: Almacen
let filas: Int
let columnas: Int
init(filas: Int, columnas: Int) {
self.filas = filas
self.columnas = columnas
self.almacen = Almacen(capacidad: filas * columnas)
}
subscript(f: Int, c: Int) -> Double {
get { almacen.datos[f * columnas + c] }
set {
if !isKnownUniquelyReferenced(&almacen) {
almacen = Almacen(copiando: almacen)
}
almacen.datos[f * columnas + c] = newValue
}
}
}
Lee ahora cada decisión como una respuesta a una escala distinta. struct externo: escala objeto, el usuario obtiene semántica de valor y nadie puede alias su matriz por accidente. final class interno: escala memoria, hace falta una identidad para que varias copias compartan un buffer y para que ARC decida cuándo liberarlo; final además elimina el despacho dinámico. isKnownUniquelyReferenced: la bisagra exacta entre las dos escalas anteriores, el punto donde el conteo de referencias se usa para simular que cada copia tiene su propio almacén. El deinit que desinicializa y libera: escala instante, el buffer no tiene dueño automático y el deinit es el único lugar donde el ciclo de vida se cierra. Y Sendable en el struct: escala tiempo, la promesa de que enviar esta matriz a otro dominio de aislamiento no crea una carrera, promesa que es cierta precisamente porque el copy-on-write garantiza que nadie más escribirá en el mismo buffer.
Si quitas cualquiera de las cuatro decisiones, otra de las escalas se rompe. Sin isKnownUniquelyReferenced, Sendable sería mentira. Sin final class, el deinit no existiría y el buffer se filtraría. Sin struct externo, la semántica de valor desaparecería. Un buen tipo en Swift es un sistema de garantías que se sostienen entre sí, no una suma de features.
Las cuatro costuras donde el modelo se rompe
Ningún sistema de tipos es total, y el valor de conocer el modelo está sobre todo en saber dónde deja de aplicarse. Swift tiene cuatro costuras declaradas, y en las cuatro la estrategia es idéntica: el compilador se retira, lo dice en voz alta con una palabra fea, y te transfiere la obligación de prueba.
Unsafe
UnsafePointer y su familia. El compilador deja de razonar sobre vida útil y aliasing. Tú demuestras que el puntero es válido durante todo el ámbito, y solo durante él.
unchecked
@unchecked Sendable. Declaras que el tipo es seguro por un argumento que el compilador no puede reconstruir: un lock, un buffer inmutable, una cola serial. La prueba vive en un comentario, no en el tipo.
La frontera C y Objective-C
Un tipo importado no trae garantías. La nulabilidad, la propiedad del puntero y la seguridad ante hilos son afirmaciones del encabezado, no hechos verificados.
El await como grieta
En cada await el actor se puede reentrar. El aislamiento protege la exclusividad instantánea, no la invariante que abarca dos instantes.
Estas cuatro costuras explican por qué el track dedicó niveles enteros a punteros, interoperabilidad, reentrada y migración de código heredado. No eran apéndices: son el borde del teorema, y todo bug serio que escribirás en Swift vivirá en uno de esos bordes o en la unión de dos.
La costura más traicionera es la cuarta, porque no tiene palabra fea que la señale. Un await parece una espera y es una cesión de turno: entre la suspensión y la reanudación, otras tareas entran al actor y el estado que leíste antes puede haber cambiado.
actor Inventario {
private var existencias: [String: Int] = [:]
func reservar(_ sku: String) async throws {
guard let n = existencias[sku], n > 0 else { throw Falta.agotado }
try await registrarEnRemoto(sku) // aqui otro puede entrar y vaciar el stock
existencias[sku] = n - 1 // decrementa con un valor caducado
}
}
El aislamiento hizo su trabajo: no hay carrera de datos, ninguna escritura se solapa y el compilador está en silencio. Lo que se rompió es una invariante que abarcaba dos instantes, y esa clase de corrección nunca fue competencia del sistema de tipos. El modelo mental completo incluye saber exactamente eso: dónde termina la garantía mecánica y empieza tu obligación de razonar.
Usar el modelo para decidir
Un modelo mental solo vale si produce decisiones. Este produce una, y se toma con cuatro preguntas en orden estricto.
Primera: ¿este dato tiene identidad? Es decir, ¿dos instancias con los mismos campos son la misma cosa o cosas distintas? Un usuario con el mismo nombre y el mismo correo que otro es el mismo usuario si comparten identificador; una coordenada con los mismos números es indistinguible de la otra. Identidad significa class o actor; ausencia de identidad significa struct o enum, y esta pregunta se responde antes que ninguna otra porque condiciona el resto.
Segunda: ¿el estado se toca desde más de un dominio de aislamiento? Si la respuesta es no, un tipo de valor Sendable o un tipo confinado al actor principal basta. Si es sí y hay identidad, la respuesta natural es actor; si es sí y no hay identidad, basta con que el tipo sea Sendable de verdad y no por decreto.
Tercera: ¿la copia es aceptable? Si el tipo almacena mucho o su copia tiene efectos, la respuesta es copy-on-write con almacenamiento interno de referencia, tal como la Matriz de arriba. Si el tipo representa un recurso que no debe duplicarse jamás —un descriptor de fichero, una transacción, una clave criptográfica— la respuesta es ~Copyable, y entonces el compilador impide la copia en vez de hacerla barata.
Cuarta: ¿quién debe verlo a través de la frontera de abstracción? Un tipo concreto no oculta nada, some Protocolo oculta la identidad del tipo pero conserva su estaticidad y el rendimiento, any Protocolo la borra por completo a cambio de despacho dinámico y una caja. La regla operativa es empezar por lo más estático que resuelva el problema y bajar solo cuando haga falta heterogeneidad real en tiempo de ejecución.
Las cuatro preguntas se responden en ese orden porque cada una restringe el espacio de la siguiente. Elegir primero el rendimiento y después la semántica es la ruta directa a un tipo que va rápido y es incorrecto, y a una reescritura seis meses más tarde.
Merece la pena entender que la imagen que acabas de construir no es la unica posible, y que Swift eligio su version por razones que se pueden nombrar. Hay tres familias de respuesta al problema de la mutacion observada. La primera es la inmutabilidad total, la via de Haskell, Clojure o Elm: si nada muta, no hay observadores que proteger, y el problema desaparece por construccion. Su precio es que toda actualizacion de estructura pasa por estructuras persistentes y por una capa de indireccion, y que el codigo de sistema, el que toca hardware y buffers, se vuelve un ciudadano de segunda. La segunda familia es el ownership estatico exclusivo, la via de Rust: la mutacion se permite pero el compilador rastrea un unico dueno y un sistema de prestamos con vidas explicitas; el resultado es maximo control y coste cero, y el precio es que el sistema de tipos entra en cada firma y cada estructura de datos, y que patrones perfectamente correctos deben reescribirse para satisfacerlo. La tercera es la que Swift eligio: semantica de valor por defecto con identidad opcional, es decir, mutabilidad libre dentro de una region cuya frontera es la copia. Que la copia sea barata no es un detalle de implementacion sino la condicion que hace viable toda la estrategia; por eso copy-on-write no es una optimizacion opcional de la stdlib sino el pilar sin el cual el modelo entero seria inaceptablemente lento, y por eso isKnownUniquelyReferenced es publico y documentado en lugar de un truco interno. La consecuencia mas profunda es de gradualidad: como el coste se paga en runtime y no en el sistema de tipos, un principiante puede escribir codigo correcto sin escribir una sola anotacion de ownership, y solo quien necesita el ultimo diez por ciento de rendimiento baja a borrowing, consuming, ~Copyable y Span. Swift apuesta a que la seguridad debe ser el camino por defecto y el control el camino explicito, mientras que Rust apuesta a lo contrario y Haskell niega la disyuntiva. Ninguna es gratis: la de Swift paga con retain y release que a veces no puedes eliminar, con un modelo de concurrencia que llego diez anos despues del lenguaje y tuvo que encajarse hacia atras, y con la existencia misma de las cuatro costuras. Reconocer esa apuesta es lo que separa a quien usa Swift de quien lo entiende, porque a partir de ahi cada feature nueva que llegue se lee como un movimiento dentro de una estrategia conocida, y no como una novedad suelta que hay que memorizar.
Una idea, cuatro escalas: instante, objeto, tiempo y abstracción. Cada mecanismo del lenguaje pertenece a una escala y protege el mismo invariante. Las cuatro costuras (unsafe, unchecked, la frontera C y el await) son donde el compilador te devuelve la carga de la prueba.
- Toma diez features del track que aún no aparezcan en esta lección y asigna cada una a una de las cuatro escalas. Justifica en una frase qué invariante protege.
- Modifica la
Matrizpara que también funcione con elementos~Copyableo razona por escrito por qué no puede, y qué garantía concreta lo impide. - Escribe una versión de
Matrizmarcada@unchecked Sendableque sea realmente incorrecta y otra que sea realmente correcta. Redacta el comentario de prueba de la segunda. - Localiza en un proyecto tuyo un bug pasado y clasifícalo en una de las cuatro costuras. Si no encaja en ninguna, has encontrado un caso interesante: descríbelo.
- Dibuja tu propia versión del diagrama sin mirar la de arriba, y compara. Las diferencias son exactamente tus huecos.