wandres.dev
EL LENGUAJE · minimalismo como doctrina

Las versiones y el cisma

Por qué Lua rompe compatibilidad entre versiones menores y por qué esa política es coherente, qué trajo cada versión de 5.1 a 5.5, y cómo el estancamiento de LuaJIT en 5.1 partió el ecosistema en dos mundos.

⏱ 15 min

Casi todos los lenguajes que conoces tratan la compatibilidad hacia atrás como un valor sagrado, y quien la rompe paga un precio reputacional durante años. Lua hace lo contrario de forma deliberada y sistemática: cada versión menor es, en rigor, un lenguaje distinto que puede convivir instalado junto a los demás. Esa política parece temeraria hasta que entiendes quién es el usuario real de Lua, y entonces resulta ser la única coherente. Su consecuencia más visible, sin embargo, es un cisma que lleva quince años sin cerrarse.

🎯 Al terminar esta lección sabrás
  • Explicar por qué la política de compatibilidad de Lua es consistente con su modelo de despliegue.
  • Situar los cambios centrales de 5.1, 5.2, 5.3, 5.4 y 5.5 y qué problema resolvía cada uno.
  • Describir el origen y el estado actual del cisma con LuaJIT y qué dialecto usa cada anfitrión.
  • Decidir con criterio qué versión elegir para un proyecto nuevo y cómo escribir código que sobreviva.

Por qué Lua puede permitirse romper

El argumento se sostiene en un hecho que ya conoces: Lua casi nunca se instala, se incrusta. Cuando escribes un plugin de Neovim o un script de Redis, la versión del intérprete no la elige tu gestor de paquetes ni el administrador de la máquina: la eligió el anfitrión cuando enlazó la biblioteca, y viaja dentro de su binario. No existe realmente un Lua del sistema que todo el mundo comparta y que nadie pueda actualizar sin romper a los demás.

De ahí salen tres consecuencias que invierten la aritmética habitual. Primera: las versiones coexisten sin conflicto. Una máquina puede tener a la vez lua5.1, lua5.4 y lua5.5, con bibliotecas y árboles de módulos separados, porque el nombre de la biblioteca compartida incluye la versión. Segunda: el coste de romper recae sobre quien decide actualizar, no sobre terceros, porque nadie te obliga a mover el anfitrión. Tercera, y decisiva: mantener la compatibilidad tiene un coste en tamaño y en complejidad del núcleo, y ya sabes cuál es el presupuesto de Lua. Sostener para siempre setfenv junto a _ENV, o el operador de longitud antiguo junto al nuevo, engorda el intérprete y complica el manual.

El equipo es además diminuto, tres personas en una universidad, sin una fundación con recursos para mantener ramas antiguas. Cada versión menor recibe correcciones durante un tiempo y la energía se concentra en la rama actual. El manual documenta de forma explícita, en una sección de incompatibilidades, todo lo que se rompió respecto de la versión anterior, y esa sección es lectura obligatoria antes de cualquier migración.

La coexistencia no es una promesa vaga, está en los nombres. Los binarios se instalan como lua5.1 y lua5.4, las bibliotecas compartidas llevan la versión en su nombre de objeto, las cabeceras viven en directorios separados y los árboles de módulos de LuaRocks también se separan por versión.

ls /usr/lib/x86_64-linux-gnu/liblua5.*.so   # varias a la vez, sin conflicto
ls /usr/include/lua5.4/ /usr/include/lua5.1/
lua5.1 -v ; lua5.4 -v

Nada de esto es gratis. El precio lo paga el ecosistema de bibliotecas, que tiene que declarar compatibilidad por versión, y lo pagas tú cuando descubres que la biblioteca que necesitas nunca se actualizó más allá de 5.1. Conviene además matizar que la ruptura casi nunca es masiva: cada versión rompe unas pocas cosas concretas y documentadas, y el intérprete puede compilarse con opciones que reactivan parte del comportamiento antiguo durante una migración. Lo que no existe es la promesa de que el código de hace diez años funcione sin mirarlo.

El recorrido de 5.1 a 5.5

flowchart LR
A[5.0 en 2003: VM de registros, corrutinas, licencia MIT] --> B[5.1 en 2006: require y package, GC incremental]
B --> C[5.2 en 2011: _ENV, goto, fuera el sistema de modulos antiguo]
C --> D[5.3 en 2015: subtipo entero, operadores de bits, utf8]
D --> E[5.4 en 2020: GC generacional, atributos const y close]
E --> F[5.5 en diciembre de 2025: globales declarados, arrays compactos]
B --> G[LuaJIT 2.0 en 2012: compilador de trazas y FFI]
G --> H[LuaJIT 2.1: congelado en la semantica de 5.1]
style B fill:#f9e2af,color:#11111b
style F fill:#89b4fa,color:#11111b
style H fill:#f38ba8,color:#11111b

Lua 5.1, en 2006. La versión más longeva y la más consecuente. Trajo el sistema de módulos que aún reconoces: require, package.path, package.cpath y la caché de package.loaded. Trajo el recolector incremental, que reparte el trabajo del ciclo en pasos pequeños para no producir pausas largas. Y consolidó el operador de longitud como operador del lenguaje. Es la versión sobre la que se congeló medio ecosistema.

Lua 5.2, en 2011. El cambio conceptual es _ENV: el entorno global deja de ser una función mágica del intérprete y pasa a ser una variable superior implícita que envuelve todo fragmento de código. Con eso desaparecen setfenv y getfenv y los sandboxes se construyen sobre una idea uniforme. Llega el salto goto con etiquetas, llegan las tablas efímeras en el recolector, y se retira la función de declaración de módulos de 5.1, que contaminaba el espacio global.

-- 5.1: cambiar el entorno era una operacion especial sobre una funcion
setfenv(f, entorno)

-- 5.2 en adelante: el entorno es una variable ordinaria, y nada mas
local _ENV = entorno
print = nil        -- esto escribe en entorno, no en el global real

Ese cambio parece cosmético y es el más profundo de la lista, porque elimina un concepto del lenguaje en lugar de añadir uno: después de 5.2 no hay entornos, hay variables superiores.

Lua 5.3, en 2015. La ruptura numérica. Antes de entrar en el detalle conviene ver por qué duele tanto:

-- en 5.2 esto imprime 5, un flotante
-- en 5.3 y posteriores imprime 5.0 con division normal, y 5 con division entera
print(10 / 2)
print(10 // 2)
print(math.type(10 / 2), math.type(10 // 2))   -- float   integer

Ningún programa falla ahí, y ese es exactamente el problema: el resultado sigue siendo correcto y su representación textual cambia, así que lo que se rompe son las claves de tablas, los identificadores concatenados y las comparaciones con datos externos.

Hasta aquí todo número era un flotante de doble precisión; a partir de aquí el tipo número tiene dos subtipos, entero de sesenta y cuatro bits y flotante, con reglas explícitas de conversión y de desbordamiento. Llegan los operadores de bits como operadores del lenguaje, no como biblioteca; llega la división entera; llega la biblioteca utf8 y llegan el empaquetado y desempaquetado binario de cadenas. Es la versión que más código antiguo rompió en silencio, porque una división que antes daba un flotante ahora puede dar otra cosa.

Lua 5.4, en 2020. Modo generacional del recolector, además del incremental. Y dos atributos de variable local que parecen menores y cambian el estilo: <const>, que el compilador aprovecha para propagar el valor, y <close>, que convierte una variable en un recurso con cierre determinista invocando el metamétodo correspondiente al salir del ámbito. Es la respuesta de Lua al problema de liberar recursos sin depender del recolector.

local function abrir(ruta)
  local f <close> = setmetatable({}, {
    __close = function() print("cerrando " .. ruta) end,
  })
  return leer(f)          -- se cierra al salir, incluso si hay un error
end

Lua 5.5, en diciembre de 2025. La novedad más discutida es la declaración explícita de variables globales, que permite al compilador señalar el uso de un global no declarado y ataca de raíz el error tipográfico silencioso, que era el defecto más caro del lenguaje. Por debajo hay dos cambios de representación con efecto directo en memoria y en integración: la parte array de las tablas pasa a almacenarse de forma compacta, reduciendo el consumo en estructuras grandes, y la API en C incorpora las cadenas externas, que permiten crear una cadena cuyo búfer gestiona la aplicación anfitriona en lugar de copiarlo dentro del estado. Junto a eso, la habitual poda de opciones de compatibilidad heredadas.

El cisma con LuaJIT

En 2005 Mike Pall publica LuaJIT, y en 2012 la rama 2.0 con la arquitectura que lo hizo legendario: un compilador de trazas que detecta bucles calientes y compila el camino real de ejecución a código máquina, y una FFI que permite declarar estructuras y funciones de C con su sintaxis nativa y llamarlas sin escribir una sola línea de código de enlace.

-- la FFI de LuaJIT: la frontera con C deja de tener coste de escritura
local ffi = require("ffi")
ffi.cdef[[
  int puts(const char *s);
]]
ffi.C.puts("sin una linea de codigo de enlace")

Conviene entender por qué la compilación por trazas encaja tan bien con Lua. Un lenguaje dinámico sin tipos declarados no puede compilarse bien de forma estática, porque no se sabe si una suma es de enteros, de flotantes o de objetos con metamétodo. Un compilador de trazas resuelve eso observando la ejecución real: graba la secuencia concreta de operaciones de un bucle caliente, especializa el código máquina para los tipos que de hecho aparecieron, e inserta comprobaciones baratas que salen del código compilado si alguna suposición se rompe. En un lenguaje con un modelo de valores tan pequeño como el de Lua, esas suposiciones aciertan casi siempre.

El resultado es un intérprete que en cargas numéricas compite con C y que reescribió las expectativas de rendimiento de todo el nicho. El problema es la fecha: LuaJIT fijó su semántica en Lua 5.1, y ahí sigue. Mike Pall se apartó del mantenimiento en 2015 y el proyecto continúa como esfuerzo comunitario, con la rama 2.1 en publicación continua, ampliada con extensiones seleccionadas de 5.2 y con goto, pero sin lo esencial de 5.3 en adelante.

Esa congelación tiene consecuencias muy concretas. En LuaJIT no hay subtipo entero: todo número es un flotante de doble precisión, y los enteros de sesenta y cuatro bits solo aparecen como datos de la FFI. No hay operadores de bits del lenguaje, sino la biblioteca bit. No hay _ENV, sino setfenv y getfenv. No hay atributos <const> ni <close>, ni recolector generacional, ni utf8. Escribir una biblioteca que funcione en las dos ramas obliga a renunciar a todo lo posterior a 2006 o a bifurcar el código.

Y sin embargo el ecosistema que más importa vive ahí. OpenResty y Kong usan LuaJIT. Neovim usa LuaJIT. Tarantool usa LuaJIT. Redis y las extensiones de muchos juegos usan la rama 5.1 de PUC-Rio. Roblox se salió por otra puerta y mantiene Luau, un derivado propio de 5.1 con tipado gradual y su propio recolector. El resultado es que el mundo Lua está partido en dos hemisferios que comparten sintaxis básica y no comparten futuro.

Cómo se convive con esto

La decisión práctica se reduce a dos preguntas encadenadas. La primera: ¿hay un anfitrión? Si escribes para Neovim, para OpenResty o para Redis, la versión ya está decidida y tu única tarea es conocer su dialecto exactamente. La segunda, si no hay anfitrión: ¿el rendimiento numérico es el factor dominante? Si lo es, LuaJIT sigue siendo la elección racional pese a su antigüedad. Si no lo es, un proyecto nuevo debería empezar en 5.4 o 5.5 y disfrutar de los enteros, del cierre determinista y de los globales declarados.

Escribir código portable entre ramas es posible con disciplina y siempre incompleto. La detección de versión se hace con la variable _VERSION, la carga de funcionalidad opcional con pcall alrededor de require, y la diferencia numérica se aísla en un módulo de compatibilidad en lugar de esparcirse.

local es_jit = type(jit) == "table"
local tiene_enteros = math.type ~= nil        -- 5.3 en adelante

local bits
if tiene_enteros then
  bits = load("return function(a, b) return a & b end")()
else
  bits = require("bit").band                  -- LuaJIT y 5.1 con biblioteca
end

Fíjate en el detalle incómodo de ese fragmento: los operadores de bits son sintaxis, no funciones, así que un intérprete antiguo ni siquiera puede analizar el archivo que los contiene. Por eso hay que esconderlos detrás de load, en una cadena que solo se compila cuando la versión lo permite. Ese es el nivel de fricción real de la portabilidad entre ramas, y es la razón por la que la mayoría de proyectos serios eligen un hemisferio y se quedan en él.

La compatibilidad hacia atrás no es una virtud, es una elección con precio

La industria ha convertido la compatibilidad hacia atrás en un axioma moral, y es solo una decisión de ingeniería con un balance concreto. Lo que hace Lua no es descuido: es la consecuencia lógica de que el consumidor de una versión sea una aplicación que la incrusta y la fija, no un ecosistema compartido en el que todos actualizan a la vez. Cambia esa premisa y cambia la respuesta correcta. Compáralo con el otro extremo, un lenguaje que arrastra treinta años de decisiones que hoy nadie tomaría porque romperlas era impensable: ese lenguaje también pagó, solo que en una moneda menos visible, la de la complejidad permanente y la de las cuatro formas de hacer lo mismo. Lua pagó en la moneda visible, la de la migración dolorosa, y a cambio conserva un manual de cien páginas después de tres décadas. Ninguna de las dos posturas es gratis y ninguna es universalmente superior. Lo que sí es un error, y lo verás en muchos equipos, es adoptar la política sin haber identificado quién es el usuario real de la interfaz. La pregunta que hay que responder antes de prometer estabilidad no es cuánto valoras la estabilidad, sino quién queda atrapado cuando rompes y si esa persona tiene la capacidad de decidir cuándo actualizar. El cisma de LuaJIT es la prueba empírica del otro lado de la moneda: cuando una implementación se queda quieta y el ecosistema la necesita, el estancamiento se propaga durante quince años.

⚔️ Auditoría de dialectos
  1. Averigua la versión exacta de Lua que usan tres programas que tengas instalados y comprueba si son LuaJIT o PUC-Rio. Explica en cada caso por qué eligieron esa.
  2. Escribe un fragmento que se comporte de forma distinta en 5.1 y en 5.4 por culpa del subtipo entero, y explica exactamente qué operación cambia el resultado.
  3. Lee la sección de incompatibilidades del manual de 5.4 y la de 5.5, y clasifica cada punto según si rompe código de usuario, código de la API en C o ambos.
  4. Implementa un módulo de compatibilidad que ofrezca operaciones de bits funcionando tanto en LuaJIT como en 5.4, sin que el archivo falle al analizarse en ninguno de los dos.
  5. Argumenta por escrito, para un proyecto real tuyo, qué rama elegirías y qué tendría que ocurrir para que cambiaras de opinión. Incluye el coste estimado de la migración.