wandres.dev
STRINGS · inmutables e internadas

La cadena como valor inmutable

En Lua una cadena es un valor, no un buffer: cada concatenación fabrica una cadena nueva. Qué implica eso para el recolector, por qué concatenar dentro de un bucle degrada a coste cuadrático y cuál es el patrón acumulador canónico.

⏱ 16 min

En Lua una cadena no es un contenedor que puedas modificar: es un valor, tan inmutable como el número siete. No existe ninguna operación que altere una cadena ya creada; existen operaciones que fabrican otra. Esa decisión, que parece un detalle de biblioteca, gobierna el rendimiento de casi todo el código de texto que vas a escribir: explica por qué concatenar dentro de un bucle degrada a coste cuadrático, por qué el recolector de basura acaba siendo el protagonista silencioso de tus rutinas de formateo, y por qué el idioma canónico del lenguaje consiste en acumular fragmentos en una tabla y unirlos una sola vez al final.

🎯 Al terminar esta lección sabrás
  • Entender la cadena como valor inmutable y no como buffer mutable.
  • Calcular el coste real de concatenar dentro de un bucle y por qué es cuadrático.
  • Aplicar el patrón acumulador con table.concat y justificar por qué es lineal.
  • Reconocer los casos en los que la concatenación directa sigue siendo correcta.

Un valor, no un objeto

Una cadena en Lua se comporta exactamente como un número: se copia por valor desde el punto de vista semántico, se compara por contenido y no admite mutación in situ. No hay s[3] = "x", no hay método que recorte el original, no hay buffer al que añadir. Todo lo que parece modificación es en realidad construcción de un valor nuevo que luego reasignas a la variable.

local s = "hola"
local t = s          -- t y s designan el mismo valor
s = s .. " mundo"    -- no muta nada: crea otra cadena y la asigna a s

print(t)             --> hola
print(s)             --> hola mundo

La variable s cambió; la cadena "hola" no. Sigue viva mientras t la referencie y desaparecerá cuando nadie la nombre. Esta propiedad tiene tres consecuencias que conviene tener presentes desde el principio.

La primera es que la longitud es un dato almacenado, no calculado: el operador # sobre una cadena es de coste constante porque el objeto interno guarda su tamaño. La segunda es que una cadena puede usarse como clave de tabla sin ningún riesgo de que alguien la modifique a tus espaldas y corrompa el hash. La tercera, la que más duele en producción, es que cada operación textual es una asignación de memoria y, por tanto, trabajo futuro para el recolector.

local nombre = "ada"
print(#nombre)                    --> 3
print(nombre:upper())             --> ADA  (cadena nueva)
print(nombre)                     --> ada  (intacta)
print(("x"):rep(3))               --> xxx  (cadena nueva)

El operador de concatenación merece una mirada propia. Se escribe con dos puntos consecutivos, es asociativo por la derecha y acepta también números, que convierte a su representación textual antes de unirlos. Esa coerción es cómoda y a la vez traicionera: hace que una expresión mal escrita produzca texto en lugar de un error, y esconde la conversión numérica justo donde el formato importa.

print(1 .. 2)                     --> "12"  (coercion silenciosa)
print(type(1 .. 2))               --> string
print(0.1 + 0.2 .. "")            --> "0.3" con la precision por defecto

-- El espacio antes de los dos puntos no es opcional tras un numero:
-- 1..2 seria un numero mal formado para el analizador lexico.

Cuando uno de los operandos no es cadena ni número, Lua consulta el metamétodo __concat de su metatabla antes de rendirse con un error. Es el punto de extensión que permite que tus objetos participen en expresiones textuales, y conviene usarlo con moderación: un __concat que construye cadenas intermedias reintroduce por la puerta de atrás exactamente el problema que este nivel enseña a evitar.

local Ruta = {}
Ruta.__index = Ruta
Ruta.__concat = function(a, b)
  local ta = type(a) == "table" and a.valor or a
  local tb = type(b) == "table" and b.valor or b
  return setmetatable({valor = ta .. "/" .. tb}, Ruta)
end
Ruta.__tostring = function(r) return r.valor end

local raiz = setmetatable({valor = "/srv"}, Ruta)
print(tostring(raiz .. "www" .. "index.html"))   --> /srv/www/index.html

El error clásico: concatenar dentro de un bucle

Este es el fallo de rendimiento más repetido en código Lua, y no porque el programador sea descuidado, sino porque el código parece correcto y en lenguajes con cadenas mutables o con optimización de acumuladores lo sería.

-- Antipatrón: coste cuadrático en tiempo y en basura generada
local buf = ""
for i = 1, 100000 do
  buf = buf .. tostring(i) .. ","
end

Analicemos el coste. En la iteración i, la cadena acumulada mide aproximadamente i veces k bytes, siendo k la longitud media de cada fragmento. Concatenar exige reservar un bloque nuevo y copiar íntegramente lo ya acumulado. Sumando sobre las n vueltas, el número de bytes copiados crece con el cuadrado de n. Con cien mil iteraciones eso significa del orden de decenas de miles de millones de bytes movidos, y además unas doscientas mil cadenas intermedias condenadas a morir en cuanto se crea la siguiente.

flowchart LR
A[buffer de 1000 bytes] --> B[copia 1000 mas 6]
B --> C[copia 1006 mas 6]
C --> D[copia 1012 mas 6]
D --> E[cada vuelta copia todo lo anterior]
E --> F[basura intermedia proporcional a n al cuadrado]
La inmutabilidad no es lentitud: la lentitud es no entenderla

La reacción instintiva ante el bucle cuadrático es acusar al lenguaje de ineficiente por no ofrecer cadenas mutables, y es exactamente la lectura equivocada. La inmutabilidad es lo que permite que Lua interne las cadenas cortas, que la comparación de igualdad sea una comparación de punteros, que una clave de tabla nunca se corrompa, que dos módulos compartan el mismo literal sin copiarlo y que pasar una cadena a una función C sea gratis. Todo eso lo pagas con una regla única: no construyas incrementalmente lo que puedes construir de una vez. El coste cuadrático no lo genera la inmutabilidad, lo genera pedirle a un valor inmutable que finja ser un buffer. Cuando entiendes que .. no es un operador de anexado sino un constructor, el patrón correcto aparece solo: reúne los fragmentos en una estructura que sí es mutable —la tabla— y materializa el texto una única vez, cuando ya sabes exactamente cuánto mide. Es la misma disciplina que impone cualquier lenguaje funcional serio, y la recompensa es un modelo mental sin aliasing, sin copias defensivas y sin invariantes rotos por un tercero. Quien pelea contra la inmutabilidad escribe Lua lento; quien la usa como palanca escribe Lua que rinde y además razona.

El patrón acumulador

La solución idiomática invierte el orden del trabajo: primero se recogen los fragmentos en una tabla —que sí es mutable y crece de forma amortizada— y solo al final se ensambla la cadena definitiva con table.concat.

local partes = {}
for i = 1, 100000 do
  partes[#partes + 1] = tostring(i)
end
local buf = table.concat(partes, ",")

table.concat hace dos pasadas: una para sumar las longitudes de todos los fragmentos y otra para copiarlos en un único bloque del tamaño exacto. El resultado es lineal en el total de bytes y genera una sola cadena final en lugar de doscientas mil intermedias. En cargas reales la diferencia entre ambas versiones se mide en órdenes de magnitud, no en porcentajes.

Conviene, sin embargo, no convertir la regla en superstición. La máquina virtual de Lua compila una expresión con varias concatenaciones encadenadas a una única instrucción OP_CONCAT que opera sobre un rango de registros: a .. b .. c .. d en una sola expresión reserva un bloque, no tres. El problema nunca fue el operador, sino repetirlo a lo largo de iteraciones sucesivas con un acumulado que crece.

-- Perfectamente correcto: una expresión, una asignación de memoria
local linea = nombre .. ": " .. valor .. " (" .. unidad .. ")"

-- Igual de correcto y más legible cuando hay formato de por medio
local linea2 = string.format("%s: %s (%s)", nombre, valor, unidad)

La heurística práctica es sencilla: si el número de piezas se conoce al escribir el código, concatena o formatea directamente; si el número de piezas depende de un bucle o de datos de entrada, acumula en una tabla. Cuando el volumen es realmente grande y el consumo de memoria de la tabla intermedia preocupa, la alternativa es vaciar el acumulador por bloques hacia la salida.

local partes, salida = {}, {}
for i = 1, 1000000 do
  partes[#partes + 1] = tostring(i)
  if #partes == 4096 then                  -- vaciado periódico
    salida[#salida + 1] = table.concat(partes, ",")
    partes = {}
  end
end
salida[#salida + 1] = table.concat(partes, ",")
local texto = table.concat(salida, ",")
🧵

La tabla es tu buffer

Lua no tiene tipo buffer porque no lo necesita: la tabla crece de forma amortizada y table.concat materializa el resultado en una sola asignación del tamaño exacto.

⚖️

Una expresión, una asignación

Varias concatenaciones dentro de la misma expresión compilan a una única instrucción sobre un rango de registros. El coste aparece al repetir la operación en iteraciones sucesivas.

🗑️

Cada intermedia es basura

El bucle ingenuo no solo copia de más: alimenta al recolector con miles de objetos efímeros y dispara ciclos que compiten con el trabajo útil de tu programa.

📤

Vaciar por bloques

Cuando el volumen desborda la memoria disponible, alterna acumulación y volcado. Mantienes el coste lineal sin retener el resultado completo en memoria.

⚔️ Mide el abismo
  1. Escribe las dos versiones —bucle con .. y acumulador con table.concat— para cien mil enteros y cronométralas con os.clock.
  2. Repite la medición con mil, diez mil y cien mil iteraciones. Comprueba que el tiempo del acumulador crece de forma proporcional y el del bucle no.
  3. Instrumenta ambas con collectgarbage("count") antes y después: compara la memoria pico y el número de ciclos de recolección disparados.
  4. Sustituye la concatenación encadenada de una línea por string.format y verifica con un microbenchmark que la diferencia es marginal: el enemigo era el bucle, no el operador.
  5. Escribe una función que reciba una tabla de mil registros y devuelva un CSV. Hazla primero mal a propósito, luego bien, y quédate con la intuición de cuánto separa a las dos.