wandres.dev
STRINGS · inmutables e internadas

La biblioteca string sin patrones

Las funciones de la biblioteca estándar que no dependen de patrones: subcadena e índices negativos, longitud, repetición con separador, cambio de caja, conversión entre bytes y caracteres, y el motor de formateo de string.format.

⏱ 17 min

La biblioteca string se divide en dos mitades muy distintas. Una es el motor de patrones —find, match, gmatch, gsub— que merece un nivel entero para sí solo. La otra, la que abordamos aquí, es un conjunto de operaciones elementales sobre secuencias de bytes: extraer, medir, repetir, cambiar de caja, convertir entre número y carácter y, sobre todo, formatear. Son funciones sencillas de aprender y llenas de detalles que separan al que las usa del que las domina: índices negativos, el separador de rep, la dependencia de la configuración regional en upper, y un format cuyo especificador %q es en realidad un serializador con garantía de ida y vuelta.

🎯 Al terminar esta lección sabrás
  • Extraer subcadenas con índices positivos y negativos sin errores de contorno.
  • Usar string.rep con separador y string.byte con rango en lugar de bucles.
  • Formatear con string.format y conocer las restricciones de cada especificador.
  • Entender qué garantiza %q y por qué no es lo mismo que rodear de comillas.

Medir, extraer, repetir

La longitud se obtiene con el operador # o con string.len, que son equivalentes y ambos de coste constante: cuentan bytes, no caracteres. Extraer se hace con string.sub, cuya característica distintiva son los índices negativos, que cuentan desde el final: el uno indica el primer byte y el menos uno el último.

local s = "programacion"
print(#s)                 --> 12
print(s:sub(1, 4))        --> prog
print(s:sub(-4))          --> cion
print(s:sub(-6, -3))      --> maci
print(s:sub(5, 100))      --> ramacion   (recorta, no falla)
print(s:sub(9, 3))        --> cadena vacia

Los índices fuera de rango no producen error: se ajustan al intervalo válido, y un rango invertido devuelve la cadena vacía. Esa tolerancia elimina la mayor parte de las comprobaciones de contorno que otros lenguajes exigen, pero también significa que un error de cálculo se manifiesta como resultado silenciosamente vacío en lugar de como excepción.

string.rep repite un fragmento y admite un tercer argumento, el separador, que se intercala entre repeticiones pero no en los extremos. Es la forma correcta de construir separadores, sangrados y plantillas repetitivas sin bucles.

print(("ab"):rep(3))          --> ababab
print(("ab"):rep(3, "-"))     --> ab-ab-ab
print(("-"):rep(40))          --> regla horizontal
print(("?"):rep(5, ", "))     --> ?, ?, ?, ?, ?

El cambio de caja lo hacen string.upper y string.lower, y aquí conviene una advertencia: operan byte a byte siguiendo la configuración regional del sistema, lo que en la práctica significa que solo transforman de forma fiable el rango ASCII. Una vocal acentuada codificada en UTF-8 ocupa dos bytes y ninguno de los dos es una letra a ojos de la función, así que sale intacta. No existe cambio de caja consciente de Unicode en la biblioteca estándar.

La conversión entre bytes y caracteres la cubren string.byte y string.char. La primera acepta un rango y devuelve múltiples valores, lo que permite inspeccionar sin crear subcadenas intermedias; la segunda recibe una lista de códigos y construye la cadena correspondiente.

print(("A"):byte())               --> 65
print(("hola"):byte(1, -1))       --> 104  111  108  97
print(string.char(104, 111, 108, 97))  --> hola
🧵

Métodos sobre literales

Toda cadena tiene una metatabla cuyo campo __index apunta a la biblioteca string. Por eso s:sub(1, 3) funciona; sobre un literal necesitas paréntesis o corchetes dobles para desambiguar del análisis léxico.

🔍

byte con rango evita basura

Inspeccionar con string.byte sobre el original no crea objetos; string.sub sí. En bucles calientes la diferencia es medible.

🔄

reverse invierte bytes

string.reverse da la vuelta a la secuencia de bytes. Sobre texto ASCII hace lo esperado; sobre UTF-8 produce basura, porque parte las secuencias multibyte.

🧩

La sintaxis de llamada sin paréntesis

Cuando el único argumento es un literal de cadena o un constructor de tabla, los paréntesis son opcionales. De ahí que require "modulo" y print [[texto]] sean válidos.

El motor de formateo

string.format sigue el modelo de la familia printf de C y es la herramienta correcta siempre que el texto tenga estructura fija y contenido variable: separa la plantilla de los datos, evita concatenaciones encadenadas y hace explícita la conversión de cada valor.

print(string.format("%s tiene %d anos", "Ada", 36))
print(string.format("%5.2f", math.pi))          --> " 3.14"
print(string.format("%-10s|", "izq"))           --> "izq       |"
print(string.format("%08.3f", 2.5))             --> 0002.500
print(string.format("%x  %X  %o", 255, 255, 8)) --> ff  FF  10

Los especificadores esenciales son %d para enteros, %s para cualquier valor convertible a texto, %f, %g y %e para reales, %x y %o para bases, %c para un byte a partir de su código, %a para el formato hexadecimal exacto de un real, y %% para el símbolo literal. Los modificadores de anchura, precisión, relleno con ceros y alineación a la izquierda se comportan como en C.

Hay dos detalles que sorprenden a quien llega de otros lenguajes. El primero: desde la separación entre enteros y reales, %d exige un valor con representación entera exacta, y pasarle un real con parte fraccionaria es un error en tiempo de ejecución, no un truncamiento silencioso. El segundo: %s invoca la conversión estándar a texto, lo que significa que respeta el metamétodo __tostring de tus objetos y los formatea como tú decidiste.

-- string.format("%d", 3.5)   --> error: number has no integer representation
print(string.format("%d", 3.0))        --> 3
print(string.format("%.0f", 3.5))      --> 4

local punto = setmetatable({x = 1, y = 2},
  {__tostring = function(p) return "(" .. p.x .. ", " .. p.y .. ")" end})
print(string.format("punto: %s", punto))   --> punto: (1, 2)
El especificador que serializa: por qué %q no es simplemente añadir comillas

%q produce una representación del valor que, al ser leída de nuevo por el intérprete de Lua, reconstruye exactamente el valor original. Esa promesa de ida y vuelta parece trivial y no lo es en absoluto. Sobre una cadena, escapa las comillas, las barras invertidas y los saltos de línea, y emite los bytes de control como escapes decimales cuidando de que un dígito posterior no los alargue por accidente, un detalle que casi todas las implementaciones caseras de escapado olvidan y que corrompe los datos justo cuando un byte de control precede a una cifra. Sobre un entero emite el número tal cual. Sobre un real no emite decimales legibles sino notación hexadecimal de coma flotante, porque es la única representación textual que garantiza recuperar el mismo patrón de bits sin perder el último dígito significativo; la belleza tipográfica se sacrifica a la corrección numérica, y es la decisión acertada. Sobre los valores especiales del punto flotante emite expresiones que el intérprete puede evaluar. La consecuencia práctica es que %q es el ladrillo con el que se construye un serializador de datos Lua en veinte líneas, sin dependencias y sin ambigüedades de codificación: recorres la tabla, emites claves y valores con %q, y obtienes un fichero que es a la vez datos y código Lua válido. Es un ejemplo perfecto del diseño del lenguaje: en lugar de incluir una biblioteca de serialización, incluye la primitiva exacta que permite escribirla.

Una técnica que conviene tener en el repertorio es construir la plantilla en tiempo de ejecución cuando la anchura depende de los datos. Como la plantilla es a su vez una cadena, se genera con el mismo string.format y se aplica después, lo que evita concatenar celda a celda.

local function columna(ancho)
  return string.format("%%-%ds", ancho)      -- produce por ejemplo "%-12s"
end

local plantilla = columna(12) .. columna(8) .. "%s"
print(string.format(plantilla, "nombre", "tipo", "valor"))

Y para cerrar el círculo con el nivel anterior: string.format produce una cadena por llamada. Formatear mil filas dentro de un bucle y concatenarlas con el operador reintroduce el coste cuadrático; formatearlas y acumularlas en una tabla para unirlas al final mantiene el coste lineal. Las dos herramientas son complementarias, no alternativas.

Contornos y errores frecuentes

Las funciones de esta mitad de la biblioteca son tolerantes, y esa tolerancia genera una clase de fallos que no se manifiestan como excepción sino como resultado incorrecto. Vale la pena inventariar los cuatro más habituales.

El primero es el índice fuera de rango que no falla. Un cálculo erróneo en string.sub devuelve la cadena vacía en lugar de avisar, así que la validación tiene que ser tuya cuando los índices provienen de datos externos.

local function trozo(s, i, n)
  assert(i >= 1 and i <= #s, "indice fuera de rango")
  return s:sub(i, i + n - 1)
end

El segundo es la conversión implícita en %s. El especificador acepta cualquier valor y lo convierte, incluido nil, de modo que un dato ausente se cuela como el texto de un valor nulo en mitad de un informe sin que nada proteste. Si el campo es obligatorio, comprueba antes.

print(string.format("usuario: %s", nil))     --> usuario: nil
print(string.format("usuario: %s", {}))      --> usuario: table: 0x...

El tercero es el desajuste entre plantilla y argumentos. Pasar menos valores de los que la plantilla espera sí produce error, pero pasar de más no: los sobrantes se descartan en silencio, y ese silencio esconde plantillas desactualizadas tras un refactor.

El cuarto es el cambio de caja sobre texto no ASCII, ya mencionado y suficientemente frecuente como para repetirlo: string.upper no toca las secuencias multibyte, así que un título en español sale a medio transformar. Si tu programa muestra texto a personas, esa función pertenece a una biblioteca de Unicode, no a la estándar.

print(("año nuevo"):upper())        --> AñO NUEVO  (la enne queda intacta)
print(("ANO"):lower())              --> ano

Cierra el inventario una advertencia sobre string.reverse y # combinados con datos binarios: ambos son perfectamente correctos sobre bytes y perfectamente engañosos sobre texto humano. La regla que resume este nivel entero es que la biblioteca string es una biblioteca de bytes, exacta y rápida en ese dominio, y que en cuanto tu dato es texto de un idioma la responsabilidad de interpretarlo pasa a ser tuya.

⚔️ Construye un formateador y un serializador
  1. Escribe una función que reciba una tabla de filas y devuelva una tabla de texto con columnas alineadas, calculando el ancho de cada columna y usando string.format con anchura dinámica.
  2. Implementa una regla horizontal con string.rep y otra con separador entre repeticiones. Compara ambas con la versión escrita a base de concatenación en bucle.
  3. Escribe un volcado hexadecimal: dieciséis bytes por línea, offset a la izquierda, representación imprimible a la derecha. Usa string.byte con rango y nunca string.sub.
  4. Serializa una tabla anidada de datos usando %q para todos los valores escalares y comprueba que load reconstruye una tabla idéntica, reales incluidos.
  5. Provoca a propósito el error de %d con un real fraccionario y decide en qué punto de tu código conviene redondear explícitamente.