wandres.dev
GENÉRICOS AVANZADOS · restricciones y where

Restricciones de tipo: genéricos con contrato

Limitar un parámetro genérico a los tipos que conforman ciertos protocolos, componer varias restricciones y aprender a descomponer una firma genérica compleja de la librería estándar.

⏱ 16 min

Un parámetro genérico sin restricciones es casi inútil: puedes guardarlo, moverlo y devolverlo, pero no puedes preguntarle nada. La restricción es el contrato que convierte un marcador vacío en un tipo con capacidades verificadas en compilación. Este nivel empieza aquí porque todo lo demás —cláusulas where, conformidad condicional, especialización— son refinamientos de esta única idea.

🎯 Al terminar esta lección sabrás
  • Comprender por qué un parámetro genérico sin restricciones apenas puede operar.
  • Restringir con un protocolo, con varios y con una superclase.
  • Distinguir la sintaxis de dos puntos de la cláusula where y saber cuándo cada una.
  • Descomponer una firma genérica compleja leyéndola por capas.

El marcador vacío y su contrato

Cuando escribes un parámetro de tipo sin restricción, Swift solo garantiza una cosa: que existe algún tipo concreto ocupando ese hueco. Nada más. No sabe si se puede comparar, imprimir, sumar o iterar.

func describir<T>(_ valor: T) -> String {
    // valor == valor      // error: T no conforma a Equatable
    // valor.description   // error: T no tiene esa propiedad
    "\(valor)"             // esto sí: la interpolación funciona con todo
}

La restricción se escribe tras dos puntos y significa exactamente “cualquier T, siempre que conforme a esto”. A partir de ese instante, el cuerpo de la función puede usar todo lo que el protocolo promete, y el compilador lo verifica en la definición, no en cada llamada.

func maximo<T: Comparable>(_ a: T, _ b: T) -> T {
    a > b ? a : b          // legal: Comparable garantiza el operador
}

Esta es la diferencia esencial con los genéricos de C++ o con el duck typing: allí la plantilla se comprueba al instanciarse y los errores aparecen en el punto de uso; en Swift el contrato se verifica una sola vez, al definir. Si el cuerpo compila, compilará para todos los tipos que satisfagan la restricción, sin excepción.

ℹ️
Restricción no es herencia

Restringir con un protocolo no crea una jerarquía. T sigue siendo un tipo concreto —Int, String, tu struct— no una caja polimórfica. El protocolo solo actúa como filtro de admisión y como catálogo de lo que puedes invocar dentro.

Componer restricciones

Un mismo parámetro puede exigir varias capacidades a la vez con el ampersand de composición, y puede exigir además ser subclase de una clase concreta. La lista de parámetros admite tantos marcadores como necesites, cada uno con su propio contrato.

// Dos protocolos sobre el mismo parámetro
func clave<T: Hashable & CustomStringConvertible>(_ x: T) -> String {
    "\(x.hashValue)-\(x.description)"
}

// Restricción de superclase mas protocolo
func configurar<V: UIView & Identifiable>(_ vista: V) { /* ... */ }

// Varios parametros, cada uno con su contrato
func emparejar<K: Hashable, V: Equatable>(_ k: K, _ v: V) -> [K: V] {
    [k: v]
}
🔒

Protocolo

La forma canónica. T: Collection admite cualquier tipo que sepa iterar y indexar, sea Array, String o uno tuyo.

🧬

Composición

T: Hashable & Sendable exige ambas conformidades. El orden no importa y no hay límite práctico de términos.

🏛️

Superclase

T: NSObject restringe a una clase y sus descendientes. Es la única restricción nominal no protocolar y solo aplica a tipos de referencia.

La sintaxis de dos puntos solo puede hablar del parámetro en sí. En cuanto necesitas decir algo sobre los tipos asociados de ese parámetro —“el elemento de la colección debe ser comparable”— la lista de parámetros se queda corta y hay que bajar a la cláusula where, que es el tema de la lección siguiente.

Leer una firma compleja

La habilidad que separa a quien usa genéricos de quien los diseña es la lectura. Una firma densa se descompone siempre igual: primero los parámetros de tipo con sus contratos, luego los parámetros de valor, luego el retorno.

func agrupar<C: Collection, K: Hashable>(
    _ coleccion: C,
    por clave: (C.Element) throws -> K
) rethrows -> [K: [C.Element]]

Léela por capas, en este orden:

  1. Los marcadores. Hay dos: C y K. Todo lo demás se expresa en términos de ellos.
  2. Los contratos. C debe ser una colección; K debe ser hashable, porque va a ser clave de diccionario. El compilador ya sabe que C tiene un tipo asociado Element.
  3. Los valores. Recibe la colección y una función que, dado un elemento de esa colección, produce una clave. Esa función puede lanzar.
  4. El retorno. Un diccionario de claves K a arrays de elementos de C. Y rethrows dice que esta función solo lanza si la función que le pasaste lanza.
flowchart LR
A[Parametros de tipo: C y K] --> B[Contratos: C es Collection, K es Hashable]
B --> C[Parametros de valor: coleccion y funcion clave]
C --> D[Retorno: diccionario de K a arrays de Element]
D --> E[Efectos: rethrows depende del argumento]
style A fill:#89b4fa,color:#11111b
style B fill:#f9e2af,color:#11111b
style E fill:#a6e3a1,color:#11111b

Con esa disciplina, firmas que parecen intimidantes se vuelven mecánicas. Cuando abras la documentación de la librería estándar y veas la declaración real de Dictionary.init(grouping:by:) o de Sequence.flatMap, reconocerás la misma anatomía.

Restricciones implícitas y errores frecuentes

No todas las restricciones que gobiernan tu código están escritas por ti. El protocolo que exiges arrastra las suyas: pedir Hashable te da también Equatable, porque el primero hereda del segundo; pedir Collection te da Sequence y, con él, todo el catálogo de algoritmos que se definen sobre secuencias. Diseñar bien consiste en pedir el protocolo más débil que habilite lo que necesitas, no el más cómodo.

// Innecesariamente estricto: no usamos el orden para nada
func contarApariciones<T: Comparable>(_ x: T, en lista: [T]) -> Int {
    lista.filter { $0 == x }.count
}

// Correcto: la igualdad basta, y ahora admite muchos mas tipos
func contarApariciones<T: Equatable>(_ x: T, en lista: [T]) -> Int {
    lista.filter { $0 == x }.count
}

Tres errores que aparecen una y otra vez al empezar:

  • Restringir de más. Cada protocolo extra que exiges expulsa tipos válidos. Si el cuerpo no usa la capacidad, sobra.
  • Confundir el parámetro con su contenido. Escribir T: Comparable cuando lo que querías era ordenar los elementos de T. La restricción de dos puntos no puede alcanzar tipos asociados.
  • Restringir en la extensión en lugar de en el tipo. Poner la restricción en la declaración del tipo genérico obliga a que todas sus instancias la cumplan; ponerla en una extensión deja el tipo abierto y añade capacidades solo donde procede.
💡
La prueba del cuerpo

Ante la duda, borra todas las restricciones y vuelve a añadirlas una a una, guiándote por los errores del compilador. Lo que quede es el contrato mínimo. Es un ejercicio mecánico que produce firmas mucho mejores que la intuición.

La restricción es el sistema de tipos hablando de sí mismo

Aquí ocurre algo profundo que conviene nombrar. Una restricción genérica no es documentación ni una comprobación diferida: es una proposición lógica que el compilador demuestra antes de generar una sola instrucción. Cuando escribes T: Comparable, no estás pidiendo un favor al optimizador, estás añadiendo una hipótesis a un sistema formal, y el cuerpo de tu función es una prueba que solo se acepta si se deriva de esa hipótesis. Por eso Swift comprueba la definición y no cada instanciación: no está expandiendo plantillas, está verificando un teorema una vez y aplicándolo infinitas. La consecuencia práctica es enorme. Un genérico que compila es un genérico correcto para todos los tipos presentes y futuros que cumplan el contrato, incluidos los que escribirá alguien que jamás verá tu código. Y la consecuencia de diseño lo es todavía más: cada restricción que añades estrecha el universo de tipos admitidos pero ensancha el universo de operaciones legales dentro. Diseñar un genérico es negociar ese intercambio con precisión quirúrgica —pedir exactamente lo que necesitas, ni un protocolo más—, y esa negociación es el oficio entero.

⚔️ Descompón y diseña
  1. Escribe func primerosDistintos<S: Sequence>(_ s: S, _ n: Int) -> [S.Element] añadiendo la restricción mínima que haga falta para eliminar duplicados. ¿Cuál es y por qué no basta con Sequence?
  2. Declara una función que acepte un parámetro que sea a la vez Codable y Sendable y explica qué operaciones te habilita cada uno.
  3. Busca en la librería estándar la firma real de Sequence.max(by:) y descomponla en las cuatro capas de esta lección.
  4. Intenta escribir T: Comparable donde en realidad necesitabas hablar de T.Element. Lee el error del compilador: te está señalando la lección 2.