wandres.dev
STRINGS Y TEXTO · Unicode bien hecho

El modelo de String: grafemas y no bytes

Swift eligió que la unidad de una cadena fuera lo que un lector percibe como un carácter y no el byte ni el punto de código, y esa elección tiene un precio medible: contar obliga a recorrer. Esta lección reconstruye el modelo desde el algoritmo de segmentación de texto de Unicode, explica por qué la propiedad count no puede ser la lectura de un campo almacenado, muestra cómo la equivalencia canónica redefine lo que significa que dos cadenas sean iguales, y cierra con las consecuencias de rendimiento y con el hecho incómodo de que el resultado depende de la versión del estándar que haya instalada.

⏱ 18 min

Casi todos los lenguajes te mienten sobre qué es una cadena. Unos te venden un vector de bytes, otros un vector de unidades de dieciséis bits, y en ambos casos la longitud es un número que ya estaba guardado y que devuelven sin pensar. El precio de esa comodidad lo paga el usuario: una letra acentuada mide dos, un emoji de familia mide once, y cortar por la mitad produce basura. Swift tomó la decisión contraria y la sostuvo hasta el final: la unidad de String es el grafema, aquello que una persona señalaría con el dedo y llamaría un carácter. Todo lo que te resulte extraño en esta parte del lenguaje deriva de esa única decisión.

🎯 Al terminar esta lección sabrás
  • Definir el Character de Swift como un cluster de grafemas extendido según el anexo 29 de Unicode.
  • Explicar por qué count es una operación lineal y qué información no está almacenada en la cadena.
  • Distinguir la igualdad por equivalencia canónica de la igualdad binaria y prever cuándo divergen.
  • Reconocer los patrones que convierten un recorrido lineal en uno cuadrático y sustituirlos.

El grafema como unidad

Un Character de Swift no es un byte ni un punto de código: es un cluster de grafemas extendido, la unidad que el anexo 29 del estándar Unicode define como lo que una persona percibe como un carácter. Esa definición no es una metáfora, es un algoritmo: una máquina de estados que recorre la secuencia de escalares y decide, entre cada par consecutivo, si hay frontera o no la hay. Una letra base seguida de una tilde combinante no tiene frontera en medio, así que forman un solo Character. Dos indicadores regionales forman una bandera y tampoco se separan. Una familia de emoji son siete escalares cosidos por juntadores de anchura cero, y sigue siendo un Character.

let precompuesta = "é"          // un escalar, U+00E9
let compuesta = "e\u{0301}"     // e mas tilde combinante

print(precompuesta.count)               // 1
print(compuesta.count)                  // 1
print(compuesta.unicodeScalars.count)   // 2
print(compuesta.utf8.count)             // 3

let familia = "👨‍👩‍👧‍👦"
print(familia.count)                    // 1
print(familia.unicodeScalars.count)     // 7
print(familia.utf8.count)               // 25

Fíjate en lo que acaba de ocurrir. Tres números distintos describen la misma cadena y los tres son correctos, porque responden a tres preguntas distintas: cuántas cosas ve el lector, cuántos puntos de código define Unicode y cuántos bytes ocupa en memoria. Un lenguaje que solo expone el tercero te obliga a fingir que es el primero, y ahí nacen los cortes a mitad de emoji. Swift expone los tres, pero pone el primero en la puerta principal.

El propio Character lleva encima una capa de conveniencia que conviene conocer antes de bajar de nivel. Sabe si es letra, dígito, espacio o signo, sabe su valor numérico cuando lo tiene, y responde a esas preguntas agregando las propiedades de los escalares que lo componen. Para la mayoría de los validadores cotidianos, esa capa basta y evita tener que razonar sobre puntos de código.

let c: Character = "7"
print(c.isNumber, c.wholeNumberValue ?? -1)     // true 7

let letra: Character = "Ñ"
print(letra.isLetter, letra.isUppercase)        // true true

// Un Character puede contener varios escalares; un escalar es indivisible
print(letra.unicodeScalars.count)               // 1
print(Character("👍🏽").unicodeScalars.count)     // 2

// El literal de un solo elemento se infiere como String salvo anotacion
let comoCadena = "a"            // String
let comoCaracter: Character = "a"
📝
Un carácter no cabe en un registro

Como un Character puede contener un número arbitrario de escalares, su tamaño no está acotado por el tipo, y por eso internamente es una cadena diminuta con la misma optimización de almacenamiento en línea que String. Esto explica que no exista aritmética sobre caracteres ni conversión directa a entero: no hay un entero que represente a un cluster. Si lo que necesitabas era un código numérico, la pregunta pertenece al nivel del escalar.

Por qué contar es recorrer

La consecuencia inmediata es que la longitud no está guardada en ninguna parte. Lo que hay en memoria es una secuencia de bytes en UTF-8 y una cabecera con la cantidad de bytes; el número de grafemas no es una función barata de ese dato, porque depende de reglas que solo pueden aplicarse mirando el contenido. Para saber cuántos Character hay, la biblioteca estándar decodifica los bytes a escalares, aplica la segmentación y cuenta los saltos de frontera. Es un recorrido completo, y por eso count sobre String es lineal en el tamaño y no constante.

flowchart TD
A[Bytes UTF8 en memoria] --> B[Decodificar a escalares Unicode]
B --> C[Aplicar reglas de segmentacion del anexo 29]
C --> D[Marcar fronteras de cluster]
D --> E[Un Character por cada cluster]
E --> F[count es el numero de pasos dados]

Esto también explica una omisión que sorprende a quien llega de otros lenguajes: String conforma BidirectionalCollection, no RandomAccessCollection. La conformidad no es un descuido, es una declaración honesta de complejidad. Puedes avanzar y retroceder de a un grafema, pero no puedes saltar al enésimo sin pasar por los anteriores, y el sistema de protocolos lo dice en voz alta en vez de esconderlo detrás de un subíndice que parece barato.

ℹ️
Los números que sí son baratos

isEmpty no cuenta nada: comprueba si el buffer tiene contenido y responde en tiempo constante. utf8.count tampoco recorre: la cadena guarda su longitud en bytes porque esa sí es la magnitud que necesita para gestionar memoria. Si lo que quieres es saber si hay algo, o cuánto ocupará al enviarlo por la red, tienes respuestas inmediatas; el recorrido solo aparece cuando preguntas por grafemas.

La igualdad no es comparar bytes

El compromiso con el grafema llega hasta el operador de igualdad. Swift compara cadenas por equivalencia canónica, según las reglas de normalización de Unicode: dos cadenas son iguales si representan el mismo texto, aunque sus bytes difieran. La letra con tilde precompuesta y la letra seguida de tilde combinante son la misma cadena para Swift, ocupan distinto espacio y producen el mismo valor de hashValue, que es la condición imprescindible para que funcionen como claves de un diccionario.

let a = "café"
let b = "cafe\u{0301}"

print(a == b)                  // true
print(a.utf8.count, b.utf8.count)   // 5 6

var indice: [String: Int] = [:]
indice[a] = 1
print(indice[b] ?? 0)          // 1, la misma clave

// La comparacion binaria hay que pedirla explicitamente
print(Array(a.utf8) == Array(b.utf8))   // false

La implementación no es ingenua: hay un camino rápido que compara los buffers byte a byte y termina en cuanto son idénticos, que es el caso mayoritario, y solo cuando difieren entra la normalización perezosa que compara segmento a segmento sin materializar una copia normalizada. Aun así, el modelo mental correcto es que la igualdad de String es semántica, no física. Si necesitas la física, porque estás calculando una firma criptográfica o comparando un identificador de protocolo, baja a la vista de bytes y compara ahí.

💡
Ordenar es otra cosa distinta

El operador menor que sobre String ordena por el valor de los escalares en su forma canónica, no por el alfabeto de un idioma. Esa ordenación es determinista y estable, pero no es la que espera un usuario español ni un usuario sueco. Para listas visibles usa una comparación sensible al idioma, que en las plataformas de Apple pasa por localizedStandardCompare o por un String.Comparator configurado con el Locale correspondiente.

El coste, y cómo no pagarlo dos veces

Nada de lo anterior es lento en términos absolutos: recorrer una cadena de mil grafemas cuesta microsegundos. El problema aparece cuando el recorrido se anida dentro de otro recorrido y una operación lineal se multiplica hasta volverse cuadrática. Los dos patrones que lo provocan son siempre los mismos y ambos se reconocen a simple vista.

// Cuadratico: count recorre la cadena en cada vuelta
for i in 0..<texto.count {
  usar(texto[texto.index(texto.startIndex, offsetBy: i)])
}

// Lineal: el iterador ya mantiene la posicion
for caracter in texto {
  usar(caracter)
}

// Cuadratico disfrazado: preguntar por vacio contando
if texto.count == 0 { ... }

// Constante
if texto.isEmpty { ... }
🍊

Recorre, no indexes

Si el algoritmo puede expresarse como una pasada de principio a fin, escríbelo con un bucle sobre la cadena o con los algoritmos de Sequence. El índice explícito solo hace falta cuando necesitas volver atrás o guardar una posición.

🍊

Cachea la longitud

Si de verdad necesitas el número de grafemas más de una vez, guárdalo en una constante. La cadena es de valor y no cambia bajo tus pies, así que el valor cacheado sigue siendo válido mientras no la mutes.

🍊

Elige la vista adecuada

Para límites de almacenamiento, cuenta bytes. Para propiedades de Unicode, cuenta escalares. Para lo que verá el usuario, cuenta grafemas. Contar la magnitud equivocada es el error más caro y el más silencioso.

El grafema es una convención con fecha, y tu código depende de ella

Hay un detalle que casi nunca se dice y que cambia la forma de razonar sobre todo esto: la segmentación de grafemas no es una verdad matemática, es una tabla de datos con número de versión. Las reglas del anexo 29 se revisan en cada publicación de Unicode, y cada revisión ajusta cómo se agrupan secuencias nuevas, sobre todo emoji, que llegan por decenas cada año. Eso significa que count sobre la misma cadena puede devolver un número distinto en dos sistemas operativos distintos, porque la biblioteca estándar de Swift, en las plataformas de Apple, consulta las tablas de Unicode que trae el sistema y no las que traía el compilador. La consecuencia práctica es severa: el número de grafemas es una propiedad de presentación, no un dato. Sirve para decidir si un mensaje cabe en un cuadro, para colocar un cursor o para animar una escritura letra a letra, y no sirve para validar longitudes en un formato de intercambio, para calcular un desplazamiento en un archivo binario ni para acordar un límite con un servidor. Cuando dos sistemas tienen que estar de acuerdo, el único acuerdo posible es sobre bytes o sobre escalares, porque son las magnitudes cuya definición no se mueve. Y hay una lección más general escondida ahí. El modelo de Swift es más caro que el de sus contemporáneos precisamente porque decidió representar la unidad humana y no la unidad de la máquina, y esa decisión traslada el coste de la corrección desde el usuario, que antes veía texto roto, hasta el programador, que ahora ve una complejidad lineal y un protocolo que se niega a prometerle acceso aleatorio. Es el mismo intercambio que hace el sistema de tipos con los opcionales: el lenguaje se vuelve más incómodo en el sitio exacto donde el problema es genuinamente difícil, en vez de dejar que la dificultad reaparezca más tarde como un fallo. Entender esto convierte la incomodidad en información.

⚔️ Mide el modelo con tus propias manos
  1. Construye una cadena con al menos una letra combinante, una bandera y un emoji con modificador de tono, y publica una tabla con los cuatro conteos: grafemas, escalares, unidades UTF-8 y unidades UTF-16.
  2. Escribe dos funciones que devuelvan el enésimo carácter, una con subíndice calculado dentro del bucle y otra con un iterador, y mide ambas sobre una cadena de cien mil grafemas. Anota el factor de diferencia.
  3. Crea dos cadenas canónicamente equivalentes con bytes distintos, compruébalas con el operador de igualdad, úsalas como clave del mismo diccionario y después compáralas por su vista de bytes. Explica por escrito los tres resultados.
  4. Toma una cadena con emoji recientes y ejecuta el conteo de grafemas en dos versiones de sistema operativo distintas. Si obtienes el mismo número, documenta por qué era esperable en ese caso concreto.
  5. Revisa un proyecto tuyo y busca todas las apariciones de la propiedad de conteo. Clasifica cada una en presentación o dato, y corrige las de la segunda categoría.