wandres.dev
STRINGS · inmutables e internadas

Unicode y memoria: utf8, Lua 5.5 y cadenas externas

Lua trabaja con bytes, no con caracteres. Qué ofrece realmente la biblioteca utf8 y qué deja fuera, el nuevo utf8.offset de Lua 5.5 que devuelve también la posición final, y las cadenas externas que envuelven memoria ajena al asignador.

⏱ 18 min

Conviene decirlo sin rodeos: Lua no tiene soporte de Unicode. Lo que tiene es una biblioteca auxiliar, utf8, que sabe interpretar una secuencia de bytes como si estuviera codificada en UTF-8 y ofrece las cuatro o cinco operaciones imprescindibles para recorrerla. Todo lo demás —normalización, comparación cultural, cambio de caja consciente del idioma, agrupación en clusters de grafemas, anchura de presentación— queda fuera, y queda fuera a propósito. Este nivel cierra el bloque de cadenas mirando en las dos direcciones que más han cambiado en 2025: qué garantiza y qué no garantiza esa biblioteca tras las mejoras de Lua 5.5, y cómo las nuevas cadenas externas rompen por fin el supuesto de que toda cadena vive en memoria del asignador de Lua.

🎯 Al terminar esta lección sabrás
  • Separar con precisión el nivel byte, el nivel punto de código y el nivel grafema.
  • Recorrer y medir texto UTF-8 con utf8.len, utf8.codes y utf8.offset.
  • Conocer el cambio de utf8.offset en Lua 5.5 y qué problema resuelve.
  • Entender qué es una cadena externa, qué invariantes exige y cuándo compensa.

Bytes primero, caracteres después

Una cadena Lua es una secuencia de bytes con longitud explícita. El operador #, string.sub, string.byte y todo el resto de la biblioteca string operan sobre bytes y no tienen la menor idea de codificaciones. Eso no es una carencia: es lo que permite que una cadena albergue igualmente un texto en japonés, el contenido de un fichero PNG o un buffer de red sin conversiones ni supuestos.

local s = "año"                -- 4 bytes en UTF-8
print(#s)                      --> 4
print(utf8.len(s))             --> 3
print(s:sub(1, 2))             --> "a" mas medio caracter: bytes rotos
print(s:upper())               --> "AÑO" no: la enne acentuada sale intacta

La biblioteca utf8 añade la capa de interpretación. utf8.char construye la codificación de una lista de puntos de código; utf8.codepoint hace el camino inverso sobre un rango; utf8.len cuenta caracteres y, si encuentra bytes inválidos, devuelve nil más la posición del fallo, lo que la convierte también en un validador; utf8.codes es el iterador canónico, que produce la posición en bytes y el punto de código de cada carácter; y utf8.charpattern es un patrón que casa exactamente un carácter, pensado para combinarse con el motor de patrones.

for pos, cp in utf8.codes("añoß") do
  print(pos, cp, utf8.char(cp))
end
--> 1  97   a
--> 2  241  ñ
--> 4  111  o
--> 5  223  ß

print(utf8.len("a\xFFb"))      --> nil  2   (byte invalido en la posicion 2)

Lo que la biblioteca no ofrece es tan importante como lo que ofrece. No normaliza: la forma compuesta y la descompuesta de una misma letra acentuada son dos cadenas distintas y desiguales. No hace cambio de caja fuera de ASCII. No ordena según convenciones culturales. No agrupa en grafemas, de modo que un emoji con modificador de tono de piel o una familia compuesta con uniones de ancho cero cuentan como varios puntos de código aunque el usuario vea un solo símbolo. Y no calcula anchura de columna en terminal. Para todo eso se necesita una biblioteca externa, y conviene saberlo antes de prometer soporte internacional.

Dos ejemplos concretos bastan para calibrar el hueco. El primero es la normalización: la letra e con acento agudo admite dos codificaciones legítimas, una compuesta en un solo punto de código y otra descompuesta en la letra base seguida de un acento combinante. Para el usuario son el mismo carácter; para Lua son dos cadenas distintas, desiguales, con longitudes distintas y hashes distintos.

local compuesta = "caf\u{e9}"                -- e acentuada en un punto
local descompuesta = "cafe\u{301}"           -- e mas acento combinante

print(#compuesta, #descompuesta)             --> 5   6
print(utf8.len(compuesta), utf8.len(descompuesta))  --> 4   5
print(compuesta == descompuesta)             --> false

El segundo es el grafema. Un emoji con modificador, una bandera regional o una familia compuesta son varios puntos de código que el usuario percibe como un único símbolo, y ninguna función de la biblioteca los agrupa. Si tu programa trunca un nombre para que quepa en una columna, contar puntos de código puede partir un símbolo por la mitad exactamente igual que contar bytes partía una secuencia UTF-8.

flowchart TD
A[Secuencia de bytes] --> B[Nivel byte: operador de longitud y biblioteca string]
B --> C[Nivel punto de codigo: biblioteca utf8]
C --> D[Nivel grafema: fuera de la biblioteca estandar]
D --> E[Normalizacion y ordenacion cultural: siempre externas]

Lo que cambió en Lua 5.5

Lua 5.5, publicado en diciembre de 2025, trajo dos novedades directamente relevantes para este nivel.

La primera afecta a utf8.offset, la función que traduce un índice de carácter a un índice de byte. Hasta ahora devolvía un único valor: la posición inicial del carácter buscado. Quien necesitaba el carácter completo tenía que volver a llamar pidiendo el siguiente y restar uno, una danza incómoda y propensa a errores en los extremos. En 5.5 la función devuelve dos valores: la posición inicial y la posición final del carácter. Extraer el enésimo carácter pasa a ser una línea directa.

local s = "añoß"

-- Antes: dos llamadas y aritmetica de contorno
local i = utf8.offset(s, 2)
local j = utf8.offset(s, 3) - 1
print(s:sub(i, j))                 --> ñ

-- En Lua 5.5: una sola llamada
local ini, fin = utf8.offset(s, 2)
print(s:sub(ini, fin))             --> ñ

Merece la pena detenerse en los detalles de esa función, porque es la bisagra entre los dos niveles de índice. Acepta posiciones negativas para contar caracteres desde el final, admite un tercer argumento que fija el byte desde el que empezar a contar, y devuelve un valor nulo cuando el carácter pedido cae fuera del texto en lugar de provocar un error. Combinada con utf8.len, que también acepta un rango de bytes, permite recorrer y trocear texto sin construir subcadenas intermedias.

local s = "programación"

print(utf8.len(s))                     --> 12 caracteres
print(#s)                              --> 13 bytes

-- El ultimo caracter, con inicio y fin en una sola llamada
local i, j = utf8.offset(s, -1)
print(s:sub(i, j))                     --> n

-- Recorrer sin crear objetos: solo indices
local ini, fin = utf8.offset(s, 3)
print(ini, fin, utf8.codepoint(s, ini))

La segunda novedad es de otra escala: las cadenas externas. Hasta 5.5, crear una cadena en Lua implicaba siempre copiar sus bytes a memoria reservada por el asignador del estado. Si tu programa anfitrión ya tenía el contenido en un buffer propio —un fichero proyectado en memoria, una región compartida, un cuerpo de petición ya recibido— entregárselo a Lua costaba una copia íntegra. Con la nueva primitiva de la API C, el anfitrión puede crear una cadena que apunta a memoria que Lua no gestiona, sin copiar un solo byte, y proporcionar opcionalmente la función que la liberará cuando el recolector determine que ya nadie la referencia.

flowchart LR
A[Fichero proyectado en memoria por el anfitrion] --> B[Buffer fuera del asignador de Lua]
B --> C[Se crea una cadena externa sin copiar]
C --> D[Es siempre cadena larga y nunca se interna]
D --> E[El recolector invoca la funcion de liberacion al morir]

Invariantes y criterio de uso

Desde el código Lua una cadena externa es indistinguible de cualquier otra: misma semántica, mismas funciones, misma inmutabilidad. Toda la responsabilidad recae en el lado C, y son tres condiciones que no admiten matices.

La primera es que el buffer debe permanecer válido mientras la cadena esté viva, y su tiempo de vida lo decide el recolector, no tu función. Liberar la región por tu cuenta produce un acceso a memoria liberada dentro del intérprete. La segunda es que el contenido debe ser inmutable: si el anfitrión modifica los bytes por debajo, rompe el invariante fundamental del tipo y con él toda comparación, hash o clave de tabla que dependa de esa cadena. La tercera es de interoperabilidad: para que el buffer pueda entregarse a funciones que esperan una cadena al estilo de C, debe terminar en un byte cero situado justo detrás del último byte útil.

Hay además una consecuencia que se deduce del nivel anterior y que conviene hacer explícita: una cadena externa es siempre una cadena larga. No podría ser de otro modo, porque internar exige registrar el contenido en la tabla global y compartir un único objeto, y compartir es incompatible con envolver un buffer concreto que pertenece a otro. De ahí se sigue que su igualdad compara longitudes y luego bytes, que su hash se calcula de forma perezosa solo si la usas como clave, y que no obtienes ninguna de las ventajas del internamiento. Son un mecanismo para datos, nunca para símbolos.

🧵

Cero copia para cargas grandes

Plantillas, assets y cuerpos de petición ya presentes en memoria del anfitrión dejan de duplicarse. El ahorro es proporcional al tamaño y se nota en la huella del proceso.

El recolector decide el final

El buffer vive hasta que la cadena muere, y eso lo determina un ciclo de recolección. Nunca liberes por tu cuenta; entrega la función de liberación y espera.

🔒

Inmutabilidad por contrato

Hasta ahora el runtime la garantizaba; con memoria externa la promete tu código C y nadie la verifica. Modificar los bytes corrompe hashes y claves de tabla.

📊

Contabilidad invisible

Esa memoria no entra en las heurísticas del recolector. Puedes agotar la memoria del proceso mientras el intérprete cree ir holgado, así que vigila el total tú mismo.

La pregunta de diseño, entonces, no es si la primitiva es rápida —lo es, por construcción, porque no copia nada— sino si tu programa tiene realmente el problema que resuelve. Si el texto que cruza la frontera entre el anfitrión y Lua se mide en kilobytes y se consume de inmediato, la copia clásica es más simple, más segura y suficientemente barata. Si se mide en megabytes, se reutiliza a lo largo de la vida del proceso y ya existe en una región que controlas, la cadena externa elimina una duplicación que ninguna optimización posterior habría podido recuperar.

Las cadenas externas cambian el contrato de memoria del lenguaje, no solo su rendimiento

Durante tres décadas Lua sostuvo un supuesto simple y muy rentable: toda cadena vive en memoria del asignador del estado, y por tanto el recolector conoce con exactitud cuánta memoria está en juego y puede ajustar sus ciclos en consecuencia. Las cadenas externas rompen ese supuesto de forma deliberada, y esa ruptura es justo lo que las hace valiosas y lo que exige criterio. Lo que ganas es enorme cuando el texto es grande: un servidor que sirve plantillas desde ficheros proyectados en memoria, un motor que expone assets ya cargados por el subsistema de recursos, un puente que entrega cuerpos de petición recibidos por el sistema de entrada y salida, todos ellos dejan de pagar una copia proporcional al tamaño y dejan de duplicar la huella de memoria del proceso; la cadena existe conceptualmente en Lua y físicamente en el anfitrión. Lo que pagas es que el recolector ya no ve esa memoria en su contabilidad, de modo que la presión que tu programa ejerce sobre el sistema puede ser muy superior a la que las heurísticas de recolección perciben, y un programa que retenga miles de cadenas externas puede agotar la memoria del proceso mientras el recolector cree estar holgado. Pagas también que el invariante de inmutabilidad, que hasta ahora el runtime garantizaba por construcción, pasa a ser una promesa que hace tu código C y que nadie verifica. Y pagas que son siempre cadenas largas, nunca internadas: perfectas para datos, inadecuadas para símbolos. La lectura correcta es que Lua no ha añadido una optimización, ha añadido un mecanismo de integración de bajo nivel dirigido al programa anfitrión, coherente con la naturaleza del lenguaje —existir dentro de otro— y con su tradición de dar la primitiva mínima y dejar la política en tus manos.

⚔️ Del byte al grafema y de vuelta
  1. Escribe una función que trunque un texto a n caracteres sin partir ninguna secuencia UTF-8, usando utf8.offset con sus dos valores de retorno.
  2. Implementa un validador de entrada apoyado en utf8.len que devuelva la posición exacta del primer byte inválido y muéstrala en un mensaje útil.
  3. Compara #, utf8.len y el número de símbolos que ves en pantalla para una cadena con un emoji compuesto. Explica las tres cifras distintas.
  4. Escribe la inversión de una cadena a nivel de punto de código con utf8.codes y comprueba en qué se diferencia de string.reverse sobre texto acentuado.
  5. Diseña sobre el papel la integración de un fichero de plantillas proyectado en memoria como cadena externa: quién posee el buffer, quién lo libera, cómo garantizas la inmutabilidad y qué presión invisible añades al recolector.