wandres.dev
STRINGS Y TEXTO · Unicode bien hecho

Índices y Substring: navegar sin romper el texto

La ausencia de subíndice entero en String es la consecuencia lógica del modelo de grafemas, no un capricho de diseño. Esta lección explica qué guarda realmente un String.Index y por qué solo es válido para la cadena que lo produjo, recorre las operaciones de navegación y sus costes, presenta Substring como una rebanada que comparte el almacenamiento del padre, y detalla el peligro poco conocido de retener una rebanada pequeña de un documento enorme.

⏱ 18 min

La primera vez que intentas escribir el equivalente de acceder al quinto carácter de una cadena, Swift te dice que no. No hay un subíndice que acepte enteros y no lo habrá, y la respuesta oficial suele quedarse en que las cadenas son complicadas. La respuesta real es más interesante: ofrecer ese subíndice sería mentir sobre el coste, porque la operación es lineal y todo el mundo la escribiría como si fuera constante. En su lugar, Swift te obliga a manipular un tipo opaco cuya única función es hacer visible el recorrido, y a cambio te regala rebanadas que no copian nada. Las dos caras de ese trato, la incomodidad y el regalo, son el tema de esta lección.

🎯 Al terminar esta lección sabrás
  • Explicar por qué String no ofrece subíndice por entero y qué complejidad esconde esa ausencia.
  • Describir qué representa un String.Index y por qué está atado a la cadena que lo creó.
  • Usar las operaciones de navegación y conversión sin caer en recorridos repetidos.
  • Anticipar el consumo de memoria de un Substring retenido y decidir cuándo copiar.

Por qué no hay subíndice por entero

En un vector de elementos de tamaño fijo, llegar al elemento número cinco es aritmética: dirección base más cinco veces el tamaño. En una cadena UTF-8 segmentada en grafemas no hay ninguna aritmética posible, porque ni los escalares ocupan lo mismo ni los grafemas contienen el mismo número de escalares. Llegar al quinto grafema exige empezar por el primero y aplicar la segmentación cuatro veces. Un lenguaje puede ocultar eso tras un subíndice de aspecto inocente, y entonces cada bucle escrito por costumbre se vuelve cuadrático sin que nadie lo note; o puede negarse, y entonces el coste queda escrito en el código. Swift se niega.

let texto = "Hola, mundo"

let inicio = texto.startIndex
let quinto = texto.index(inicio, offsetBy: 4)
print(texto[quinto])                  // "a"

// Antes del final, nunca el final: endIndex esta una posicion mas alla
let ultimo = texto.index(before: texto.endIndex)
print(texto[ultimo])                  // "o"

// Version segura cuando el desplazamiento puede pasarse
if let seguro = texto.index(inicio, offsetBy: 40, limitedBy: texto.endIndex) {
  print(texto[seguro])
} else {
  print("fuera de rango")
}

Conviene fijar dos hechos desde el principio. endIndex no señala al último elemento sino a la posición siguiente al último, así que usarlo como subíndice es un error de programación que detiene el proceso. Y la variante limitada existe porque desplazarse más allá del final también lo es: en Swift, salirse de rango no devuelve un opcional por defecto, interrumpe la ejecución, y las funciones con la etiqueta de límite son la forma de convertir ese fallo en un valor con el que puedas razonar.

Qué es realmente un índice

Un String.Index no es un entero disfrazado. Guarda una posición dentro del almacenamiento UTF-8 del receptor, junto con información de cacheado sobre la vista y sobre la frontera de grafema en la que cayó. Esa carga extra es lo que permite que el mismo índice sirva para las distintas vistas de la cadena sin recalcular nada, y es también lo que lo ata a un almacenamiento concreto: un índice obtenido de una cadena y usado en otra distinta produce, en el mejor de los casos, un resultado sin sentido, y en el peor una interrupción del proceso. Los índices son válidos para la cadena que los generó y para las rebanadas que comparten su almacenamiento; nada más.

flowchart LR
S[String con buffer UTF8] --> I[String Index opaco]
I --> P[Desplazamiento en bytes]
I --> G[Frontera de grafema cacheada]
S --> SS[Substring como rebanada]
SS --> B[Comparte el mismo buffer]
B --> R[Los indices del padre siguen siendo validos]

De ahí se sigue la regla práctica: los índices no se guardan entre mutaciones. Si insertas o eliminas texto, los índices anteriores dejan de significar lo que significaban, aunque sigan siendo formalmente utilizables. Lo que sí puedes guardar sin miedo es un Range sobre una cadena inmutable, o mejor todavía, guardar la rebanada en sí y olvidarte de las posiciones.

ℹ️
Las búsquedas devuelven índices, no números

firstIndex(of:), range(of:) y los operadores de expresiones regulares devuelven índices o rangos de índices, precisamente para que no tengas que reconstruirlos. Si necesitas la distancia en grafemas para mostrarla o para registrarla, distance(from:to:) te la da, pero recuerda que esa conversión vuelve a recorrer. El camino barato es quedarse en el mundo de los índices y no bajar nunca a enteros.

Substring: la rebanada que no copia

Cuando cortas una cadena, Swift no te devuelve un String sino un Substring. La diferencia de tipo es deliberada y expresa una diferencia de propiedad: el Substring no tiene almacenamiento propio, apunta a un rango dentro del almacenamiento del padre y lo mantiene vivo mediante el conteo de referencias. Crear uno cuesta tiempo constante y cero bytes de copia, lo que hace que trocear un documento grande sea gratis mientras no conviertas nada.

let linea = "nombre=valor"

let clave = linea.prefix(while: { $0 != "=" })    // Substring
let resto = linea.drop(while: { $0 != "=" })      // Substring
let valor = resto.dropFirst()                     // Substring

print(type(of: clave))          // Substring
print(clave.startIndex == linea.startIndex)   // true

// Los indices se comparten con el padre y entre rebanadas
let campos = "a,b,c".split(separator: ",")      // [Substring]

// Convertir es lo que copia
let claveFinal = String(clave)

Un detalle que sorprende: el startIndex de un Substring no es cero ni equivale al principio del padre salvo que la rebanada empiece ahí. La rebanada hereda el sistema de coordenadas de la cadena original, lo que es exactamente lo que quieres para poder cruzar índices entre ambas, y exactamente lo que rompe el código escrito con la suposición de que toda colección empieza en su propio origen. Es el mismo malentendido que produce fallos con ArraySlice, y la cura es la misma: usa startIndex de la rebanada, nunca una constante.

🍊

Cortar es gratis

Encadenar recortes por el principio y por el final no asigna memoria ni copia bytes. Un analizador que consume su entrada a base de rebanadas hace un solo recorrido y cero asignaciones, que es el mejor resultado alcanzable.

🍊

Convertir es lo que cuesta

Cada conversión a cadena asigna un buffer nuevo y copia. Dentro de un bucle, esa conversión es el único coste real del algoritmo, y suele ser también el único que se puede eliminar sin cambiar nada más.

🍊

La mutación invalida

Insertar o borrar reorganiza el almacenamiento y deja sin sentido los índices tomados antes. Si el algoritmo necesita mutar y recordar posiciones, recalcula después de cada mutación o construye el resultado en una cadena nueva.

💡
Firma con String, trabaja con Substring

La guía de la biblioteca estándar es clara: las funciones públicas deberían aceptar y devolver String, y el uso de Substring debería quedar dentro de la implementación. Escribir todo genérico sobre StringProtocol parece más flexible pero contagia genéricos a toda la base de código, complica los diagnósticos del compilador y rara vez aporta rendimiento. Convierte en la frontera y trabaja con rebanadas dentro.

El peligro de retener una rebanada

Aquí está el coste oculto del regalo. Como el Substring mantiene vivo el almacenamiento completo del padre, guardar una rebanada de diez caracteres extraída de un documento de cincuenta megabytes mantiene los cincuenta megabytes en memoria mientras esa rebanada exista. No es una fuga en sentido estricto, porque la memoria se libera cuando se libera la rebanada, pero se comporta como tal: el perfilador te muestra un consumo enorme y el objeto responsable es minúsculo.

func extraerTitulo(de documento: String) -> Substring {
  documento.prefix(while: { $0 != "\n" })   // retiene todo el documento
}

func extraerTituloSeguro(de documento: String) -> String {
  String(documento.prefix(while: { $0 != "\n" }))   // copia y libera
}

El caso más traicionero no es el de una función que devuelve una rebanada, porque ese se ve leyendo la firma, sino el de una estructura que la guarda de refilón. Un diccionario construido con rebanadas como claves, una caché de resultados de análisis, o un modelo que almacena el campo extraído de la línea original: en los tres casos el tipo del contenedor delata el problema, pero el contenedor suele estar declarado lejos del sitio donde se hizo el corte.

// Cada clave mantiene viva la linea completa de la que salio
var indice: [Substring: Int] = [:]

for (n, linea) in documento.split(separator: "\n").enumerated() {
  let clave = linea.prefix(while: { $0 != ":" })
  indice[clave] = n            // el documento entero queda retenido
}

// Copiar en la frontera del contenedor lo resuelve
var indiceSeguro: [String: Int] = [:]
for (n, linea) in documento.split(separator: "\n").enumerated() {
  indiceSeguro[String(linea.prefix(while: { $0 != ":" }))] = n
}

La regla que resuelve el noventa por ciento de los casos cabe en una frase: si la rebanada va a sobrevivir al ámbito donde se creó, conviértela; si va a morir antes que el padre, no la conviertas. Todo el trabajo intermedio de un analizador, de un tokenizador o de un filtro puede y debe hacerse con rebanadas, porque su vida es corta y encadenar cortes sin copiar es justamente lo que hace que ese trabajo sea rápido. Lo que se guarda en un modelo, en una caché o en una propiedad de larga vida se guarda como String.

La rebanada es el lugar donde el lenguaje te enseña quién es el dueño de la memoria

Merece la pena detenerse en lo que este par de tipos está enseñando, porque va mucho más allá del texto. Swift podría haber hecho que cortar una cadena devolviera otra cadena, copiando, y nadie habría protestado; la mayoría de los lenguajes lo hacen así y el coste se diluye en el recolector de basura. En vez de eso, la biblioteca estándar introduce un tipo distinto cuya única razón de ser es hacer visible una relación de propiedad que de otro modo sería invisible. El tipo dice, en la firma de cada función, esto no es tuyo, esto vive dentro de otra cosa. Y como el sistema de tipos lo dice, el problema deja de ser una cuestión de disciplina y se convierte en algo sobre lo que puedes razonar localmente: cuando ves un Substring en un campo almacenado de una clase de larga vida, no necesitas ejecutar nada ni abrir un perfilador para saber que tienes un problema de retención, lo ves leyendo. Esa es la misma jugada que hacen las semánticas de valor con la copia, las clases con la identidad y los actores con el aislamiento: coger una propiedad que en otros lenguajes solo existe en la cabeza del programador y darle un nombre en el sistema de tipos, para que el compilador y el lector la vean. La incomodidad de tener que escribir la conversión no es un impuesto, es el mecanismo por el que la decisión se vuelve explícita, y en el sitio exacto donde importa: la frontera entre lo temporal y lo persistente. Cuando entiendas eso, dejarás de leer Substring como una molestia y empezarás a leerlo como lo que es, una anotación de tiempo de vida escrita en el tipo.

⚔️ Domina las coordenadas del texto
  1. Escribe una función que devuelva el enésimo grafema como opcional, usando la variante limitada del desplazamiento, y demuestra con un caso que no interrumpe el proceso cuando el índice se pasa del final.
  2. Toma una cadena, extrae una rebanada del medio y comprueba si su startIndex es igual al del padre y si sus índices sirven para indexar la cadena original. Explica ambos resultados.
  3. Implementa un analizador de líneas de configuración que trocee mil líneas usando solo rebanadas, y otra versión que convierta a cadena en cada paso. Compara tiempo y memoria asignada.
  4. Reproduce el caso de retención: guarda en una propiedad una rebanada de un documento de varios megabytes, mide la memoria del proceso y repite guardando la conversión. Anota la diferencia.
  5. Recorre un proyecto tuyo buscando propiedades almacenadas cuyo tipo sea Substring o genérico sobre StringProtocol. Justifica cada una o conviértela.