wandres.dev
SOME Y ANY · opacos y existenciales

El protocolo que no podía ser un tipo

Un protocolo con tipos asociados o requisitos de Self no describe un tipo, sino una familia infinita de tipos. De esa imposibilidad nacen las dos palabras clave del nivel. Los dos papeles de un protocolo, el error histórico del compilador, y por qué fijar el tipo y ocultarlo no es lo mismo que olvidarlo y guardarlo en una caja.

⏱ 18 min

Durante años, escribir un array de Dibujable compilaba y escribir un array de Collection no. El compilador respondía con una frase que casi todo el mundo ha leído alguna vez: el protocolo solo puede usarse como restricción genérica porque tiene requisitos de Self o de tipo asociado. No era un capricho ni una carencia temporal del compilador: era una verdad sobre qué es un protocolo. Entender por qué un contrato con huecos de tipo no puede comportarse como un tipo cualquiera es entender de dónde salen some y any, y por qué son dos respuestas distintas —una estática, otra dinámica— a una misma imposibilidad.

🎯 Al terminar esta lección sabrás
  • Distinguir los dos papeles de un protocolo: restricción sobre genéricos y tipo por derecho propio.
  • Ver por qué los tipos asociados y los requisitos de Self destruyen el segundo papel.
  • Leer un protocolo como una familia de tipos en lugar de como un tipo concreto.
  • Situar some y any como las dos salidas del mismo callejón sin salida.

Un protocolo tiene dos vidas

Un protocolo sencillo —sin tipos asociados, sin requisitos de Self— vive dos vidas a la vez, y es fácil no notar que son distintas.

protocol Dibujable {
    func dibujar() -> String
}

struct Circulo: Dibujable { func dibujar() -> String { "circulo" } }
struct Cuadrado: Dibujable { func dibujar() -> String { "cuadrado" } }

// Vida 1: restriccion. T es UN tipo concreto, elegido en cada llamada.
func pintarEstatico<T: Dibujable>(_ figura: T) -> String { figura.dibujar() }

// Vida 2: tipo. Una caja capaz de contener CUALQUIER conformante.
let galeria: [any Dibujable] = [Circulo(), Cuadrado()]

En la primera vida el protocolo es un adjetivo: califica a un tipo que el compilador resolverá en el punto de llamada. En la segunda es un sustantivo: nombra por sí mismo un tipo de dato, uno cuyos valores pueden ser hoy un Circulo y mañana un Cuadrado. La primera vida es universal —para todo T que conforme—; la segunda es existencial —existe algún tipo, no sé cuál, y aquí está el valor—. Esta distinción, que en lógica separa el cuantificador universal del existencial, es la columna vertebral de todo el nivel.

Los tipos asociados rompen la segunda vida

Añade un solo associatedtype y la segunda vida se desmorona.

protocol Contenedor {
    associatedtype Elemento
    var cuenta: Int { get }
    mutating func agregar(_ nuevo: Elemento)
    subscript(indice: Int) -> Elemento { get }
}

La pregunta que mata la idea es concreta: si tuvieras una variable declarada simplemente como Contenedor, sin más, ¿qué tipo devolvería su subíndice? ¿Int, si dentro hay un array de enteros? ¿String, si hay otro? El compilador tendría que responder antes de saber qué valor guardarás dentro, y no puede: el tipo de retorno depende del conformante concreto, que es justo lo que la caja pretende olvidar. Hasta Swift 5.6 el compilador zanjaba el asunto en la fase de comprobación de tipos:

error: protocol 'Contenedor' can only be used as a generic constraint
       because it has Self or associated type requirements

Los requisitos de Self producen el mismo colapso por otra vía. Equatable exige un operador cuyos dos operandos sean del mismo tipo:

protocol Combinable {
    static func combinar(_ a: Self, _ b: Self) -> Self
}

Dos cajas independientes podrían contener un Int y un String. Pedirles que se combinen es pedir algo que ningún conformante prometió: la promesa era combinarse consigo mismo, no con cualquier otro habitante del protocolo. La caja borra precisamente la información —la identidad del tipo— que el requisito necesita para tener sentido.

ℹ️
La regla es la varianza, no el capricho

La frontera exacta no es tener tipos asociados, sino dónde aparecen. Un tipo asociado que solo asoma en posición de salida —el retorno de un método— puede tratarse hoy como Any y funcionar. Uno que aparece en posición de entrada —un parámetro, como en el método de agregar— es contravariante: exigiría que quien llama conociera el tipo exacto, y por eso sigue vedado sobre una caja sin abrir. Swift llama a eso un miembro no disponible en el existencial.

Un protocolo es una familia, no un tipo

La lectura correcta es esta: Collection no es un tipo. Es un esquema parametrizado por Element, Index, SubSequence y varios más. Cada elección de esos parámetros produce un tipo distinto de la familia. Escribir la familia entera donde se espera un tipo es un error de categoría, como escribir la palabra función donde se espera un número.

// Cada linea es un miembro distinto de la familia Collection
let a: [Int]              // Element es Int, Index es Int
let b: Set<String>        // Element es String, Index es un indice de tabla hash
let c: Substring          // Element es Character, Index es String.Index

Un tipo, para serlo, necesita responder tres preguntas sin ambigüedad: cuánto ocupa en memoria, cómo se copia y se destruye, y qué operaciones admite con qué firmas. Un protocolo sin tipos asociados puede responderlas de forma uniforme para todos sus conformantes. Uno con tipos asociados no: la tercera respuesta cambia con cada miembro de la familia.

La analogía más útil es la de una función. Un protocolo con tipos asociados se comporta como una función que va de tipos a tipos: dale un conformante y te devuelve la firma concreta de cada requisito. Una función no es un valor de su codominio, igual que la raíz cuadrada no es un número. Confundir la función con sus resultados es el error de categoría exacto que el compilador denunciaba.

// Cada conformidad instancia la familia con valores distintos
extension Array: Contenedor {
    typealias Elemento = Element     // el hueco queda resuelto aqui
}

Y hay un matiz que conviene fijar desde ya: la prohibición nunca afectó al protocolo entero, sino a los miembros cuya firma depende del hueco. Contar elementos no depende del tipo asociado y siempre fue seguro; insertar uno sí depende, y por eso quedaba fuera. Swift 5.7 formalizó justamente esa distinción: hoy la caja se construye siempre, y el compilador te bloquea miembro a miembro en lugar de vetar el tipo completo.

flowchart TB
P[Protocolo] --> Q{Tiene tipos asociados o requisitos de Self}
Q -->|No| T[Puede actuar como tipo directamente]
Q -->|Si| F[Es una familia de tipos, no un tipo]
F --> S[Salida 1 con some: fijar un miembro y ocultarlo]
F --> A[Salida 2 con any: olvidar cual y encajarlo]
S --> E[Despacho estatico y coste cero]
A --> D[Despacho dinamico y coste de caja]
style F fill:#f38ba8,color:#11111b
style S fill:#a6e3a1,color:#11111b
style A fill:#89b4fa,color:#11111b

Dos salidas: fijar u olvidar

Ante la misma imposibilidad, Swift ofrece dos caminos que no compiten sino que se reparten el territorio.

La primera salida es fijar un miembro concreto de la familia y ocultar cuál. Eso es some: el compilador sabe con exactitud que devuelves un Array de enteros; quien llama solo sabe que devuelves algo que conforma. El tipo existe, es único y es estático; simplemente no aparece en la firma.

func numeros() -> some Collection {   // el compilador sabe que es [Int]
    [1, 2, 3]
}

La segunda salida es olvidar cuál y meterlo en una caja capaz de albergar a cualquiera. Eso es any: el tipo concreto se decide en ejecución y viaja acompañado de la maquinaria necesaria para operar sobre él sin conocerlo.

let figuras: [any Dibujable] = [Circulo(), Cuadrado()]  // heterogeneo, dinamico

Las dos salidas no compiten: se reparten el trabajo según dónde viva la variedad. Si la variedad está en el código —muchos conformantes, pero uno solo por cada punto del programa—, la salida es some. Si la variedad está en los datos —el mismo punto ve tipos distintos en ejecución—, la salida es any.

Desde Swift 5.6, la palabra any es obligatoria al escribir un existencial: lo que antes era un nombre de protocolo desnudo —ambiguo entre las dos vidas— hoy debe declararse. Y desde Swift 5.7 los existenciales están permitidos incluso para protocolos con tipos asociados: la caja se construye, aunque los miembros contravariantes sigan fuera de alcance salvo que abras el existencial o restrinjas el tipo asociado.

Universal y existencial: la lógica escondida en dos palabras clave

Detrás de some y any late una distinción que la lógica formalizó mucho antes de que existiera Swift, y verla convierte dos palabras clave en una idea. Un genérico es una afirmación universal: para todo tipo que conforme al protocolo, esta función funciona. Un existencial es una afirmación existencial: existe algún tipo que conforma, y aquí tienes un valor suyo, pero no te diré cuál. Por eso any se llama técnicamente un tipo existencial y por eso su comportamiento es tan distinto: cuando afirmas “existe algún tipo”, te comprometes a que el programa funcione sin conocerlo, y eso obliga a arrastrar en ejecución la información que el sistema de tipos ya no lleva. Lo bello es que some en posición de retorno es un existencial también —“existe un tipo que devuelvo”—, pero uno resuelto en compilación: la diferencia no es qué se afirma, sino cuándo se sabe. Un tipo asociado destruye la vida de sustantivo porque convierte el protocolo en una función de tipos, y las funciones no son valores del mismo modo que los números no lo son de sus funciones. Todo el resto del nivel es un corolario de esta única frase: some cuantifica sobre un tipo que el compilador conoce y el lector ignora; any cuantifica sobre uno que ni el compilador conoce hasta que el programa corre. La sintaxis casi idéntica esconde una diferencia de mundo, y confundirlas es la fuente número uno de código Swift que funciona pero paga un precio invisible.

📝
Lo esencial del origen

Un protocolo vive como restricción sobre genéricos y, si no tiene tipos asociados ni requisitos de Self, también como tipo. Los tipos asociados lo convierten en una familia parametrizada de tipos, y una familia no puede ocupar el lugar de un tipo porque las firmas de sus miembros dependen del conformante. Los requisitos de Self fallan por lo mismo: exigen una identidad de tipo que la caja borra. De ahí nacen las dos salidas: some fija un miembro de la familia en compilación y lo oculta al lector; any olvida cuál es y arrastra en ejecución lo necesario para operar sobre él.

⚔️ Localiza la frontera
  1. Define un protocolo con un associatedtype usado solo como tipo de retorno y otro donde aparezca como parámetro; comprueba cuál de los dos te deja llamar a sus miembros sobre un valor any.
  2. Escribe un protocolo con un requisito estático binario sobre Self y razona, sin compilar, qué ocurriría si dos cajas distintas guardaran tipos diferentes.
  3. Toma tres tipos de la biblioteca estándar que conformen a Collection y anota para cada uno el valor de Element e Index: estás enumerando miembros de la familia.
  4. Declara una constante de tipo any Dibujable y otra devuelta por una función some Dibujable; intenta guardar ambas en el mismo array y explica el error.
  5. Reescribe una función tuya que reciba un parámetro any para que lo reciba como genérico restringido, y describe qué información gana el compilador con el cambio.