Aislamiento por regiones: enviar lo que no es Sendable
El compilador particiona los valores locales en regiones que pueden aliasarse entre sí y permite transferir las que están desconectadas. Qué es una región, cómo se fusionan, qué declara `sending` en una firma y cómo se leen los errores de envío.
Hasta Swift 6, la regla para cruzar una frontera de aislamiento era brutalmente simple y por eso mismo insuficiente: o el tipo es Sendable o no pasa. Esa regla es una afirmación universal sobre el tipo, y la seguridad real casi nunca es universal, es local. Un objeto mutable recién construido, del que nadie más tiene una referencia, puede entregarse a un actor con seguridad perfecta aunque su tipo no prometa nada, porque el problema nunca fue el tipo sino el número de manos que lo sostienen. El aislamiento por regiones es el mecanismo con el que el compilador aprendió a razonar sobre eso: en lugar de preguntar si un tipo es siempre seguro, calcula si este valor concreto, en este punto del programa, está desconectado de todo lo demás. Es el salto del tipado nominal al análisis de flujo, y es la razón de que Swift 6 sea practicable en vez de asfixiante.
- Explicar por qué la comprobación basada solo en
Sendableresultaba excesivamente conservadora. - Definir región, desconexión y fusión, y predecir qué operaciones fusionan regiones.
- Usar
sendingen parámetros y resultados para expresar transferencias en la firma. - Leer los diagnósticos de envío y corregirlos cambiando la forma del código, no la del tipo.
El problema que Sendable dejaba sin resolver
Considera el caso más común de todos: construir un objeto y entregárselo a un actor para que lo custodie.
final class Informe { // mutable, no Sendable
var lineas: [String] = []
}
actor Archivo {
private var guardados: [Informe] = []
func guardar(_ informe: Informe) { guardados.append(informe) }
}
func generar(en archivo: Archivo) async {
let informe = Informe()
informe.lineas = ["a", "b"]
await archivo.guardar(informe) // antes de Swift 6: error inevitable
}
No hay ningún riesgo en ese código: informe se crea localmente, nadie más lo ha visto, y tras la llamada la función no vuelve a tocarlo. Aun así, la comprobación basada únicamente en el tipo tenía que rechazarlo, porque Informe no promete ser seguro para todos sus usos posibles. La consecuencia práctica fue conocida y dañina: para escribir código perfectamente correcto había que marcar tipos con @unchecked Sendable, es decir, mentir. Un sistema de tipos que obliga a mentir en el caso frecuente entrena a sus usuarios a desactivarlo.
Regiones, desconexión y fusión
La solución consiste en que el compilador deje de mirar solo tipos y empiece a mirar el grafo de objetos. En cada punto del programa, particiona los valores en conjuntos llamados regiones, con una regla de pertenencia sencilla: dos valores están en la misma región si el análisis no puede descartar que uno sea alcanzable desde el otro. Una región es, por tanto, una unidad de alias potencial.
Sobre esa partición, la propiedad que interesa es la desconexión: una región está desconectada si ninguno de sus valores es alcanzable desde el estado de un actor, desde una variable global, ni desde ningún otro dominio. Y la regla de oro es una sola frase: una región desconectada puede transferirse a otro dominio, aunque sus tipos no sean seguros. Lo que la transferencia cuesta es que, a partir de ese punto, ningún valor de la región puede volver a usarse en el dominio de origen. La región no se copia: se muda.
Las regiones se fusionan cuando el código crea la posibilidad de alias, y anticipar esas fusiones es lo que permite predecir los errores.
let a = Informe()
let b = Informe()
let c = Contenedor()
c.principal = a // a y c pasan a la misma region
combinar(a, b) // pasarlos juntos a una funcion los fusiona
Después de esas dos líneas, transferir a transfiere también b y c, y usar cualquiera de ellos más tarde produce un error. Las tres operaciones que fusionan son las esperables: asignar un valor a una propiedad de otro, capturarlos juntos en una clausura y pasarlos como argumentos no marcados a la misma llamada.
La segunda mitad de la regla es igual de importante y es la que produce los diagnósticos más desconcertantes: tras la transferencia, la región entera queda fuera del alcance del dominio de origen.
let informe = Informe()
await archivo.guardar(informe) // aqui se transfiere la region
informe.lineas.append("c") // error: uso posterior a la transferencia
print(informe.lineas.count) // error tambien: leer cuenta como uso
Leer también es un uso, porque el otro dominio puede estar escribiendo en ese mismo instante. Y el compilador señala siempre dos posiciones, la del envío y la del uso; cuando el error resulta incomprensible, casi siempre es porque se está mirando la segunda cuando el problema vive en la primera.
flowchart TB
A[Valor creado localmente] --> B{Alguien mas lo alcanza}
B -->|no| C[Region desconectada]
B -->|si| D[Region fusionada con otra]
C --> E[Puede transferirse a otro dominio]
E --> F[Deja de ser usable en el origen]
D --> G[No puede transferirse]
G --> H[Romper el alias o copiar el valor]
style C fill:#a6e3a1,color:#11111b
style E fill:#89b4fa,color:#11111b
style G fill:#f38ba8,color:#11111bSendable responde a “¿es este tipo seguro en cualquier uso?” y su respuesta vale para siempre. Las regiones responden a “¿es este valor seguro aquí y ahora?” y su respuesta caduca en la siguiente línea. La segunda pregunta es estrictamente más expresiva: todo tipo seguro produce valores siempre transferibles, pero muchísimos valores transferibles pertenecen a tipos que nunca podrán ser seguros. Por eso los dos mecanismos conviven en vez de sustituirse.
sending: la transferencia escrita en la firma
El análisis de regiones es local a cada función. Al cruzar una firma, el compilador solo dispone de lo que la firma declara, y por omisión debe suponer lo peor: que un parámetro corriente queda fusionado con lo que la función pueda alcanzar. sending es la anotación que rompe esa suposición.
Un parámetro sending declara que la función se queda con la región del argumento: quien llama la transfiere en el punto de llamada y no puede volver a usarla. Un resultado sending declara lo contrario, que lo devuelto viene en una región desconectada y quien recibe puede transferirlo libremente.
actor Archivo {
func guardar(_ informe: sending Informe) { ... }
}
// Fabrica que promete devolver algo desconectado
func nuevoInforme(desde datos: [String]) -> sending Informe {
let i = Informe()
i.lineas = datos
return i
}
Hay un detalle que conviene subrayar sobre el resultado marcado: un inicializador o una fábrica que devuelve una región desconectada es la pieza que permite construir objetos complejos en un dominio y entregarlos a otro sin que ninguno de los tipos implicados tenga que prometer nada. Ese patrón —fabricar aquí, custodiar allí— cubre una fracción enorme del código real y era precisamente el que la regla antigua hacía imposible sin mentir.
Ese par de anotaciones es lo que hace componible el mecanismo a través de módulos: sin ellas, cada frontera de función obligaría a volver a exigir Sendable, y el análisis se quedaría encerrado en el cuerpo de una única función.
Conviene no confundir sending con consuming, que se le parece en la sintaxis y no en el significado. consuming habla de propiedad y vida: transfiere la responsabilidad de destruir el valor y permite evitar una retención. sending habla de aislamiento: transfiere la región a otro dominio. Un parámetro puede necesitar ambos, ninguno o solo uno, porque responden a preguntas ortogonales: quién libera esto, y quién puede tocarlo.
Cómo se leen y se arreglan los errores
Los diagnósticos de este análisis tienen una estructura fija y aprender a leerla ahorra la mayor parte del trabajo. Uno señala el punto de transferencia: enviar este valor arriesga carreras porque su región no está desconectada. Otro señala el uso posterior: este valor se usa después de haber sido transferido. En ambos casos el compilador indica dos puntos del código, y el error casi siempre está en el que no estabas mirando.
Las correcciones forman un repertorio corto. Si el problema es un alias creado antes de la transferencia, rompe el alias: no guardes el objeto en una propiedad local si vas a enviarlo. Si el problema es un uso posterior, reordena para que la transferencia sea la última operación, o pide de vuelta lo que necesitas al dominio de destino. Si el problema es que la función fabricante fusiona sus argumentos, marca su parámetro o su resultado con sending. Y si nada de eso encaja porque el valor debe seguir vivo a ambos lados, entonces el problema no era de regiones: ese objeto está genuinamente compartido y necesita un dueño o una copia.
Construye y envía
Crear el objeto justo antes de enviarlo, sin guardarlo en ningún sitio intermedio, mantiene su región desconectada y evita el error por completo.
El alias es el culpable
Casi todos los fallos vienen de una asignación previa que fusionó regiones. Búscala antes de tocar el tipo.
Escríbelo en la firma
sending en parámetro o resultado propaga el análisis a través de fronteras de función y de módulo. Sin él, cada firma vuelve a exigir Sendable.
El aislamiento por regiones es, conceptualmente, el momento en que Swift cruzó una frontera que la mayoría de los lenguajes de su familia nunca han cruzado: pasar de razonar sobre tipos a razonar sobre la forma del grafo de objetos en un punto del programa. Toda la tradición del tipado nominal descansa en afirmaciones universales, del estilo de “los valores de este tipo cumplen esta propiedad”, y su virtud es que se comprueban composicionalmente y se documentan solas en la firma. Su límite es que no pueden expresar propiedades que dependen del uso: la unicidad de una referencia no es un hecho sobre un tipo, es un hecho sobre un instante. Los lenguajes que quisieron capturarla acuñaron familias enteras de sistemas para ello —tipos lineales y afines, tipos de unicidad en Clean, ownership y borrowing en Rust, separation logic en la verificación formal— y todos comparten la misma intuición: si puedo demostrar que solo hay una manera de llegar a este objeto, entonces puedo autorizar operaciones que serían inseguras si hubiera dos. La aportación de Swift es haber empaquetado esa idea con una ergonomía inusual, porque el análisis es inferido en el cuerpo de la función y solo hay que escribirlo en las firmas, que es exactamente donde la información necesita ser explícita para componer entre módulos. Merece la pena apreciar la elegancia del reparto resultante: Sendable es la parte nominal, verificada por inducción sobre la estructura del tipo, estable y documentada en la conformidad; las regiones son la parte estructural, verificada por flujo, local y volátil. Uno cubre lo que siempre es cierto, el otro lo que es cierto aquí. Y hay una lección de diseño de lenguajes que trasciende la concurrencia: cuando un sistema de tipos obliga sistemáticamente a sus usuarios a usar la vía de escape para expresar código correcto, el defecto no está en los usuarios sino en la expresividad del sistema, y la solución correcta nunca es endurecer la regla ni predicar disciplina, sino encontrar el análisis que hace demostrable lo que ya era verdad.
El compilador particiona los valores locales en regiones de alias potencial y considera desconectada aquella que no es alcanzable desde ningún otro dominio. Una región desconectada puede transferirse aunque sus tipos no sean Sendable, a cambio de que sus valores dejen de ser usables en el origen. Las regiones se fusionan al asignar un valor dentro de otro, al capturarlos juntos o al pasarlos a la misma llamada sin anotar. sending propaga el análisis a través de las firmas: en un parámetro cede la región a la función, en un resultado promete devolverla desconectada. Y no es lo mismo que consuming, que habla de propiedad y no de aislamiento.
- Escribe la función que crea un objeto no seguro y lo entrega a un actor, y comprueba que compila; después guarda el objeto en una variable local previa y explica el error.
- Provoca deliberadamente una fusión pasando dos objetos a la misma función y anota qué valor deja de ser transferible.
- Convierte una fábrica tuya en una que devuelva
sendingy observa qué llamadas dejan de necesitar anotaciones adicionales. - Toma un tipo que hubieras marcado
@unchecked Sendablepor comodidad y comprueba si el análisis de regiones lo hace innecesario. - Escribe un caso donde
sendingyconsumingsean ambos apropiados sobre el mismo parámetro y justifica por separado qué aporta cada uno.