wandres.dev
STRINGS Y TEXTO · Unicode bien hecho

Vistas de la cadena: escalares, UTF-8 y UTF-16

Una misma cadena se puede mirar desde cuatro alturas distintas y cada altura responde a una pregunta distinta. Esta lección presenta las vistas unicodeScalars, utf8 y utf16 como proyecciones sin coste sobre el mismo almacenamiento, explica qué propiedades de Unicode solo existen en el nivel del escalar, justifica por qué UTF-8 es la representación nativa desde Swift 5 y por qué UTF-16 sobrevive como puente con Objective-C, y fija el criterio para decidir cuándo bajar y cuándo no.

⏱ 17 min

Si el grafema es la unidad que ve el usuario, tarde o temprano necesitarás las unidades que ven las otras partes del sistema: el protocolo de red que cuenta bytes, la tabla de Unicode que clasifica puntos de código, el marco de texto de Apple que mide en unidades de dieciséis bits. Swift no te obliga a elegir una sola vez ni a convertir la cadena para bajar de nivel. Expone la misma memoria a través de varias colecciones distintas, todas ellas proyecciones sin copia, y comparte entre ellas el mismo tipo de índice. Aprender a moverte entre esas alturas, y sobre todo a saber en cuál estás, es lo que separa el código que funciona con texto real del que funciona con ejemplos en inglés.

🎯 Al terminar esta lección sabrás
  • Reconocer las cuatro vistas de una cadena y qué unidad expone cada una.
  • Consultar propiedades de Unicode en el nivel donde están definidas, el del escalar.
  • Justificar por qué UTF-8 es el almacenamiento nativo y qué implica para la interoperabilidad.
  • Convertir rangos entre el mundo de Swift y el de Objective-C sin corromper posiciones.

Una cadena, cuatro alturas

Un String no contiene cuatro copias de nada. Contiene un buffer de bytes en UTF-8, y cada vista es un tipo envoltorio que reinterpreta ese mismo buffer con otra unidad de iteración. Character agrupa escalares en clusters; unicodeScalars expone los puntos de código decodificados; utf8 expone los bytes tal cual están; utf16 los recodifica al vuelo. Crear cualquiera de estas vistas cuesta tiempo constante, y todas comparten el tipo String.Index, de modo que una posición obtenida en una vista puede usarse en otra.

let s = "Ñu🚀"

print(Array(s))                      // ["Ñ", "u", "🚀"]
print(Array(s.unicodeScalars))       // ["Ñ", "u", "🚀"] como escalares
print(Array(s.utf8))                 // [195, 145, 117, 240, 159, 154, 128]
print(Array(s.utf16))                // [209, 117, 55357, 56960]

print(s.count, s.unicodeScalars.count, s.utf8.count, s.utf16.count)
// 3 3 7 4

El emoji ilustra el punto entero: es un grafema, es un escalar, son cuatro bytes en UTF-8 y son dos unidades en UTF-16 porque su valor supera el plano básico y necesita un par sustituto. Cualquier código que trate esos cuatro números como intercambiables está roto; lo que cambia entre lenguajes es solo cuándo te enteras.

flowchart TD
ST[String con buffer UTF8] --> CH[Characters o grafemas]
ST --> US[unicodeScalars]
ST --> U8[utf8 vista nativa]
ST --> U16[utf16 vista recodificada]
CH --> IX[String Index compartido]
US --> IX
U8 --> IX
U16 --> IX

Que el índice sea compartido no significa que cualquier posición exista en todas las vistas. Un índice tomado en la vista de bytes puede caer en mitad de un escalar, y ese punto no corresponde a ninguna posición de la vista de grafemas. La biblioteca lo modela con una conversión que devuelve opcional: si la posición coincide con una frontera de la vista destino, obtienes el índice equivalente; si no, obtienes nulo y sabes que estabas mirando dentro de una unidad.

let s = "aé🚀"
let i = s.utf8.index(s.utf8.startIndex, offsetBy: 2)

print(i.samePosition(in: s.unicodeScalars) != nil)   // true, frontera de escalar
print(i.samePosition(in: s) != nil)                  // true, frontera de grafema

let dentro = s.utf8.index(after: i)
print(dentro.samePosition(in: s) == nil)             // true, cae dentro de un escalar

Escalares: donde vive Unicode

El estándar Unicode no define propiedades sobre grafemas ni sobre bytes: las define sobre puntos de código. Si necesitas saber si algo es una letra, un dígito, un signo de puntuación, una marca combinante o un emoji, la pregunta se hace en la vista de escalares, porque es la única altura donde el estándar tiene algo que decir. Unicode.Scalar.Properties expone ese catálogo completo, y es la herramienta correcta para escribir validadores que no sean ingenuos.

for escalar in "a1é🚀".unicodeScalars {
  let p = escalar.properties
  print(escalar, escalar.value, p.isAlphabetic, p.generalCategory, p.isEmoji)
}

// Construir un escalar a partir de un valor puede fallar:
// los sustitutos no son escalares validos
let valido = Unicode.Scalar(0x1F680)     // opcional con valor
let invalido = Unicode.Scalar(0xD800)    // nil

// Normalizacion explicita cuando el formato importa
let compuesta = "e\u{0301}".precomposedStringWithCanonicalMapping
print(compuesta.unicodeScalars.count)    // 1

Fíjate en el inicializador que devuelve opcional. No todos los enteros de veintiún bits son escalares: el rango de sustitutos está reservado para la codificación UTF-16 y Unicode lo declara inválido como punto de código. Swift lo modela con un opcional en lugar de aceptar el valor y explotar más tarde, que es exactamente la política que aplica en el resto del lenguaje.

ℹ️
Cuándo normalizar a mano

La igualdad de String ya compara por equivalencia canónica, así que casi nunca hace falta normalizar para comparar. Sí hace falta cuando el resultado sale del proceso: nombres de archivo, claves que viajan a un servidor que compara bytes, o identificadores que se van a firmar. Ahí eliges una forma canónica, la aplicas y la documentas; dejarlo al azar de cómo teclee el usuario es una fuente de fallos que solo aparecen con acentos.

UTF-8 es la representación nativa

Desde Swift 5, el almacenamiento interno de String es UTF-8. El cambio fue algo más que una optimización: alineó el tipo con el formato dominante de archivos, redes y sistemas de archivos, eliminó una transcodificación en el camino más frecuente y abarató radicalmente la entrega de bytes a una API en C. La consecuencia práctica es que utf8 no es una vista de conveniencia sino la vista barata, y que hay operaciones que ofrecen acceso directo al buffer sin copiar.

var texto = "cabecera"

texto.withUTF8 { buffer in
  enviarPorLaRed(buffer)        // sin copia, buffer contiguo garantizado
}

// De bytes a cadena, reparando lo invalido con el caracter de reemplazo
let bytes: [UInt8] = [0x68, 0x69, 0xFF]
let reparada = String(decoding: bytes, as: UTF8.self)
print(reparada)                  // hi seguido del rombo de reemplazo

// De bytes a cadena, rechazando lo invalido
if let estricta = String(validating: bytes, as: UTF8.self) {
  print(estricta)
} else {
  print("secuencia invalida")
}

La diferencia entre esos dos inicializadores es la diferencia entre una interfaz de usuario y un analizador de protocolo. El que repara nunca falla y sustituye cada secuencia mal formada por el carácter de reemplazo, que es lo que quieres para mostrar algo antes que nada; el que valida devuelve opcional y te deja decidir, que es lo que quieres cuando unos bytes corruptos significan que la trama entera es sospechosa. Elegir el primero por costumbre convierte errores de transmisión en rombos silenciosos.

UTF-16, el puente que no se ha ido

UTF-16 no está ahí por gusto. Es la representación de NSString, y con ella la de NSRange, NSAttributedString, NSRegularExpression, TextKit y buena parte del ecosistema de Apple. Mientras exista una sola API en Objective-C que reciba longitudes o rangos, la vista utf16 es la moneda de cambio, y mezclar sus posiciones con las de Swift es la causa número uno de cortes a mitad de emoji en aplicaciones reales.

let s = "Hola 🚀 mundo"

// De un rango de Swift a NSRange y vuelta, sin aritmetica manual
if let rango = s.range(of: "mundo") {
  let ns = NSRange(rango, in: s)
  if let devuelta = Range(ns, in: s) {
    print(s[devuelta])           // mundo
  }
}
🍊

Nunca restes posiciones

Convertir entre rangos con los inicializadores dedicados es correcto en presencia de pares sustitutos; calcular desplazamientos a mano con distance sobre la vista equivocada no lo es. El error solo aparece cuando alguien escribe un emoji, es decir, en producción.

🍊

Sube en cuanto puedas

Recibe el NSRange, conviértelo en la frontera y trabaja con índices de Swift hacia dentro. Cuanto más corto sea el tramo de código que razona en unidades de dieciséis bits, menos superficie tiene el problema.

🍊

Desplazarse no es gratis

Saltar a la posición número mil de la vista UTF-16 exige recorrer, aunque Swift mantiene marcas internas que amortizan el coste de los saltos repetidos. Cuenta con que es lineal la primera vez.

Las vistas son la respuesta de Swift a un dilema que otros lenguajes resolvieron eligiendo bando

Vale la pena ver el diseño completo de golpe, porque es una respuesta poco común a un problema muy viejo. Toda biblioteca de texto se enfrenta a la misma tensión: la unidad correcta para el usuario, la unidad correcta para el estándar y la unidad correcta para la máquina no coinciden, y no pueden coincidir. Los lenguajes anteriores resolvieron la tensión eligiendo un bando y viviendo con las consecuencias: los que eligieron bytes obligan a cada aplicación a reimplementar la segmentación si quiere no romper texto, y los que eligieron unidades de dieciséis bits se quedaron con una decisión de los años noventa incrustada en el corazón de su modelo, incluida la ficción de que dieciséis bits bastaban. Swift hace algo distinto: no elige, expone las alturas como colecciones de pleno derecho sobre el mismo almacenamiento, y unifica el sistema de coordenadas para que moverse entre ellas no exija recalcular nada. El efecto conceptual es que la pregunta cuál es la longitud de esta cadena deja de tener respuesta y pasa a ser una pregunta mal formulada, que es exactamente lo que siempre fue. En su lugar, el programador tiene que decir para qué quiere la longitud, y al decirlo elige la vista, y al elegir la vista el código documenta su propia intención. Lo que parece una proliferación de opciones es en realidad lo contrario de la ambigüedad: cada vista es un compromiso explícito sobre qué pregunta estás haciendo. Y hay una moraleja de diseño de API que trasciende el texto. Cuando un dominio tiene varias descripciones legítimas e irreconciliables de la misma cosa, la salida no es inventar una síntesis que traicione a todas, sino ofrecer las descripciones como proyecciones baratas y coherentes entre sí, con una noción compartida de posición que impida mezclarlas por accidente. Eso es lo que hacen las vistas, y por eso siguen siendo el ejemplo más citado de la biblioteca estándar cuando alguien discute cómo modelar realidades múltiples sin duplicar datos.

⚔️ Baja y sube por las alturas
  1. Escribe una función que reciba una cadena y devuelva una tabla con las cuatro longitudes, y pruébala con texto latino acentuado, con una bandera y con un emoji compuesto.
  2. Implementa un validador de nombres de usuario que acepte solo letras y dígitos según Unicode, usando las propiedades del escalar en lugar de rangos de caracteres escritos a mano.
  3. Toma una secuencia de bytes con una secuencia UTF-8 mal formada y decodifícala con las dos vías, la que repara y la que valida. Documenta qué comportamiento querrías en un lector de archivos y cuál en un cliente de red.
  4. Localiza una subcadena, conviértela a rango de Objective-C, vuelve a convertirla y comprueba que el texto recuperado es idéntico. Repite el experimento colocando un emoji antes de la coincidencia.
  5. Busca en un proyecto tuyo cualquier cálculo de posiciones que use una longitud tomada de una vista y un índice tomado de otra. Corrige y anota qué caso concreto lo habría roto.