La jerarquía de protocolos: de Sequence a RandomAccessCollection
Los cuatro protocolos que sostienen todo lo que se recorre en Swift, qué garantiza exactamente cada peldaño, por qué el índice no es un entero y cómo elegir la restricción mínima correcta al escribir código genérico.
Cuando escribes for x in cosas no llamas a un método de Array: invocas un contrato. Swift no tiene un tipo privilegiado llamado colección; tiene una torre de protocolos donde cada peldaño añade una garantía y cobra un precio. Subir por esa torre es comprar operaciones más potentes a cambio de exigir más al tipo concreto. Entender qué promete cada nivel es lo que separa el código genérico que funciona por casualidad del que funciona por construcción.
- Distinguir
SequencedeCollectiony entender el consumo destructivo. - Comprender qué garantiza un índice y por qué casi nunca es un entero.
- Situar
BidirectionalCollectionyRandomAccessCollectionpor su coste. - Elegir la restricción mínima al escribir funciones genéricas.
Sequence: el derecho a iterar, quizá una sola vez
El protocolo raíz es asombrosamente pequeño. Todo lo que promete es que puedes pedirle un iterador y sacarle elementos hasta que se acabe:
protocol Sequence {
associatedtype Element
associatedtype Iterator: IteratorProtocol where Iterator.Element == Element
func makeIterator() -> Iterator
}
protocol IteratorProtocol {
associatedtype Element
mutating func next() -> Element?
}
El bucle for-in es puro azúcar sobre esto: el compilador pide un iterador y llama a next hasta recibir nil. Ahora observa lo que Sequence no promete. No promete ser finita: una secuencia puede generar valores para siempre. Y sobre todo, no promete ser multipaso: nada garantiza que recorrerla dos veces dé el mismo resultado, ni siquiera que dé algún resultado.
// una secuencia de un solo uso: al iterarla, la consume
var lineas = AnyIterator { readLine() }
let primeras = Array(AnySequence { lineas })
Una función genérica sobre Sequence que itere dos veces —por ejemplo, contar y luego procesar— compilará sin protestar y fallará en tiempo de ejecución con secuencias destructivas. La regla profesional es tajante: si tu algoritmo necesita más de una pasada, la restricción correcta no es Sequence, es Collection. El compilador no puede detectarlo por ti porque el multipaso es una promesa semántica, no de tipos.
Collection: el índice como coordenada estable
Collection refina Sequence añadiendo lo que falta: finitud, multipaso no destructivo y, sobre todo, un espacio de índices con el que nombrar posiciones:
protocol Collection: Sequence {
associatedtype Index: Comparable
var startIndex: Index { get }
var endIndex: Index { get }
subscript(position: Index) -> Element { get }
func index(after i: Index) -> Index
}
Dos detalles son fundamentales. Primero, el rango es semiabierto: startIndex apunta al primer elemento y endIndex apunta una posición más allá del último. endIndex nunca es un índice válido para el subíndice; en una colección vacía ambos coinciden. Segundo, Index es un tipo asociado cualquiera con orden, no un entero. String usa String.Index, que codifica un desplazamiento en bytes UTF-8 porque un grafema ocupa un número variable de ellos; Dictionary usa un índice que apunta a una posición de su tabla hash. Por eso el acceso por posición se escribe con index(after:) y no con + 1.
let s = "café"
let i = s.index(s.startIndex, offsetBy: 3)
print(s[i]) // é
var it = s.startIndex
while it != s.endIndex {
print(s[it])
it = s.index(after: it) // avanzar es una operacion de la coleccion
}
El contrato exige además que el subíndice y index(after:) sean de coste constante. Un índice no es un puntero seguro: mutar la colección puede invalidarlo, y usarlo después es comportamiento indefinido, no un error atrapable.
Los refinamientos: retroceder y saltar
flowchart TD S[Sequence: iterar una vez] --> C[Collection: indices y multipaso] C --> B[BidirectionalCollection: retroceder en O de uno] B --> R[RandomAccessCollection: saltar en O de uno] C -.-> M[MutableCollection: escribir en su sitio] C -.-> RR[RangeReplaceableCollection: crecer y encoger] style S fill:#f9e2af,color:#11111b style R fill:#a6e3a1,color:#11111b
BidirectionalCollection añade una única exigencia, index(before:) en tiempo constante, y a cambio la biblioteca te regala last, last(where:), suffix, dropLast y un reversed() que devuelve una vista perezosa sin copiar nada. String llega hasta aquí y no más: no puedes saltar al carácter número mil sin recorrer los novecientos noventa y nueve anteriores.
RandomAccessCollection exige que index(_:offsetBy:) y distance(from:to:) también sean constantes. La consecuencia inmediata es que count pasa de lineal a constante, y con ello se abren la ordenación eficiente y la búsqueda binaria escrita a mano. Array y ArraySlice cumplen; Set y Dictionary no, porque su índice recorre celdas de una tabla dispersa y saltar a la posición mil exige visitar las anteriores.
Sequence
Solo promete iterar. Puede ser infinita y puede consumirse al recorrerla. AnySequence, un lector de líneas.
Collection
Finita, multipaso, con índices y subíndice constante. String, Set, Dictionary, Array.
BidirectionalCollection
Retroceder cuesta constante: aparecen last, suffix y reversed. String llega hasta aquí.
RandomAccessCollection
Saltar y medir distancias cuesta constante: count se vuelve gratis. Array y ArraySlice.
Nada de esto lo verifica el compilador. Puedes declarar conformidad a RandomAccessCollection en una lista enlazada y compilará: la torre entera descansa sobre promesas que firmas al conformar, no sobre comprobaciones automáticas. Volveremos a esta idea en la última lección del nivel.
MutableCollection habilita el subíndice de escritura sin cambiar la longitud, y RangeReplaceableCollection permite insertar y borrar. No están en la misma columna que los anteriores: son ejes independientes. Array conforma a los cinco; ArraySlice también, pero no String, que solo es reemplazable por rangos.
Elegir la restricción mínima
La disciplina consiste en pedir lo justo: cada garantía extra excluye tipos de tu API.
// una sola pasada: Sequence basta
func suma<S: Sequence>(_ s: S) -> Int where S.Element == Int {
s.reduce(0, +)
}
// necesita indices y varias pasadas: Collection
func pares<C: Collection>(_ c: C) -> [(C.Element, C.Element)] {
zip(c, c.dropFirst()).map { ($0, $1) }
}
// necesita saltar al centro en tiempo constante: RandomAccess
func mediana<C: RandomAccessCollection>(_ c: C) -> C.Element?
where C.Element: Comparable {
guard !c.isEmpty else { return nil }
return c.sorted()[c.count / 2]
}
La primera acepta un array, un conjunto, un rango y un flujo de líneas de entrada. La tercera solo acepta arrays y rebanadas. Ninguna de las dos es mejor: son contratos distintos, y elegir mal tiene consecuencias asimétricas. Restringir de más produce una API artificialmente cerrada que obliga a los llamantes a materializar arrays innecesarios. Restringir de menos produce algo peor: código que compila para tipos a los que arruinará el rendimiento, porque un count que parecía gratuito resulta ser un recorrido completo dentro de un bucle.
Cuando leas una función ajena, mira primero su restricción genérica. Te dice, sin abrir el cuerpo, cuántas pasadas puede dar sobre los datos y si asume acceso posicional barato. Una función sobre Sequence promete una sola pasada hacia delante; una sobre RandomAccessCollection te está avisando de que su algoritmo salta.
Detente en lo que está ocurriendo aquí, porque es una de las ideas más finas de toda la biblioteca estándar. En casi cualquier otro lenguaje, el coste de una operación es una nota al pie de la documentación: sabes que indexar una lista enlazada es lineal porque alguien lo escribió en un comentario y tú te acordaste. En Swift, ese coste está codificado en el sistema de tipos. RandomAccessCollection no añade un solo método nuevo respecto a BidirectionalCollection: redeclara los mismos requisitos cambiando únicamente su complejidad prometida. Es un protocolo cuyo contenido semántico entero es una afirmación sobre el tiempo de ejecución. Cuando escribes func f de C que conforma a RandomAccessCollection, no estás pidiendo capacidades: estás pidiendo garantías de rendimiento, y el compilador rechazará que le pases un String o un Set no porque les falte una operación, sino porque no pueden ofrecerla al precio que tu algoritmo asume. Esto invierte la relación habitual entre tipos y eficiencia. El sistema de tipos deja de ser solo un filtro de corrección para volverse también un filtro de coste, y una firma genérica se convierte en un teorema pequeño: para todo tipo que pueda saltar en tiempo constante, esta función es lineal. Interioriza la consecuencia práctica: la restricción que eliges no es burocracia sintáctica que hay que satisfacer, es la declaración pública del contrato de rendimiento de tu código. Pedir de más cierra tu API a tipos legítimos; pedir de menos convierte una función en una trampa cuadrática que nadie ve venir hasta que los datos crecen.
- Escribe una función genérica sobre
Sequenceque itere dos veces y pásale unAnySequencedestructivo. Observa el resultado y arréglalo subiendo la restricción. - Recorre un
StringconstartIndex,index(after:)yendIndexsin usarfor-in. Explica por qué no puedes sumar uno al índice. - Comprueba que
Setno conforma aRandomAccessCollectionintentando pasarlo a una función restringida así, y lee el error del compilador. - Mide con un contador cuántas veces se llama a
index(after:)al pedircountsobre unStringfrente a unArray. - Escribe una búsqueda binaria genérica y justifica por qué su restricción debe ser
RandomAccessCollectiony noCollection.