wandres.dev
COLECCIONES A FONDO · protocolos y algoritmos

Slices y ArraySlice: vistas sobre una colección sin copiar

Qué es realmente una subsecuencia, por qué rebanar cuesta tiempo constante, el peligro de los índices heredados que no empiezan en cero, la retención silenciosa del búfer completo y las reglas para pasar de vista a valor sin sorpresas.

⏱ 18 min

prefix, suffix, dropFirst, split y el subíndice por rango no te devuelven un array: te devuelven una ventana sobre el array que ya tenías. Rebanar cuesta tiempo constante porque no se copia ni un byte. Ese regalo viene con dos facturas que casi nadie lee hasta que las paga en producción: los índices de la ventana son los del original, y la ventana mantiene vivo todo el almacenamiento del original aunque solo mires tres elementos de un millón.

🎯 Al terminar esta lección sabrás
  • Entender qué es SubSequence y por qué rebanar es de coste constante.
  • Manejar índices heredados sin caer en el error de suponer que empiezan en cero.
  • Reconocer la retención del búfer completo y cuándo obliga a copiar.
  • Aprovechar las rebanadas para algoritmos recursivos sin asignaciones.

Una vista, no una copia

Cada colección declara un tipo asociado SubSequence que representa un tramo de sí misma. Para Array es ArraySlice, para String es Substring, y para los tipos que no definen uno propio la biblioteca ofrece el genérico Slice. Todos comparten la misma idea: guardar una referencia al almacenamiento base más un par de índices que delimitan la ventana.

let base = [10, 20, 30, 40, 50]
let trozo = base[2...]          // ArraySlice<Int>, coste constante
print(trozo)                    // [30, 40, 50]
print(type(of: trozo))          // ArraySlice<Int>
flowchart LR
B[bufer base con cinco elementos] --> S[slice: referencia mas rango dos a cinco]
S --> I[startIndex vale dos]
S --> E[endIndex vale cinco]
S --> R[el bufer entero sigue vivo]
style S fill:#89b4fa,color:#11111b
style R fill:#f38ba8,color:#11111b

Por eso una rebanada conforma a los mismos protocolos que su base: es una Collection de pleno derecho, indexable, recorrible y a menudo mutable. El diseño es deliberado: permite escribir algoritmos que trabajan sobre tramos sin asignar memoria en cada paso.

Los índices heredados

Aquí está la trampa que produce más caídas en tiempo de ejecución de todo el tema. Una rebanada conserva los índices del original:

let base = [10, 20, 30, 40, 50]
let trozo = base[2...]

print(trozo.startIndex)   // 2, no 0
print(trozo.count)        // 3
print(trozo[2])           // 30, el primer elemento
print(trozo[0])           // CRASH: indice fuera de rango

La decisión es coherente: si los índices se renumeraran, trozo[0] y base[0] nombrarían cosas distintas y sería imposible usar un índice obtenido en la rebanada para volver a la base. El precio es que cualquier código que asuma que una colección empieza en cero está roto en cuanto le pasas una rebanada.

// FRAGIL: solo funciona si la coleccion empieza en cero
func primero<C: Collection>(_ c: C) -> C.Element { c[c.startIndex] }   // correcto
// c[0] ni siquiera compilaria en generico: el indice no es un entero

// CUIDADO: enumerated da desplazamientos desde cero, NO indices
for (i, x) in trozo.enumerated() { print(i, x) }   // 0,30  1,40  2,50
for i in trozo.indices { print(i, trozo[i]) }      // 2,30  3,40  4,50
⚠️
`enumerated` no devuelve índices

El primer componente de enumerated es un contador que empieza en cero, no un índice de la colección. Sobre un Array completo coinciden por casualidad; sobre una rebanada, un String o un Set no coinciden nunca. Cuando necesites el índice real, usa zip(c.indices, c).

La retención silenciosa del búfer

Una rebanada mantiene una referencia fuerte al almacenamiento completo de la base. Mientras la vista viva, el búfer entero está vivo:

var ultimos: ArraySlice<Registro> = []

func procesar(_ todos: [Registro]) {
    ultimos = todos.suffix(3)     // guarda 3 elementos... y retiene el millon
}

Si todos tiene un millón de registros, esa propiedad de tres elementos impide liberar el millón. Lo mismo ocurre con Substring: guardar la subcadena de un documento de diez megabytes mantiene los diez megabytes en memoria. La solución es explícita y consiste en convertir la vista en un valor propio:

var ultimos: [Registro] = []
ultimos = Array(todos.suffix(3))          // copia real: libera el resto del bufer
let nombre = String(documento.prefix(20)) // copia real: libera el documento entero

De ahí la convención de la biblioteca: las rebanadas son para usar y tirar dentro de un ámbito; en cuanto una subsecuencia va a sobrevivir a la función que la creó —guardarse en una propiedad, devolverse en una API pública, entrar en un caché— hay que materializarla.

El caso más traicionero es split, porque produce muchas vistas de golpe:

let campos = linea.split(separator: ",")   // [Substring], todas retienen la linea
cache[clave] = campos                       // el documento entero queda vivo
cache[clave] = campos.map(String.init)      // correcto: copias independientes

La regla operativa cabe en una frase: la frontera de un tipo público es la frontera de la materialización. Dentro de una función, trabaja con vistas y disfruta del coste cero; al devolver o almacenar, decide y copia.

💡
Mutar una rebanada dispara la copia sobre escritura

ArraySlice es un tipo de valor y participa en la copia sobre escritura como cualquier array. Escribir en una rebanada mientras el búfer está compartido provoca la copia del búfer completo, no solo del tramo. Es otra razón para no mantener rebanadas vivas más tiempo del necesario.

Rebanadas como herramienta: recursión sin asignaciones

Bien usadas, las subsecuencias son la forma idiomática de escribir algoritmos de divide y vencerás sin reservar memoria en cada nivel de la recursión:

func busquedaBinaria<C: RandomAccessCollection>(_ c: C, _ objetivo: C.Element) -> C.Index?
    where C.Element: Comparable {
    guard !c.isEmpty else { return nil }
    let medio = c.index(c.startIndex, offsetBy: c.count / 2)
    if c[medio] == objetivo { return medio }
    if c[medio] < objetivo {
        return busquedaBinaria(c[c.index(after: medio)...], objetivo)
    }
    return busquedaBinaria(c[..<medio], objetivo)
}

Observa que la función devuelve un índice de la rebanada que sigue siendo válido en la base, precisamente porque los índices se heredan. Lo que parecía un inconveniente es aquí la propiedad que hace correcto el algoritmo: si cada nivel de la recursión renumerase desde cero, el índice devuelto por la llamada más profunda no significaría nada arriba, y habría que ir sumando desplazamientos a mano en cada retorno.

🍊

Rebanar es constante

El subíndice por rango, prefix, suffix y dropFirst no copian: guardan referencia y límites.

🍋

Los índices se heredan

startIndex no es cero. Usa startIndex, indices o first, nunca literales.

🍇

La base sigue viva

Una vista de tres elementos puede retener un búfer de un millón o un texto de diez megabytes.

🍒

Materializa en la frontera

Dentro de una función, vistas. Al devolver, almacenar o cachear, Array o String.

Una rebanada es una referencia con semántica de valor, y ahí vive toda la tensión

Detente en la contradicción aparente. ArraySlice es un struct: se copia al asignarlo, se compara por contenido, no tiene identidad. Y sin embargo, por dentro es una referencia compartida a un búfer que no le pertenece más otro par de números. Swift ha construido, encima de un puntero compartido, algo que se comporta ante ti como un valor independiente, y esa ilusión es casi perfecta: casi. Se rompe exactamente en los dos puntos que has visto, y ambos son fugas del mismo secreto. Los índices heredados delatan que la rebanada no es el origen de su propio espacio de coordenadas, sino una ventana anotada sobre el espacio de otro. La retención del búfer completo delata que la rebanada no posee sus datos, sino que los toma prestados y, al ser un valor sin ámbito léxico limitado, prolonga ese préstamo indefinidamente. Otros lenguajes resuelven la segunda mitad con tiempos de vida en el sistema de tipos: un slice de Rust no puede sobrevivir a lo que observa porque el compilador lo prohíbe. Swift eligió no pagar ese precio en complejidad y te dejó a ti la responsabilidad, sustituyendo la verificación por una convención cultural: las subsecuencias son efímeras, y cruzar con una de ellas la frontera de una función, de una propiedad o de un caché es una decisión que debes tomar conscientemente, no por omisión. Interiorizar esto reordena tu forma de leer una firma: cuando una función devuelve SubSequence, no está devolviendo datos, está devolviendo una obligación de decidir, y el momento de decidir es ahora, no cuando el perfil de memoria empiece a subir sin explicación.

⚔️ Domina la ventana
  1. Rebana un array por la mitad y provoca a propósito el fallo de acceder al índice cero. Después arréglalo con startIndex.
  2. Recorre la misma rebanada con enumerated y con zip(c.indices, c) y explica por qué los primeros componentes difieren.
  3. Guarda un suffix(3) de un array grande en una propiedad y razona cuánta memoria queda retenida. Corrígelo materializando.
  4. Comprueba que documento.prefix(10) es un Substring y mide la diferencia de retención frente a construir un String.
  5. Escribe un algoritmo recursivo sobre rebanadas y verifica que el índice devuelto sirve para indexar la colección original.