wandres.dev
PROTOCOLOS A FONDO · POP y sus límites

associatedtype y Self: protocolos con requisitos de tipo

Cuando un protocolo deja un hueco de tipo por rellenar deja de ser un tipo utilizable sin más. Qué son los requisitos asociados, por qué Self envenena la existencial, y las tres salidas: restricción genérica, some y any.

⏱ 22 min

Hay un momento en el aprendizaje de Swift en que el compilador rechaza algo que parecía obvio: guardar en un array valores de un protocolo perfectamente razonable. La causa es que ese protocolo no describe un contrato, sino una familia de contratos parametrizada por un tipo que solo el conformante conoce. Un protocol con associatedtype o con Self en posición de entrada no es un tipo; es una plantilla de tipos. Entender por qué —y no solo memorizar el remedio— es la diferencia entre pelear con el compilador y diseñar abstracciones que se dejan usar.

🎯 Al terminar esta lección sabrás
  • Declarar un associatedtype y restringirlo con where.
  • Explicar el papel de Self como tipo del conformante y sus dos posiciones.
  • Justificar por qué un protocolo con requisitos de tipo no es una existencial libre.
  • Elegir entre restricción genérica, some y any según el caso.

Un hueco de tipo dentro del contrato

Un associatedtype es un parámetro de tipo que el protocolo declara pero no fija: cada conformante decide con qué lo rellena.

protocol Contenedor {
    associatedtype Elemento
    var cuenta: Int { get }
    mutating func anadir(_ item: Elemento)
    subscript(i: Int) -> Elemento { get }
}

struct Pila: Contenedor {
    private var items: [Int] = []
    var cuenta: Int { items.count }
    mutating func anadir(_ item: Int) { items.append(item) }   // Elemento = Int
    subscript(i: Int) -> Int { items[i] }
}

Swift infiere que Elemento es Int a partir de las firmas implementadas; podrías escribirlo explícitamente con typealias Elemento = Int. Lo decisivo es que Contenedor no nombra un tipo único: nombra Contenedor con Elemento igual a Int, con Elemento igual a String, y así infinitas veces. Es una familia.

El hueco puede acotarse. associatedtype Elemento: Equatable exige que el relleno conforme a algo, y una cláusula where puede relacionar varios huecos entre sí, como hace la biblioteca estándar al pedir que el elemento de un iterador coincida con el de su secuencia.

Self, el tipo del conformante

Self no es un associatedtype declarado por ti, pero se comporta igual: es un hueco implícito que vale, en cada conformidad, el propio tipo conformante.

protocol Duplicable {
    func copia() -> Self              // Self en posicion de salida
}

protocol Combinable {
    func combinar(con otro: Self) -> Self   // Self en posicion de entrada
}

La distinción entre las dos posiciones es más importante que la sintaxis. En salida, Self promete devolver algo del mismo tipo; el que llama recibe información. En entrada, Self exige recibir algo del mismo tipo, y ahí aparece el problema: si tuvieras dos valores distintos que solo conoces como Combinable, nada garantiza que sean del mismo tipo concreto, y combinar sería una llamada imposible de tipar. Equatable es el caso arquetípico: el operador de igualdad compara dos Self, no dos cosas cualesquiera.

💡
Requisitos de tipo, un solo nombre

associatedtype y Self se agrupan bajo la etiqueta de requisitos de tipo, y la documentación llama protocolo con tipos asociados a cualquiera que tenga uno u otro. Todo lo que se dice de los huecos vale igual para ambos.

Por qué no es un tipo sin más

Un protocolo sin requisitos de tipo puede usarse como existencial: una caja que dice “aquí dentro hay algún valor que cumple el contrato”. La caja funciona porque toda la información necesaria para operar está en el contrato.

Con un hueco de tipo, la caja pierde una pieza. Dos cajas de Contenedor pueden esconder rellenos incompatibles, así que el compilador no puede tipar una operación que mezcle sus elementos.

// Antes de Swift 5.7 esto era directamente un error de compilacion.
// Hoy es legal, pero con limites reales:
let cajas: [any Contenedor] = [Pila(), Pila()]

for caja in cajas {
    print(caja.cuenta)          // ok: no menciona Elemento
    // caja.anadir(1)           // error: Elemento no se conoce aqui
}

La regla que subyace es limpia: puedes usar los miembros cuyo tipo asociado aparezca solo en salida —Swift lo abre como un tipo opaco anónimo— y no puedes usar los que lo tengan en entrada, porque tendrías que fabricar un valor de un tipo que nadie sabe nombrar.

Si el protocolo declara un tipo asociado primario, puedes cerrar el hueco al escribir la existencial y recuperarlo casi todo:

protocol Coleccionable<Elemento> {
    associatedtype Elemento
    func todos() -> [Elemento]
}

let fuentes: [any Coleccionable<Int>] = []   // hueco cerrado en Int
flowchart TB
p[protocolo con associatedtype o Self] --> q{el hueco esta cerrado en el uso}
q -->|no| lim[existencial limitada solo miembros de salida]
q -->|si por restriccion generica| gen[tipo concreto conocido en compilacion]
q -->|si por tipo asociado primario| pri[existencial utilizable con el hueco fijado]
gen --> full[acceso a todos los miembros y especializacion]
pri --> full

Las tres salidas

🧩

Restricción genérica

func procesar<C: Contenedor>(_ c: C). El hueco se resuelve en compilación para cada llamada. Máxima potencia y coste cero; el tipo concreto viaja con la función.

🫥

Tipo opaco con some

func hacerPila() -> some Contenedor. Hay un tipo concreto, fijo y oculto al llamante. Conserva la identidad del tipo sin revelarlo.

📦

Existencial con any

let c: any Contenedor. Admite tipos distintos en la misma variable o array. Paga una caja en ejecución y pierde los miembros de entrada.

🍊

Borrado de tipos

Un envoltorio propio, al estilo AnySequence, que captura las operaciones en closures. Es lo que haces cuando any no basta.

Las tres conviven en una misma API sin contradecirse:

// 1. El llamante elige el tipo: se especializa, coste cero.
func sumar<C: Contenedor>(_ c: C) -> Int where C.Elemento == Int {
    (0..<c.cuenta).reduce(0) { $0 + c[$1] }
}

// 2. El implementador elige y lo oculta: un solo tipo, identidad conservada.
func pilaVacia() -> some Contenedor { Pila() }

// 3. Tipos distintos en el mismo sitio: caja en ejecucion, miembros limitados.
func contarTodo(_ cajas: [any Contenedor]) -> Int {
    cajas.reduce(0) { $0 + $1.cuenta }
}

Fíjate en la cláusula where C.Elemento == Int de la primera: solo la versión genérica puede hablar del hueco por su nombre y relacionarlo con otros tipos. Esa capacidad —expresar restricciones entre huecos— es la que se pierde al pasar a la existencial, y suele ser la razón real por la que un diseño acaba obligado a ser genérico.

Universal frente a existencial: dos cuantificadores, no dos sintaxis

La pareja some y any no es azúcar sintáctico: es la aparición explícita, en la superficie del lenguaje, de los dos cuantificadores de la lógica. Un parámetro genérico <C: Contenedor> es una afirmación universal: para todo tipo que cumpla el contrato, esta función es correcta. El que llama elige el tipo, el compilador lo conoce, y por eso puede especializar el código, inlinear y borrar toda indirección; el tipo está presente en la firma aunque no se escriba. Una existencial any Contenedor es una afirmación existencial: existe algún tipo que cumple el contrato, pero quién sea es información que el implementador retuvo y el llamante no recibe. Esa asimetría —quién elige el tipo— explica de un plumazo todas las diferencias prácticas: por qué la genérica es gratis y la existencial cuesta una caja, por qué la genérica preserva la relación entre dos parámetros del mismo tipo y la existencial la pierde, por qué solo la genérica puede pasar un valor a un método que espera Self. Y explica también qué es exactamente some: una existencial con identidad conservada, un tipo abstracto en el sentido de Mitchell y Plotkin, donde el llamante no sabe qué tipo es pero sí sabe que a lo largo del programa es siempre el mismo, lo cual basta para comparar, encadenar y componer resultados sin abrir la caja. El error de novato de “no puedo usar mi protocolo como tipo” no es un capricho del compilador: es el sistema de tipos negándose a fingir que una familia infinita de contratos es un único contrato. La pregunta correcta nunca fue cómo esquivarlo, sino quién debe elegir el tipo concreto en tu diseño: si el llamante, escribe genérico; si el implementador y hay uno solo, escribe some; si el implementador y hay muchos, escribe any y acepta el precio.

📝
Lo esencial

associatedtype y Self convierten un protocolo en una familia de contratos parametrizada por un hueco de tipo. Por eso una existencial any solo puede usar los miembros donde el hueco aparece en salida, salvo que lo cierres con un tipo asociado primario. Las salidas son tres: restricción genérica cuando el llamante elige el tipo, some cuando lo elige el implementador y es único, y any o un borrado de tipos propio cuando hay que mezclar tipos distintos.

⚔️ Cierra el hueco
  1. Define Contenedor con associatedtype Elemento y conforma dos structs con rellenos distintos.
  2. Intenta guardar ambos en un array del protocolo y lee el error o la limitación que aparezca.
  3. Escribe una función genérica restringida al protocolo que sume las cuentas de dos contenedores.
  4. Convierte el protocolo en uno con tipo asociado primario y repite el paso 2 fijando el hueco.
  5. Añade un método que reciba Self y comprueba qué deja de funcionar en la versión existencial.