Sendable: el pasaporte de los tipos
Qué promete exactamente un protocolo sin requisitos, cómo decide el compilador la conformidad automática, por qué los tipos públicos quedan fuera de ella, y qué obligación de prueba asumes al escribir `@unchecked Sendable` o `nonisolated(unsafe)`.
Sendable es el protocolo más extraño de la biblioteca estándar: no declara ni una sola función, ni una propiedad, ni un tipo asociado. No aporta capacidades; aporta una afirmación. Decir que un tipo conforma a Sendable es declarar que cualquier valor suyo puede atravesar la frontera entre dos dominios de aislamiento sin que ese cruce introduzca un data race. Como toda afirmación en un sistema formal, o se demuestra o se toma como axioma, y Swift ofrece exactamente esas dos vías: la conformidad que el compilador verifica campo a campo y la que tú firmas con @unchecked. Entender qué se está afirmando —y sobre qué exactamente, porque no es sobre un valor sino sobre todos los valores presentes y futuros del tipo— es lo que separa usar el modo estricto de pelearse con él.
- Enunciar con precisión la promesa semántica que hace una conformidad a
Sendable. - Deducir las reglas de conformidad automática desde esa promesa, incluida la excepción de lo público.
- Verificar cuándo una clase o un tipo envuelto en un cerrojo merece
@unchecked Sendable. - Distinguir
@Sendablesobre funciones,sendingsobre parámetros ynonisolated(unsafe)sobre variables.
Qué promete un protocolo sin requisitos
Sendable es un protocolo marcador: existe solo en tiempo de compilación y no genera tabla de testigos ni información de tipo en ejecución. No puedes comprobarlo dinámicamente de forma fiable ni escribir una extensión que dependa de él para despachar. Su único efecto es autorizar o bloquear cruces de frontera durante la comprobación de tipos.
La promesa concreta depende de la naturaleza del tipo. Para un tipo de valor cuyos campos son todos seguros, el cruce copia el contenido y no queda estado compartido: la promesa es trivial. Para un tipo de referencia la copia es del puntero, no del objeto, así que el cruce sí produce compartición y la promesa exige una de dos cosas: inmutabilidad profunda, o sincronización interna que el compilador no puede ver.
struct Punto: Sendable { // trivial: dos valores copiados
let x: Double
let y: Double
}
final class Configuracion: Sendable { // el compilador lo verifica
let clave: String // final, sin estado mutable,
let intentos: Int // y todo campo es Sendable
}
Una conformidad cuantifica sobre todos los valores del tipo, para siempre. Por eso una sola propiedad mutable la invalida aunque en tu código nadie la escriba después de construir el objeto: la garantía debe sostenerse para cualquier uso posible, incluidos los que escribirá otra persona dentro de dos años. Esta cuantificación universal es exactamente lo que el análisis por regiones vendrá a relajar, permitiendo razonar sobre un valor concreto en un punto concreto del programa.
Conformidad automática y por qué lo público queda fuera
El compilador infiere la conformidad cuando puede demostrarla sin ayuda. Las reglas se deducen todas de la promesa anterior.
Un struct o un enum obtiene conformidad implícita si todas sus propiedades almacenadas —o todos los valores asociados de sus casos— son a su vez Sendable. Lo mismo vale para tuplas, metatipos y clausuras que solo capturan valores seguros. Los actores son siempre Sendable por definición, porque su estado está protegido por su ejecutor. Los tipos genéricos de la biblioteca estándar usan conformidad condicional: un array es seguro cuando su elemento lo es, y un opcional cuando lo es su valor envuelto.
La excepción importante es que la inferencia no se aplica a los tipos públicos. Un struct público con campos seguros no obtiene la conformidad gratis: debe declararla. La razón es de compatibilidad de API, no de seguridad. Si la inferencia atravesara la frontera del módulo, añadir mañana un campo no seguro a un tipo público retiraría en silencio una promesa de la que dependen todos los clientes. Al obligar a escribirla, Swift convierte esa promesa en una decisión consciente de quien publica la biblioteca.
struct Pedido { // interno: conformidad inferida sin escribir nada
let id: Int
var lineas: [String]
}
public struct Envio: Sendable { // publico: hay que declararla a mano
public let destino: String
}
// La biblioteca estandar la propaga por conformidad condicional
extension Array: Sendable where Element: Sendable { }
Las clases nunca la obtienen por inferencia, pero sí pueden declararla y ser verificadas: el compilador exige que la clase sea final, que no tenga propiedades almacenadas mutables, que todas sus propiedades sean seguras y que no herede de una superclase que rompa la garantía. Una clase aislada a un actor global es también segura, por una vía distinta: su estado no está desprotegido, está bajo un dominio.
flowchart TB
T[Necesito cruzar una frontera con este tipo] --> V{Es tipo de valor}
V -->|si| C{Todos sus campos son Sendable}
C -->|si| OK[Conformidad automatica o declarada]
C -->|no| R[Cambiar el campo culpable]
V -->|no| M{Es inmutable, final y sin herencia}
M -->|si| OK
M -->|no| S{Tiene sincronizacion interna real}
S -->|si| U[unchecked Sendable con prueba escrita]
S -->|no| A[Asignarle un dominio propietario]
style OK fill:#a6e3a1,color:#11111b
style U fill:#f9e2af,color:#11111b
style A fill:#89b4fa,color:#11111bCuando el compilador no puede demostrarlo
Hay tipos genuinamente seguros cuya seguridad es invisible para el análisis: una clase que protege su estado con un cerrojo, un envoltorio sobre un manejador de C documentado como reentrante, una caché con sincronización propia. Para ellos existe @unchecked Sendable, que no desactiva la seguridad sino la comprobación, trasladándote la carga de la demostración.
final class CacheSegura: @unchecked Sendable {
private let cerrojo = NSLock()
private var almacen: [String: Data] = [:]
func leer(_ clave: String) -> Data? {
cerrojo.lock(); defer { cerrojo.unlock() }
return almacen[clave]
}
func escribir(_ dato: Data, para clave: String) {
cerrojo.lock(); defer { cerrojo.unlock() }
almacen[clave] = dato
}
}
Esa conformidad es honesta si se cumplen tres condiciones, y deja de serlo en cuanto falla una. Primera: todo acceso al estado mutable pasa por el mismo mecanismo de sincronización, incluidos los de las extensiones y los de futuras subclases, que por eso conviene prohibir con final. Segunda: ninguna referencia al estado interno escapa hacia fuera; devolver el diccionario entero destruiría la garantía aunque la lectura estuviera protegida. Tercera: el estado no es alcanzable por ninguna otra vía, lo que exige que sea private y que el tipo no exponga métodos que devuelvan referencias mutables.
Cuando el problema es una sola variable y no un tipo entero, la herramienta proporcionada es más fina: nonisolated(unsafe) marca esa declaración concreta como exenta de la comprobación, sin mentir sobre el tipo. Es preferible a un @unchecked amplio porque acota el alcance del axioma y porque deja una huella localizable.
Si escribes @unchecked Sendable y no sabes nombrar el mecanismo exacto que serializa los accesos —un cerrojo concreto, una cola serial concreta, una garantía documentada de la biblioteca de C—, no estás afirmando nada: estás apagando un diagnóstico. El síntoma clásico es marcar un modelo mutable para poder pasarlo a una tarea. Ese caso no pide un axioma, pide un dueño: un actor, un actor global, o convertir el tipo en valor.
Funciones, capturas y envío
La marca también viaja sobre los tipos función. Un tipo @Sendable de función promete dos cosas: que puede llamarse desde cualquier dominio y que todo lo que captura es a su vez seguro. Es la razón de que capturar un objeto mutable dentro de una tarea produzca error, porque el cuerpo de una tarea es exactamente una clausura de esa clase.
func programar(_ trabajo: @Sendable @escaping () -> Void) { ... }
let modelo = ModeloMutable()
programar { modelo.actualizar() } // error: la captura no es Sendable
Desde Swift 6 la inferencia mejora notablemente: una clausura no escapante o una que solo captura valores seguros recibe la marca sola, y los key paths obtienen conformidad cuando sus componentes la tienen. Eso elimina buena parte de las anotaciones manuales que la primera generación de código concurrente exigía.
// La marca se infiere sola cuando la captura ya es segura
let limite = 10 // Int es Sendable
programar { procesar(hasta: limite) } // sin anotar nada: compila
// Y se puede transferir por region lo que el tipo no promete
func encolar(_ informe: sending Informe) { ... }
Conviene separar tres anotaciones que se confunden. Sendable es una propiedad del tipo: vale siempre y para todos sus valores. sending, sobre un parámetro o un resultado, es una propiedad del uso: declara que ese valor concreto se transfiere al otro lado y deja de ser usable aquí, aunque su tipo no sea seguro. Y nonisolated(unsafe), sobre una declaración, es una excepción que no promete nada y solo silencia la comprobación en un punto. La primera se demuestra, la segunda se verifica por flujo, la tercera se asume.
Inmutable gana siempre
Un tipo con solo constantes seguras cruza cualquier frontera sin coste ni discusión. La mayoría de los conflictos con el compilador se resuelven congelando lo que nunca debió ser mutable.
El cerrojo justifica el axioma
@unchecked solo es legítimo cuando puedes señalar el mecanismo que serializa cada acceso y demostrar que no hay ninguna vía alternativa al estado.
Enviar en vez de marcar
Si un valor no seguro solo necesita viajar una vez y no volver, sending resuelve el problema sin obligarte a mentir sobre el tipo entero.
Un protocolo sin requisitos parece una rareza sintáctica, pero es una de las ideas más profundas del diseño de Swift 6, y su linaje viene de lejos: son los marker traits que Rust usa para Send y Sync, los tipos fantasma de la programación funcional tipada, y en último término la vieja intuición de Curry y Howard de que un tipo puede ser una proposición y un valor su demostración. Aquí la proposición es “los valores de este tipo pueden cruzar fronteras de aislamiento sin crear carreras”, y el compilador la demuestra por inducción estructural: un agregado es seguro si sus partes lo son, y el caso base son los tipos primitivos e inmutables. Esa inducción es la razón de que la conformidad automática funcione sin que nadie escriba nada, y también la razón de que falle en cuanto aparece una referencia mutable, porque ahí la inducción no se sostiene: el campo no contiene el estado, apunta a él. Lo que hace @unchecked es introducir un axioma en ese sistema, y la lógica es implacable con los axiomas: un sistema de pruebas solo es tan sólido como el más débil de los suyos. Una única conformidad no comprobada y falsa no produce un error local, invalida la garantía de todo el programa que la use, directa o indirectamente, porque el compilador construirá encima de ella razonamientos que ya no se sostienen. De ahí sale la única política sensata: los axiomas deben ser poquísimos, estar concentrados en tipos minúsculos y auditables, llevar escrita su demostración informal al lado, y ser localizables con una búsqueda de texto. Y de ahí sale también por qué el análisis por regiones fue necesario: cuando la única herramienta es una proposición universal sobre el tipo, el sistema obliga a mentir en todos los casos donde la verdad era local, y un sistema que obliga a mentir se degrada solo.
Sendable es un protocolo marcador sin requisitos que afirma que todos los valores de un tipo pueden cruzar fronteras de aislamiento con seguridad. El compilador lo infiere para tipos de valor no públicos con campos seguros, lo exige explícito en los públicos por compatibilidad de API, y lo verifica en clases final sin estado mutable. @unchecked Sendable sustituye la prueba por un axioma tuyo y solo es honesto con sincronización interna real, encapsulación completa y ausencia de fugas del estado. nonisolated(unsafe) acota esa excepción a una sola declaración, y sending resuelve por flujo lo que la marca de tipo no puede expresar.
- Toma cinco tipos de un proyecto tuyo y clasifícalos en seguros por inmutabilidad, seguros por dominio, seguros por cerrojo e inseguros; justifica cada uno en una frase.
- Haz público un
structinterno que era seguro por inferencia y observa qué diagnóstico aparece; explica por qué la regla protege a los clientes. - Escribe una clase con cerrojo que sea genuinamente segura y después rómpela devolviendo una referencia a su estado interno sin cambiar la conformidad.
- Convierte un
@unchecked Sendablede tu código en un actor y compara qué desaparece del tipo y qué aparece en los puntos de llamada. - Declara una función que reciba una clausura sin la marca y otra con ella; intenta pasar ambas a una tarea y describe la diferencia en los mensajes.