Composición y refinamiento: combinar protocolos y acotar extensiones
Protocolos que heredan de protocolos, composición con el operador ampersand, conformidad condicional y cláusulas where que activan comportamiento solo para ciertos conformantes. El álgebra de contratos de Swift.
Un protocolo aislado es útil; un sistema de protocolos que se refinan, se combinan y se activan condicionalmente es una herramienta de expresividad de otro orden. Swift ofrece un pequeño álgebra de contratos: puedes construir uno más exigente sobre otros —refinamiento—, puedes exigir varios a la vez sin crear ninguno nuevo —composición—, y puedes hacer que una extension entregue comportamiento solo a los conformantes que además cumplan una condición. Con esas tres operaciones, la biblioteca estándar describe toda la jerarquía de secuencias y colecciones sin una sola clase.
- Refinar un protocolo heredando de uno o varios protocolos.
- Componer requisitos con el operador
&y nombrarlos contypealias. - Escribir extensiones acotadas con
wheresobre el tipo asociado o sobreSelf. - Declarar conformidad condicional para un tipo genérico.
Refinar: un contrato sobre otro
Un protocolo puede heredar de otros. El resultado es un contrato más exigente: quien lo firme debe cumplir todo lo suyo y todo lo de sus padres.
protocol Identificable {
var id: String { get }
}
protocol Persistible: Identificable, Codable {
static var tabla: String { get }
func guardar() throws
}
Conformar a Persistible obliga a aportar id, la conformidad a Codable y los dos miembros nuevos. Al revés no vale: quien conforme a Identificable no gana nada de Persistible.
Un refinamiento puede además restringir la categoría del conformante:
protocol Observable: AnyObject {
func alCambiar()
}
AnyObject limita la conformidad a tipos por referencia. Es la forma correcta de expresar que la abstracción depende de la identidad y no del valor —un observador debe ser el mismo objeto entre notificaciones—, y también lo que permite guardar referencias weak a él.
La biblioteca estándar es una cascada de refinamientos: Collection refina Sequence, BidirectionalCollection refina Collection, RandomAccessCollection refina la anterior. Cada escalón añade garantías —poder recorrer varias veces, poder retroceder, poder saltar en tiempo constante— y cada algoritmo se escribe pidiendo exactamente el escalón que necesita.
Componer con el operador ampersand
Cuando quieres exigir dos contratos a la vez pero no tiene sentido crear un tercero, los compones:
func registrar(_ objeto: Identificable & Codable) { }
func mostrar(_ dato: some Equatable & CustomStringConvertible) { }
typealias Entidad = Identificable & Codable & Hashable
La composición es anónima y estructural: no define un nuevo nombre en el sistema de tipos ni obliga a nadie a declarar nada. Cualquier tipo que ya cumpla ambas partes encaja sin tocar su declaración. El typealias solo pone etiqueta a la conjunción por comodidad de lectura.
Refina cuando la combinación tiene identidad conceptual propia y quieras añadirle requisitos o extensiones. Compón cuando solo necesitas exigir dos capacidades en un punto concreto de la API. Crear un protocolo vacío que herede de otros dos, sin añadir nada, casi siempre es composición mal disfrazada, y además obliga a cada tipo a declarar la conformidad explícitamente.
Extensiones acotadas con where
Aquí la composición se vuelve dinámica en el sentido del diseño: una extension puede aplicarse solo a la parte de los conformantes que cumpla una condición extra.
protocol Contenedor {
associatedtype Elemento
var items: [Elemento] { get }
}
extension Contenedor {
var cuenta: Int { items.count } // para todos
}
extension Contenedor where Elemento: Numeric {
var total: Elemento { items.reduce(.zero, +) } // solo si el elemento suma
}
extension Contenedor where Elemento: Equatable {
func contiene(_ x: Elemento) -> Bool { items.contains(x) }
}
Un contenedor de Int obtiene cuenta, total y contiene; uno de una struct sin conformidades obtiene solo cuenta. El miembro no está oculto ni deshabilitado: no existe para ese tipo, y llamarlo es un error de compilación con un mensaje que nombra la restricción incumplida.
La condición también puede recaer sobre Self, lo que permite que un protocolo aporte comportamiento apoyándose en otro sin heredar de él:
extension Contenedor where Self: Identificable {
var etiqueta: String { "\(id): \(cuenta) items" }
}
Y el mismo mecanismo, aplicado a la declaración de conformidad, produce la conformidad condicional: un tipo genérico conforma solo cuando su parámetro lo hace.
struct Caja<T> { let contenido: [T] }
extension Caja: Equatable where T: Equatable { }
Es exactamente la regla que hace que [Int] sea Equatable y [Any] no lo sea, sin que exista un array especial para los tipos comparables.
flowchart TB seq[Sequence recorrer una vez] --> col[Collection indices estables] col --> bid[BidirectionalCollection retroceder] bid --> rac[RandomAccessCollection salto constante] col --> ext1[extension where Element es Equatable] col --> ext2[extension where Element es Numeric] ext1 --> m1[metodos de busqueda] ext2 --> m2[metodos de suma y media]
El álgebra completa
Refinamiento
protocol B: A. Conjunción nominal: crea un contrato nuevo que el conformante debe declarar y sobre el que puedes escribir extensiones.
Composición
A & B. Conjunción estructural: no crea nada, no exige declaración y solo vive en el punto donde se usa.
Extensión acotada
extension A where .... Entrega comportamiento a un subconjunto de conformantes; fuera de él, el miembro no existe.
Conformidad condicional
extension Caja: P where T: P. Propaga una capacidad del parámetro al contenedor sin duplicar tipos.
Las cuatro operaciones no son cuatro utilidades sueltas: forman un retículo de contratos donde cada punto es un conjunto de garantías y cada operación una manera de moverse por él. La herencia de clases solo sabe moverse por una línea —un padre, una cadena, un orden total impuesto de antemano por quien escribió la clase base—, y por eso cualquier capacidad que no encaje en esa línea acaba resuelta con duplicación, con delegación manual o con una clase base hinchada de métodos que la mitad de los descendientes no usa. El retículo de protocolos no tiene esa restricción: el conjunto de contratos que un tipo cumple es un conjunto, no un camino, y dos capacidades ortogonales se combinan sin que nadie decida cuál va primero. Lo que eleva esto de la ergonomía a la teoría es la cláusula where, porque introduce algo que ninguna jerarquía puede expresar: conformidad que depende de una proposición sobre otro tipo. Cuando escribes que Caja es Equatable si y solo si T lo es, no estás declarando una relación entre dos tipos, estás declarando una implicación, y el compilador la resuelve componiendo implicaciones en cadena hasta cerrar la prueba —Caja de Array de Int es comparable porque lo es Array de Int, que lo es porque lo es Int—. Ese motor es, literalmente, un demostrador de teoremas sobre tipos, y la correspondencia no es una metáfora: bajo Curry y Howard, un protocolo es una proposición, la conformidad es su prueba, y where es la flecha que va de una a otra. La consecuencia de diseño es que la pregunta correcta ante una API deja de ser “de qué debería heredar este tipo” y pasa a ser qué es lo mínimo que necesito exigir para que este algoritmo sea correcto: refina cuando la combinación tiene nombre propio, compón cuando solo la necesitas aquí, y acota con where cuando el comportamiento solo es demostrable bajo una hipótesis. Cada algoritmo pide su escalón exacto, ni uno más, y el conjunto de tipos que puede servirlo es el mayor posible.
Refinar crea un contrato nominal más exigente y permite extenderlo; componer con & exige varios contratos sin crear ninguno; where acota una extensión a los conformantes que cumplan una condición sobre su tipo asociado o sobre Self; y la conformidad condicional propaga una capacidad de un parámetro genérico a su contenedor. Con estas cuatro operaciones se describe la jerarquía de colecciones de Swift sin una sola clase.
- Define
IdentificableySerializable, y refina unPersistibleque herede de ambos. - Escribe una función que reciba la composición de los dos primeros sin usar
Persistible. - Crea un protocolo con
associatedtypey dos extensiones acotadas conwherea condiciones distintas del tipo asociado. - Comprueba con dos conformantes qué miembros existen en cada uno y lee el error al llamar al que falta.
- Declara un
struct Caja<T>con conformidad condicional aEquatabley verifica queCajadeIntcompara yCajade una struct no comparable no.