wandres.dev
GENÉRICOS AVANZADOS · restricciones y where

Conformidad condicional: heredar capacidades del parámetro

Que un tipo genérico conforme a un protocolo solo cuando su parámetro también lo hace: sintaxis, propagación en cascada, comprobación en tiempo de ejecución y la regla de unicidad de conformidades.

⏱ 16 min

Un array de enteros se puede comparar; un array de closures no. Un opcional de un tipo hashable es hashable; de otro, no. Esa asimetría no está codificada a mano en el compilador: es conformidad condicional, el mecanismo por el que un tipo genérico adquiere capacidades que su parámetro le presta. Entenderlo cambia cómo diseñas tus propios contenedores.

🎯 Al terminar esta lección sabrás
  • Declarar una conformidad que dependa de la conformidad del parámetro genérico.
  • Ver cómo las conformidades condicionales se propagan en cascada por anidamiento.
  • Entender el soporte en tiempo de ejecución y sus consecuencias en las conversiones dinámicas.
  • Respetar la regla de una única conformidad por tipo y protocolo.

La forma canónica

La declaración es una extensión con cláusula where que además adopta un protocolo. El significado es literal: este tipo conforma a ese protocolo, pero solo bajo esta condición.

struct Pila<Elemento> {
    private(set) var items: [Elemento] = []
    mutating func apilar(_ x: Elemento) { items.append(x) }
    mutating func desapilar() -> Elemento? { items.popLast() }
}

extension Pila: Equatable where Elemento: Equatable {}
extension Pila: Hashable where Elemento: Hashable {}
extension Pila: Sendable where Elemento: Sendable {}

Con esas tres líneas, Pila<Int> es comparable, hashable y segura de enviar entre dominios de aislamiento; Pila<() -> Void> no es ninguna de las tres, y el compilador lo dice en el punto exacto donde intentas usarlo. El cuerpo de la extensión suele estar vacío porque la síntesis automática de Equatable y Hashable cubre el trabajo cuando todas las propiedades almacenadas cumplen el requisito.

🧩

Depende del parámetro

La condición casi siempre es del tipo where Elemento: P para el mismo protocolo P que se adopta. Ese espejo es el patrón dominante.

🔁

Se compone

[[Int?]] es hashable porque Array lo es condicionalmente, Optional lo es condicionalmente e Int lo es incondicionalmente.

🎯

Se comprueba en runtime

Una conversión dinámica consulta la condición en tiempo de ejecución, no asume lo peor. Fue el gran cambio respecto a las primeras versiones.

Cascada y comprobación dinámica

La propiedad más valiosa es que las conformidades condicionales se componen sin que nadie las declare explícitamente para cada combinación. El compilador resuelve la cadena entera recorriendo el anidamiento.

let matriz: [[Int]] = [[1, 2], [3]]
matriz == [[1, 2], [3]]              // true: Equatable en cascada

let conjunto: Set<[String?]> = [["a", nil]]   // Hashable en cascada

Y esa resolución también existe en tiempo de ejecución. Los metadatos del tipo llevan la condición, de modo que una conversión dinámica devuelve la respuesta correcta en lugar de fallar por precaución:

func esComparable(_ x: Any) -> Bool { x is any Equatable }

esComparable([1, 2])                 // true
esComparable([{ () -> Void in }])    // false, la condicion no se cumple
flowchart TD
A[Array de Optional de Int] --> B[Array conforma a Equatable si su Element conforma]
B --> C[Optional conforma a Equatable si su Wrapped conforma]
C --> D[Int conforma a Equatable de forma incondicional]
D --> E[Resultado: la cadena entera es Equatable]
style E fill:#a6e3a1,color:#11111b
ℹ️
Por qué el cuerpo suele ir vacío

Cuando escribes extension Pila: Equatable where Elemento: Equatable {}, la síntesis automática entra en juego dentro de esa condición: el compilador genera el operador comparando propiedad a propiedad, y cada comparación es legal precisamente porque la cláusula where la garantiza. Si alguna propiedad almacenada no cumple, la síntesis falla y tendrás que escribir la implementación a mano.

Reglas, límites y trampas

La conformidad condicional está gobernada por una regla dura: un tipo conforma a un protocolo de una sola manera en todo el programa. No puedes declarar dos conformidades al mismo protocolo con condiciones distintas, aunque sean disjuntas.

extension Pila: Codable where Elemento: Codable {}
// extension Pila: Codable where Elemento: RawRepresentable {}
// error: conformidad duplicada de Pila a Codable

La razón es la coherencia global: si existieran dos rutas, dos módulos distintos podrían ver comportamientos incompatibles para el mismo tipo y el despacho dinámico dejaría de ser determinista. La solución cuando de verdad necesitas dos comportamientos es introducir dos tipos envoltorio, no dos conformidades.

Tres trampas más que conviene tener presentes:

  • La condición debe ser satisfacible. Una restricción imposible no es un error inmediato; simplemente hace que la conformidad no exista nunca y el diagnóstico aparecerá lejos, en el punto de uso.
  • Conformidad retroactiva. Extender un tipo de otro módulo para que conforme a un protocolo de un tercer módulo obliga a marcar la intención de forma explícita, porque si el dueño del tipo añade su propia conformidad más adelante, el programa se rompe en el enlazado.
  • No confundir con la especialización. Que Pila<Int> sea comparable no significa que exista un tipo Pila<Int> distinto en el sistema de tipos: hay un solo Pila genérico y una tabla de conformidad que solo se instala cuando la condición se cumple.

Diseñar un contenedor completo

Juntemos las reglas en un tipo realista: un resultado paginado, el envoltorio que devuelve casi cualquier cliente de API. La estrategia es declarar el tipo sin ninguna restricción y añadir las capacidades por capas, cada una con su condición.

struct Pagina<Elemento> {
    let elementos: [Elemento]
    let siguiente: String?
}

extension Pagina: Equatable where Elemento: Equatable {}
extension Pagina: Hashable where Elemento: Hashable {}
extension Pagina: Decodable where Elemento: Decodable {}
extension Pagina: Encodable where Elemento: Encodable {}
extension Pagina: Sendable where Elemento: Sendable {}

extension Pagina: CustomStringConvertible where Elemento: CustomStringConvertible {
    var description: String {
        elementos.map(\.description).joined(separator: ", ")
    }
}

Observa la disciplina de diseño. La declaración del tipo no impone nada, así que Pagina funciona con cualquier elemento, incluso con uno que no sea codificable. Cada extensión añade una capacidad exactamente donde tiene sentido, y ninguna de ellas estrecha el universo de tipos admitidos por las demás. Si hubieras escrito struct Pagina<Elemento: Codable>, habrías cerrado la puerta a todos los elementos no codificables para siempre, a cambio de nada.

La regla práctica es esta: las restricciones van en las extensiones, no en la declaración del tipo, salvo cuando el tipo no puede existir sin ellas. Un diccionario necesita que su clave sea hashable para funcionar, así que esa restricción sí pertenece a la declaración. Una página no necesita nada para existir.

💡
Comprueba la superficie condicional

Escribe en un playground una instanciación con un parámetro deliberadamente pobre —una función, por ejemplo— y observa qué miembros ofrece el autocompletado. Lo que desaparece es justo el conjunto de conformidades condicionales que no se cumplen. Es la manera más rápida de auditar el diseño.

La conformidad condicional es composicionalidad hecha tipo

Lo que acabas de aprender resuelve un problema que muchos lenguajes con genéricos nunca resolvieron bien: cómo hacer que las propiedades de un contenedor se deriven de las de su contenido sin escribir una explosión combinatoria de declaraciones. Antes de este mecanismo, la librería estándar de Swift tenía comparaciones de arrays escritas como funciones libres, casos especiales que el compilador conocía de memoria y agujeros visibles en cuanto anidabas dos contenedores. Con conformidad condicional, la respuesta a “¿es hashable un diccionario de arrays de opcionales de tu struct?” deja de ser una tabla que alguien mantiene a mano y pasa a ser una derivación: el compilador la calcula recorriendo el árbol de tipos y aplicando una regla en cada nodo. Eso es composicionalidad en el sentido fuerte del término, el mismo que persiguen los sistemas de tipos de la familia ML con sus instancias condicionales. Y la regla de unicidad, que al principio parece una limitación arbitraria, es en realidad lo que compra la coherencia: garantiza que la pregunta “¿este tipo cumple este protocolo?” tenga una única respuesta en todo el universo del programa, en compilación y en ejecución, hoy y cuando alguien enlace tu módulo dentro de cinco años. Diseñar contenedores genéricos sin usar este mecanismo es dejar sobre la mesa la mitad del lenguaje.

⚔️ Propaga capacidades
  1. Añade a tu Pila conformidades condicionales a Equatable, Hashable, Codable y Sendable, y comprueba cuáles sobreviven con un parámetro que sea una función.
  2. Escribe un envoltorio Etiquetado<T> con una etiqueta String y haz que conforme a Comparable solo cuando T lo sea.
  3. Verifica la cascada: construye un valor de tipo [Etiquetado<Int>?] y comprueba en el REPL que se puede comparar.
  4. Intenta declarar dos conformidades al mismo protocolo con condiciones disjuntas y razona, con el error en pantalla, por qué la coherencia global lo prohíbe.